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