From e35bc273a3d8117b4c5965da36a760fe5bc38e7f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 19 Aug 2026 14:09:31 +0100 Subject: [PATCH] refacto BlogPost --- src/PostIt/Directory.Packages.props | 4 +- .../PostIt.Android/PostIt.Android.csproj | 4 +- .../Services/ContactService.Mobile.cs | 8 +- src/PostIt/PostIt/App.axaml.cs | 157 ++++++++++-------- src/PostIt/PostIt/ViewLocator.cs | 3 + .../PostIt/ViewModels/MainPageViewModel.cs | 45 +++-- .../ViewModels/PostAclDialogViewModel.cs | 14 +- src/PostIt/PostIt/Views/MainPage.axaml.cs | 46 ----- src/PostIt/PostIt/Views/PostAclDialog.axaml | 5 +- .../Blogspot/{BlogPost.cs => BlogPostDto.cs} | 20 +-- .../Identity/Security/CircleAuthorization.cs} | 6 +- src/Yavsc.Api.Client/BlogAclApiClient.cs | 17 +- .../Controllers/BlogApiController.cs | 2 +- .../Communicating/BlogspotController.cs | 8 +- src/Yavsc.Org/Services/BlogSpotService.cs | 18 +- src/Yavsc.Server/Models/Blog/BlogPost.cs | 3 +- src/Yavsc.Server/Services/BlogSpotService.cs | 20 +-- 17 files changed, 177 insertions(+), 203 deletions(-) rename src/PostIt/{PostIt => PostIt.Android}/Services/ContactService.Mobile.cs (94%) rename src/Yavsc.Abstract/Blogspot/{BlogPost.cs => BlogPostDto.cs} (74%) rename src/{Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs => Yavsc.Abstract/Identity/Security/CircleAuthorization.cs} (78%) diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 62b3a343..88e06195 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -15,7 +15,7 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index b08143b4..b34b88d4 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -31,6 +31,6 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt/Services/ContactService.Mobile.cs b/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs similarity index 94% rename from src/PostIt/PostIt/Services/ContactService.Mobile.cs rename to src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs index 8dbd134d..c869256d 100644 --- a/src/PostIt/PostIt/Services/ContactService.Mobile.cs +++ b/src/PostIt/PostIt.Android/Services/ContactService.Mobile.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel.Communication; using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Devices; +using PostIt.Services; +using System.Linq; -namespace PostIt.Services; +namespace PostIt.Android.Services; /// /// Mobile implementation backed by MAUI Essentials @@ -49,7 +51,7 @@ public sealed class ContactService : IContactService // shape is intentionally richer than the Yavsc // directory's single-Email shape — the two flows // answer different questions. - var result = new List(contacts.Count); + var result = new List(contacts.Count()); foreach (var c in contacts) { var emails = ExtractEmails(c.Emails); @@ -67,7 +69,7 @@ public sealed class ContactService : IContactService } } - private static IReadOnlyList ExtractEmails(IEnumerable? emails) + private static IReadOnlyList ExtractEmails(IEnumerable? emails) { if (emails is null) return Array.Empty(); var list = new List(); diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 6f93edf9..5c9c7567 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -48,71 +48,11 @@ public partial class App : Application // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; - var settings = new Settings(); - settings.Load(); - - var tokenStore = new TokenStore(System.IO.Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), - "PostIt", "tokens.json")); - - var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api, settings.BlogsApiUrl); - var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); - var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); - var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(); - var userDirectory = new UserDirectory(userSearchClient); - - var services = new ServiceCollection(); - - // Vues - services.AddTransient(); - // SettingsPage is a singleton: there must be one and only one - // instance of the settings UI for the lifetime of the app. - // This guarantees that (a) the bindings always reflect the - // current in-memory Settings state, (b) the page already has - // its DataContext wired up at composition-root time (see - // below), and (c) the OpenSettingsRequested handler is a - // pure push with a no-op-if-already-on-top guard, never a - // re-resolution from DI. Transient would let the user - // accumulate stale SettingsPage instances on the navigation - // stack, each bound to a fresh SettingsViewModel and missing - // any in-flight edits. - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // ViewModels - services.AddSingleton(settings); - services.AddSingleton(api); - services.AddSingleton(api); - services.AddSingleton(client); - services.AddSingleton(circleClient); - services.AddSingleton(blogAclClient); - services.AddSingleton(userSearchClient); - services.AddSingleton(contactService); - services.AddSingleton(userDirectory); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // Persistent session banner: one instance for the lifetime of - // the app so the same VM survives page navigation. - var sessionStatus = new SessionStatusViewModel { Api = api }; - sessionStatus.Refresh(); - services.AddSingleton(sessionStatus); - services.AddTransient(); - - ServiceProvider = services.BuildServiceProvider(); - - // Bind the canonical Settings to the static accessor so any - // code path that can't easily take a constructor parameter - // (designer surfaces, Avalonia data templates) still gets - // the same instance the rest of the app is using. Idempotent: - // re-binding from a second App boot (tests) is a no-op. - Settings.BindToServiceProvider(ServiceProvider); + var serviceProvider = BuildServices(); + AttachServiceProvider(serviceProvider); + var settings = serviceProvider.GetRequiredService(); + var sessionStatus = serviceProvider.GetRequiredService(); + var api = serviceProvider.GetRequiredService(); DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(ServiceProvider)); @@ -219,6 +159,93 @@ public partial class App : Application } } + /// + /// Build the DI container the app uses. Pulled out of + /// so headless + /// tests can construct the same container at TestApp boot + /// without going through the full Avalonia desktop lifetime + /// (which never runs in a unit test). The container returned is + /// the exact one production uses — no test-only fakes, no + /// trimmed service list — so a test that exercises a VM, page, + /// or service resolves through the same wiring the real app + /// does, and a green test is a green contract for prod. + /// + internal static IServiceProvider BuildServices() + { + var settings = new Settings(); + settings.Load(); + + var tokenStore = new TokenStore(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "PostIt", "tokens.json")); + + var api = new YavscApiClient(settings, tokenStore); + var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var contactService = new ContactService(); + var userDirectory = new UserDirectory(userSearchClient); + + var services = new ServiceCollection(); + + // Vues + services.AddTransient(); + // SettingsPage is a singleton: there must be one and only one + // instance of the settings UI for the lifetime of the app. + // This guarantees that (a) the bindings always reflect the + // current in-memory Settings state, (b) the page already has + // its DataContext wired up at composition-root time (see + // below), and (c) the OpenSettingsRequested handler is a + // pure push with a no-op-if-already-on-top guard, never a + // re-resolution from DI. Transient would let the user + // accumulate stale SettingsPage instances on the navigation + // stack, each bound to a fresh SettingsViewModel and missing + // any in-flight edits. + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // ViewModels + services.AddSingleton(settings); + services.AddSingleton(api); + services.AddSingleton(api); + services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); + services.AddSingleton(userDirectory); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Persistent session banner: one instance for the lifetime of + // the app so the same VM survives page navigation. + var sessionStatus = new SessionStatusViewModel { Api = api }; + sessionStatus.Refresh(); + services.AddSingleton(sessionStatus); + services.AddTransient(); + + return services.BuildServiceProvider(); + } + + /// + /// Attach a pre-built DI container to this + /// instance. Used by headless tests after + /// ; in production this happens + /// implicitly via . + /// Idempotent w.r.t. : + /// re-binding from a second App boot is a no-op. + /// + internal void AttachServiceProvider(IServiceProvider sp) + { + ServiceProvider = sp; + Settings.BindToServiceProvider(sp); + } + private static void ApplyDarkMode(Settings settings) { Application.Current!.RequestedThemeVariant = diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index e725d0d9..025116d8 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -28,6 +28,9 @@ public class ViewLocator : IDataTemplate Settings => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), + AddCircleMemberDialogViewModel => _services.GetRequiredService(), + CirclesPageViewModel => _services.GetRequiredService(), + PostAclDialogViewModel => _services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index d1d16306..a5169ae2 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -118,6 +118,18 @@ public partial class MainPageViewModel : ViewModelBase CurrentViewModel = this; } + /// Save is enabled as soon as the user has typed + /// a non-whitespace title in the editor, regardless of + /// whether a post is selected. The "no selection" case is + /// the create-new-post path; the "with selection" case is + /// the update path. Both read from the editor buffer. + /// Previously this also required SelectedPost is not null + /// — which contradicted the create-new-post intent and + /// forced the buggy "draft with empty title" branch. + private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); + private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + /// /// Test-friendly constructor: caller supplies a pre-built /// . Production code uses the @@ -125,11 +137,11 @@ public partial class MainPageViewModel : ViewModelBase /// public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) { - SettingsModel = new Settings(); - BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));; + SettingsModel = new Settings(); + BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; Init(settings); - } + } partial void OnSearchTextChanged(string value) => ApplyFilter(); @@ -303,6 +315,11 @@ public partial class MainPageViewModel : ViewModelBase CurrentViewModel = SettingsModel; } + private ViewModelBase? GetACLViewModel(BlogPostDto selectedPost) + { + throw new NotImplementedException(); + } + private async Task RefreshPostsAsync() { var posts = await BlogClient.GetPostsAsync(); @@ -363,33 +380,13 @@ public partial class MainPageViewModel : ViewModelBase DeleteCommand.NotifyCanExecuteChanged(); } - /// Save is enabled as soon as the user has typed - /// a non-whitespace title in the editor, regardless of - /// whether a post is selected. The "no selection" case is - /// the create-new-post path; the "with selection" case is - /// the update path. Both read from the editor buffer. - /// Previously this also required SelectedPost is not null - /// — which contradicted the create-new-post intent and - /// forced the buggy "draft with empty title" branch. - private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); - private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - /// - /// Raised when the user asks to open the "manage ACL" dialog for - /// the currently selected post. The MainPage code-behind - /// listens to this event and pushes a PostAclDialog on the - /// navigation stack. The VM itself can't navigate directly - /// because the navigation surface (NavigationPage) lives - /// in the View layer. - /// - public event EventHandler? ManageAclRequested; [RelayCommand(CanExecute = nameof(CanManageAcl))] public void ManageAcl() { if (SelectedPost is null) return; - ManageAclRequested?.Invoke(this, SelectedPost); + CurrentViewModel = GetACLViewModel(SelectedPost); } /// diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index 68b96b7c..ae48fd8c 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; +using Yavsc.Abstract.Identity.Security; namespace PostIt.ViewModels; @@ -40,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase public partial ObservableCollection MyCircles { get; set; } = new(); [ObservableProperty] - public partial ObservableCollection AclEntries { get; set; } = new(); + public partial ObservableCollection AclEntries { get; set; } = new(); [ObservableProperty] public partial CircleDto? SelectedCircleToAdd { get; set; } @@ -80,9 +81,6 @@ public partial class PostAclDialogViewModel : ViewModelBase var circles = circlesTask.Result ?? new List(); MyCircles = new ObservableCollection(circles); - var allAcl = aclTask.Result ?? new List(); - AclEntries = new ObservableCollection( - allAcl.Where(a => a.BlogPostId == Post.Id)); StatusMessage = $"{AclEntries.Count} autorisation(s)"; } @@ -108,11 +106,9 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - var created = await _aclClient.GrantAsync(new CircleAuthorizationDto + var created = await _aclClient.GrantAsync(new CircleAuthorization { - CircleId = SelectedCircleToAdd.Id, - BlogPostId = Post.Id, - Comment = false, + CircleId = SelectedCircleToAdd.Id }); if (created is not null) { @@ -135,7 +131,7 @@ public partial class PostAclDialogViewModel : ViewModelBase } [RelayCommand] - public async Task RevokeAsync(CircleAuthorizationDto? acl) + public async Task RevokeAsync(CircleAuthorization? acl) { if (acl is null) return; IsBusy = true; diff --git a/src/PostIt/PostIt/Views/MainPage.axaml.cs b/src/PostIt/PostIt/Views/MainPage.axaml.cs index bec3e86c..96fa8eb9 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml.cs +++ b/src/PostIt/PostIt/Views/MainPage.axaml.cs @@ -14,56 +14,10 @@ public partial class MainPage : ContentPage public MainPage() { InitializeComponent(); - DataContextChanged += OnDataContextChanged; } MainPageViewModel? _vm; - void OnDataContextChanged(object? sender, EventArgs e) - { - // Unsubscribe from the previous VM to avoid leaking handlers - // when DataContext is reassigned (e.g. by the navigation - // host or a binding reset). - if (_vm is not null) - { - _vm.ManageAclRequested -= OnManageAclRequested; - _vm.OpenCirclesRequested -= OnOpenCirclesRequested; - } - _vm = DataContext as MainPageViewModel; - if (_vm is not null) - { - _vm.ManageAclRequested += OnManageAclRequested; - _vm.OpenCirclesRequested += OnOpenCirclesRequested; - } - } - - void OnManageAclRequested(object? sender, BlogPostDto post) - { - var app = Application.Current as App; - var services = app?.ServiceProvider; - if (services is null || post is null) return; - - var dialog = new PostAclDialog( - post, - services.GetRequiredService(), - services.GetRequiredService()); - - if (this.VisualRoot is MainWindow window) - _ = window.NavRoot.PushAsync(dialog); - } - - void OnOpenCirclesRequested(object? sender, EventArgs e) - { - var app = Application.Current as App; - var services = app?.ServiceProvider; - if (services is null) return; - - var page = services.GetRequiredService(); - page.DataContext = services.GetRequiredService(); - - if (this.VisualRoot is MainWindow window) - _ = window.NavRoot.PushAsync(page); - } /// /// DEV ONLY: temporary shortcut to open the signature capture diff --git a/src/PostIt/PostIt/Views/PostAclDialog.axaml b/src/PostIt/PostIt/Views/PostAclDialog.axaml index 7c69da74..da5320d3 100644 --- a/src/PostIt/PostIt/Views/PostAclDialog.axaml +++ b/src/PostIt/PostIt/Views/PostAclDialog.axaml @@ -4,6 +4,7 @@ x:Class="PostIt.Views.PostAclDialog" xmlns:vm="using:PostIt.ViewModels" xmlns:dtos="using:Yavsc.Api.Client.Dtos" + xmlns:yabst="using:Yavsc.Abstract.Identity.Security" x:DataType="vm:PostAclDialogViewModel" > @@ -31,13 +32,11 @@ - + -