From 94a9239cd54098b3b5eb00fb5c98336c67884e01 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 26 Sep 2026 11:42:28 +0100 Subject: [PATCH 01/65] [PostIt][Blogs] Margins and wrap mode #55 --- src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 278 +++++++++++------- 1 file changed, 165 insertions(+), 113 deletions(-) diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index a20f27195..37c5bed64 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -1,93 +1,110 @@ - + + Margin="5" + Padding="5" + HorizontalScrollBarVisibility="Disabled" + VerticalScrollBarVisibility="Visible"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -95,30 +112,49 @@ - - + + - - - - + + + + - + + + - - + + @@ -129,21 +165,38 @@ - - + + - + /> - + - + @@ -154,12 +207,11 @@ - + - - + + From c1f447636bf88a7ee2900616f5dae887d5e9ba19 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 26 Sep 2026 18:02:53 +0100 Subject: [PATCH 02/65] [PostIT] #55 nearly ok, it-s better ... --- .vscode/settings.json | 6 +- src/PostIt/PostIt.Android/MainActivity.cs | 5 +- .../Helpers/ServiceCollectionHelpers.cs | 2 + src/PostIt/PostIt/ViewLocator.cs | 1 + .../ViewModels/Layout/AboutPageViewModel.cs | 77 +++++++++++++++++++ .../Layout/SessionStatusViewModel.cs | 7 ++ src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 7 +- .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 67 +++++++++++++++- .../PostIt/Views/Layout/AboutPage.axaml | 38 +++++++++ .../PostIt/Views/Layout/AboutPage.axaml.cs | 11 +++ .../Views/Layout/SessionStatusBanner.axaml | 3 + 11 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 src/PostIt/PostIt/ViewModels/Layout/AboutPageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/Layout/AboutPage.axaml create mode 100644 src/PostIt/PostIt/Views/Layout/AboutPage.axaml.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 7732a77b1..e6f2aee1d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,5 +46,9 @@ } }, "dotnet.defaultSolution": "yavsc.sln", - "axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln" + "axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln", + "chat.tools.terminal.autoApprove": { + "dotnet build": true, + "dotnet test": true + } } diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 549100804..f8fd5a0fd 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -2,6 +2,7 @@ using Android.App; using Android.Content; using Android.Content.PM; +using Android.Views; using AndroidX.Core.Provider; using AndroidX.Emoji2.Text; using Avalonia.Android; @@ -15,7 +16,9 @@ namespace PostIt.Android; Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, - ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] + WindowSoftInputMode = SoftInput.AdjustResize, + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode) + ] public class MainActivity : AvaloniaMainActivity { /// diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 9616e7089..f6fcc3e3b 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -53,6 +53,7 @@ public static class ServiceCollectionHelpers // the navigation stack, each bound to a fresh // SettingsViewModel and missing any in-flight edits. services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -82,6 +83,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(userFilesClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 687a7cbd2..ae4662072 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -41,6 +41,7 @@ public class ViewLocator : IDataTemplate { BlogsViewModel => services.GetRequiredService(), Settings => services.GetRequiredService(), + AboutPageViewModel => services.GetRequiredService(), HomePageViewModel => services.GetRequiredService(), ActivitiesPageViewModel => services.GetRequiredService(), CommandFormsPageViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/Layout/AboutPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/AboutPageViewModel.cs new file mode 100644 index 000000000..1082e4828 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Layout/AboutPageViewModel.cs @@ -0,0 +1,77 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace PostIt.ViewModels; + +/// +/// ViewModel de la page « À propos de PostIt ». Page statique : +/// version de l'application, description, licence et liens vers le +/// dépôt Forgejo et son suivi de tickets. +/// +public partial class AboutPageViewModel : ViewModelBase +{ + public const string RepositoryUrl = "https://forgejo.pschneider.fr/notazof/yavsc"; + public const string IssuesUrl = RepositoryUrl + "/issues"; + + public AboutPageViewModel() + { + Version = ReadVersion(); + } + + public string Version { get; } + + public string Description { get; } = + "PostIt est le client de la plateforme Yavsc : blog, devis, " + + "demandes de prestations et fichiers personnels."; + + public string License { get; } = + "Licence WTFPL — Do What The Fuck You Want To Public License, version 2"; + + public override bool CanNavigateNext + { + get => false; + protected set => throw new NotImplementedException(); + } + + public override bool CanNavigatePrevious + { + get => true; + protected set => throw new NotImplementedException(); + } + + [RelayCommand] + private Task OpenRepository() => OpenUrlAsync(RepositoryUrl); + + [RelayCommand] + private Task OpenIssues() => OpenUrlAsync(IssuesUrl); + + private static async Task OpenUrlAsync(string url) + { + var app = (App)App.Current!; + var topLevel = TopLevel.GetTopLevel(app.View); + if (topLevel is not null) + { + await topLevel.Launcher.LaunchUriAsync(new Uri(url)).ConfigureAwait(true); + } + } + + private static string ReadVersion() + { + var assembly = typeof(AboutPageViewModel).Assembly; + var info = assembly + .GetCustomAttribute() + ?.InformationalVersion; + if (!string.IsNullOrEmpty(info)) + { + // "1.1.0-estimate.1+364.Branch.x.Sha.y" → "1.1.0-estimate.1" + var plus = info.IndexOf('+'); + return plus > 0 ? info[..plus] : info; + } + + return assembly.GetName().Version?.ToString() ?? "inconnue"; + } +} diff --git a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs index f2496b857..f55d1d9fb 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs @@ -143,4 +143,11 @@ public partial class SessionStatusViewModel : ViewModelBase await app.PushPageAsync(app.ServiceProvider!.GetRequiredService()).ConfigureAwait(true); } + [RelayCommand] + internal async Task OpenAbout() + { + var app = (App)App.Current!; + await app.PushPageAsync(app.ServiceProvider!.GetRequiredService()).ConfigureAwait(true); + } + } diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 37c5bed64..65c01817d 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -175,6 +175,7 @@ PlaceholderText="Title" Text="{Binding DraftTitle, Mode=TwoWay}" /> + - 0) + { + double visibleHeight = this.Bounds.Height - keyboardHeight; + if (visibleHeight > 0) + { + MainContainer.Clip = new Avalonia.Media.RectangleGeometry( + new Avalonia.Rect(0, 0, this.Bounds.Width, visibleHeight)); + } + } + else + { + MainContainer.Clip = null; // Réinitialise le clip quand le clavier se ferme + } + */ + } + + private void OnInputPaneStateChanged(object? sender, InputPaneStateEventArgs e) + { + // e.EndRect contient le rectangle final du clavier virtuel + // e.EndRect.Height vous donne sa hauteur exacte en pixels indépendants du périphérique (pixels logiques) + UpdateLayoutForKeyboard(e.EndRect.Height); + } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); diff --git a/src/PostIt/PostIt/Views/Layout/AboutPage.axaml b/src/PostIt/PostIt/Views/Layout/AboutPage.axaml new file mode 100644 index 000000000..5ad594e38 --- /dev/null +++ b/src/PostIt/PostIt/Views/Layout/AboutPage.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + public class YavscApiClientTests { + [Fact] + public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry() + { + using var apiServer = new StubApiServer(forceFirstRequest: true); + await apiServer.StartAsync(); + var tokensPath = TokensPath(); + try + { + await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath)); + await Assert.ThrowsAsync(() => + client.GetAnonymousAsync>( + apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken)); + Assert.Equal(1, apiServer.RequestCount); + Assert.Empty(apiServer.SeenBearers); + } + finally + { + File.Delete(tokensPath); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession) + { + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + var tokensPath = TokensPath(); + try + { + var store = new TokenStore(tokensPath); + if (hasSession) + store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", + DateTimeOffset.UtcNow.AddHours(1), null)); + await using var client = new YavscApiClient(new Settings(), store); + + var posts = await client.GetAnonymousAsync>( + apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken); + + Assert.Equal(2, posts.Count); + Assert.Equal(1, apiServer.RequestCount); + Assert.Empty(apiServer.SeenBearers); + } + finally + { + File.Delete(tokensPath); + } + } + private static int GetFreePort() { var l = new TcpListener(IPAddress.Loopback, 0); diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 22885aca8..c88cbaac5 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl); var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl); + var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl); var userDirectory = new UserDirectory(userSearchClient); var reverseGeocoding = new NominatimReverseGeocodingService(); @@ -93,6 +94,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(estimateClient); services.AddSingleton(frontClient); services.AddSingleton(resourceUsageClient); + services.AddSingleton(notificationsClient); services.AddSingleton(userFilesClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 7dfdb3bae..296e8b3b1 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable private readonly SemaphoreSlim _refreshGate = new(1, 1); private RefreshTokenRecord? _tokens; + private static readonly HttpRequestOptionsKey AnonymousRequest = new("PostIt.AnonymousRequest"); + + public virtual async Task GetAnonymousAsync(string path, CancellationToken ct = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Options.Set(AnonymousRequest, true); + using var response = await Http.SendAsync(request, ct).ConfigureAwait(false); + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + return await ReadJsonAsync(response, ct).ConfigureAwait(false); + } public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null) { @@ -614,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - if (_owner._tokens is not null) + if (_owner._tokens is not null + && !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous)) request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", _owner._tokens.AccessToken); return base.SendAsync(request, cancellationToken); diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 6a29afbc6..da81d9152 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -1,11 +1,16 @@ using System; +using System.Collections.ObjectModel; +using System.ComponentModel; using System.Threading.Tasks; using Avalonia; +using Avalonia.Threading; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using PostIt.Services; using PostIt.ViewModels.Chat; +using Yavsc.Abstract.Models.Messaging; using Yavsc.Api.Client; + namespace PostIt.ViewModels; public class HomePageViewModel : ViewModelBase @@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase public YavscApiClient Api { get; } public Settings Settings { get; } public SessionStatusViewModel SessionStatus { get; } + public ObservableCollection Notifications { get; } = new(); + private readonly NotificationsApiClient? _notificationsClient; + private int _notificationsLoadVersion; + private bool _isLoadingNotifications; + private string? _notificationsError; + private bool _notificationsLoaded; + + public bool IsLoadingNotifications + { + get => _isLoadingNotifications; + private set => SetProperty(ref _isLoadingNotifications, value); + } + + public string? NotificationsError + { + get => _notificationsError; + private set => SetProperty(ref _notificationsError, value); + } + + public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0; private string _welcomeText = "Welcome to PostIt!"; public string WelcomeText @@ -24,11 +49,16 @@ public class HomePageViewModel : ViewModelBase public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); } - public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus) + public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus, + NotificationsApiClient notificationsClient) { Api = api; Settings = settings; SessionStatus = sessionStatus; + _notificationsClient = notificationsClient; + SessionStatus.PropertyChanged += OnSessionStatusChanged; + RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync, + () => _notificationsClient is not null); OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); @@ -55,6 +85,59 @@ public class HomePageViewModel : ViewModelBase public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; } public IAsyncRelayCommand OpenMyFiles { get; } + public IAsyncRelayCommand RefreshNotificationsCommand { get; } + + public async Task RefreshNotificationsAsync() + { + if (_notificationsClient is null) + throw new InvalidOperationException("Notifications client is not available."); + + var version = ++_notificationsLoadVersion; + Notifications.Clear(); + NotificationsError = null; + _notificationsLoaded = false; + OnPropertyChanged(nameof(HasNoNotifications)); + IsLoadingNotifications = true; + try + { + var notifications = SessionStatus.IsLoggedIn + ? await _notificationsClient.GetCurrentAsync() + : await _notificationsClient.GetPublicAsync(); + if (version != _notificationsLoadVersion) + return; + if (notifications is null) + throw new InvalidOperationException("The notifications response is empty."); + + foreach (var notification in notifications) + Notifications.Add(notification); + _notificationsLoaded = true; + } + catch (Exception ex) + { + if (version == _notificationsLoadVersion) + NotificationsError = $"Impossible de charger les notifications : {ex.Message}"; + } + finally + { + if (version == _notificationsLoadVersion) + { + IsLoadingNotifications = false; + OnPropertyChanged(nameof(HasNoNotifications)); + } + } + } + + private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null) + return; + + // Logout can complete on a worker thread; collections and bindings belong to the UI thread. + if (Dispatcher.UIThread.CheckAccess()) + _ = RefreshNotificationsAsync(); + else + Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync()); + } private async Task OpenActivitiesAsync() { @@ -228,7 +311,7 @@ public class HomePageViewModel : ViewModelBase /// (thread-safe dispatcher marshalling on PropertyChanged) — a /// designer-only duplicate instance is therefore harmless. /// - public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel()) + public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!) { } diff --git a/src/PostIt/PostIt/Views/Layout/HomePage.axaml b/src/PostIt/PostIt/Views/Layout/HomePage.axaml index 178bd42d8..8489bb9ee 100644 --- a/src/PostIt/PostIt/Views/Layout/HomePage.axaml +++ b/src/PostIt/PostIt/Views/Layout/HomePage.axaml @@ -2,20 +2,50 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:PostIt.ViewModels" xmlns:lang="using:Yavsc.Lang" + xmlns:messaging="using:Yavsc.Abstract.Models.Messaging" x:Class="PostIt.Views.HomePage" x:DataType="vm:HomePageViewModel" Header="Home"> - + + + + +