refacto BlogPost
This commit is contained in:
parent
84f3ffa9c2
commit
e35bc273a3
17 changed files with 177 additions and 203 deletions
|
|
@ -15,7 +15,7 @@
|
|||
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0.11" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@
|
|||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitVersion.MsBuild" />
|
||||
<PackageReference Include="Microsoft.Maui.Essentials" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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<ContactDto>(contacts.Count);
|
||||
var result = new List<ContactDto>(contacts.Count());
|
||||
foreach (var c in contacts)
|
||||
{
|
||||
var emails = ExtractEmails(c.Emails);
|
||||
|
|
@ -67,7 +69,7 @@ public sealed class ContactService : IContactService
|
|||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
|
||||
private static IReadOnlyList<string> ExtractEmails(IEnumerable<ContactEmail>? emails)
|
||||
{
|
||||
if (emails is null) return Array.Empty<string>();
|
||||
var list = new List<string>();
|
||||
|
|
@ -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<MainPage>();
|
||||
// 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<SettingsPage>();
|
||||
services.AddTransient<HomePage>();
|
||||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
services.AddSingleton<YavscApiClient>(api);
|
||||
services.AddSingleton<IYavscApiClient>(api);
|
||||
services.AddSingleton(client);
|
||||
services.AddSingleton(circleClient);
|
||||
services.AddSingleton(blogAclClient);
|
||||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton<IContactService>(contactService);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddTransient<MainPageViewModel>();
|
||||
services.AddTransient<HomePageViewModel>();
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
services.AddTransient<CirclesPageViewModel>();
|
||||
|
||||
// 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<SessionStatusBanner>();
|
||||
|
||||
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<Settings>();
|
||||
var sessionStatus = serviceProvider.GetRequiredService<SessionStatusViewModel>();
|
||||
var api = serviceProvider.GetRequiredService<YavscApiClient>();
|
||||
|
||||
DataTemplates.Clear();
|
||||
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
||||
|
|
@ -219,6 +159,93 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the DI container the app uses. Pulled out of
|
||||
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
|
||||
/// tests can construct the same container at <c>TestApp</c> 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.
|
||||
/// </summary>
|
||||
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<MainPage>();
|
||||
// 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<SettingsPage>();
|
||||
services.AddTransient<HomePage>();
|
||||
services.AddTransient<SignaturePage>();
|
||||
services.AddTransient<CirclesPage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
services.AddSingleton<YavscApiClient>(api);
|
||||
services.AddSingleton<IYavscApiClient>(api);
|
||||
services.AddSingleton(client);
|
||||
services.AddSingleton(circleClient);
|
||||
services.AddSingleton(blogAclClient);
|
||||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton<IContactService>(contactService);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddTransient<MainPageViewModel>();
|
||||
services.AddTransient<HomePageViewModel>();
|
||||
services.AddTransient<SignaturePageViewModel>();
|
||||
services.AddTransient<CirclesPageViewModel>();
|
||||
|
||||
// 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<SessionStatusBanner>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attach a pre-built DI container to this <see cref="App"/>
|
||||
/// instance. Used by headless tests after
|
||||
/// <see cref="BuildServices"/>; in production this happens
|
||||
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
|
||||
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
|
||||
/// re-binding from a second App boot is a no-op.
|
||||
/// </summary>
|
||||
internal void AttachServiceProvider(IServiceProvider sp)
|
||||
{
|
||||
ServiceProvider = sp;
|
||||
Settings.BindToServiceProvider(sp);
|
||||
}
|
||||
|
||||
private static void ApplyDarkMode(Settings settings)
|
||||
{
|
||||
Application.Current!.RequestedThemeVariant =
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ public class ViewLocator : IDataTemplate
|
|||
Settings => _services.GetRequiredService<SettingsPage>(),
|
||||
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
||||
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
||||
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
|
||||
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
|
||||
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,6 +118,18 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
CurrentViewModel = this;
|
||||
}
|
||||
|
||||
/// <summary>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 <c>SelectedPost is not null</c>
|
||||
/// — which contradicted the create-new-post intent and
|
||||
/// forced the buggy "draft with empty title" branch.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Test-friendly constructor: caller supplies a pre-built
|
||||
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||
|
|
@ -125,11 +137,11 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>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 <c>SelectedPost is not null</c>
|
||||
/// — which contradicted the create-new-post intent and
|
||||
/// forced the buggy "draft with empty title" branch.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user asks to open the "manage ACL" dialog for
|
||||
/// the currently selected post. The <c>MainPage</c> code-behind
|
||||
/// listens to this event and pushes a <c>PostAclDialog</c> on the
|
||||
/// navigation stack. The VM itself can't navigate directly
|
||||
/// because the navigation surface (<c>NavigationPage</c>) lives
|
||||
/// in the View layer.
|
||||
/// </summary>
|
||||
public event EventHandler<BlogPostDto>? ManageAclRequested;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanManageAcl))]
|
||||
public void ManageAcl()
|
||||
{
|
||||
if (SelectedPost is null) return;
|
||||
ManageAclRequested?.Invoke(this, SelectedPost);
|
||||
CurrentViewModel = GetACLViewModel(SelectedPost);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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<CircleDto> MyCircles { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new();
|
||||
public partial ObservableCollection<CircleAuthorization> 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<CircleDto>();
|
||||
MyCircles = new ObservableCollection<CircleDto>(circles);
|
||||
|
||||
var allAcl = aclTask.Result ?? new List<CircleAuthorizationDto>();
|
||||
AclEntries = new ObservableCollection<CircleAuthorizationDto>(
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<BlogAclApiClient>(),
|
||||
services.GetRequiredService<CircleApiClient>());
|
||||
|
||||
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<CirclesPage>();
|
||||
page.DataContext = services.GetRequiredService<CirclesPageViewModel>();
|
||||
|
||||
if (this.VisualRoot is MainWindow window)
|
||||
_ = window.NavRoot.PushAsync(page);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DEV ONLY: temporary shortcut to open the signature capture
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
|
||||
|
|
@ -31,13 +32,11 @@
|
|||
<ListBox Grid.Row="1"
|
||||
ItemsSource="{Binding AclEntries}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="dtos:CircleAuthorizationDto">
|
||||
<DataTemplate x:DataType="yabst:CircleAuthorization">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
|
||||
FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
|
||||
FontSize="11" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Révoquer"
|
||||
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue