Live.Avalonia integration
Some checks failed
Dotnet build and test / build (push) Failing after 8m25s

This commit is contained in:
Paul Schneider 2026-09-29 01:26:37 +01:00
commit 68b224323b
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
15 changed files with 321 additions and 10 deletions

View file

@ -17,12 +17,14 @@
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" /> <PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
<PackageVersion Include="Live.Avalonia" Version="1.4.1" />
<PackageVersion Include="Mapsui" Version="5.1.0" /> <PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" /> <PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" /> <PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
<PackageVersion Include="Material.Avalonia" Version="3.20.0" /> <PackageVersion Include="Material.Avalonia" Version="3.20.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" /> <PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="PvRecorder" Version="1.2.13" /> <PackageVersion Include="PvRecorder" Version="1.2.13" />
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" /> <PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application android:label="PostIt" android:icon="@drawable/Icon"> <application
android:label="PostIt"
android:icon="@drawable/Icon"
android:networkSecurityConfig="@xml/network_security_config">
</application> </application>
</manifest> </manifest>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain> <!-- Debug address -->
</domain-config>
</network-security-config>

View file

@ -12,10 +12,14 @@ using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Helpers; using PostIt.Helpers;
using System.Globalization; using System.Globalization;
using Live.Avalonia;
using System.Diagnostics;
using ReactiveUI;
using System.Reactive;
namespace PostIt; namespace PostIt;
public partial class App : Application public partial class App : Application, ILiveView
{ {
private int _bootStarted; private int _bootStarted;
public string? CliConfigFileSpecification {  get; private set; } public string? CliConfigFileSpecification {  get; private set; }
@ -38,13 +42,29 @@ public partial class App : Application
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
#if DEBUG
this.AttachDeveloperTools();
#endif
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
#if DEBUG
if (Debugger.IsAttached && !IsProduction())
{
// Debugging requires pdb loading etc, so we disable live reloading
// during a test run with an attached debugger.
var window = new Window();
window.Content = CreateView(window);
window.Show();
}
else
#endif
{
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = new ServiceCollection().BuildServices(); this.ServiceProvider = new ServiceCollection().BuildPostItServices();
var settings = ServiceProvider.GetRequiredService<Settings>(); var settings = ServiceProvider.GetRequiredService<Settings>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
@ -60,9 +80,30 @@ public partial class App : Application
InitializeSingleViewLifetime(settings, singleViewPlatform); InitializeSingleViewLifetime(settings, singleViewPlatform);
} }
}
#if DEBUG
// Here we subscribe to ReactiveUI default exception handler to avoid app
// termination in case if we do something wrong in our view models. See:
// https://www.reactiveui.net/docs/handbook/default-exception-handler/
//
// In case if you are using another MV* framework, please refer to its
// documentation explaining global exception handling.
RxApp.DefaultExceptionHandler = Observer.Create<Exception>(Console.WriteLine);
#endif
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
private bool IsProduction()
{
#if DEBUG
return false;
#else
return true;
#endif
}
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform) public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
{ {
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>(); singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
@ -213,4 +254,22 @@ public partial class App : Application
{ {
this.CliConfigFileSpecification = configFile; this.CliConfigFileSpecification = configFile;
} }
public object CreateView(Window window)
{
if (this.ServiceProvider==null)
{
var serviceCollection = new ServiceCollection();
this.ServiceProvider = serviceCollection.BuildPostItServices();
}
if (window.DataContext == null)
window.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
// The AppView class will inherit the DataContext
// of the window. The AppView class can be a
// UserControl, a Grid, a TextBlock, whatever.
this.View = ServiceProvider!.GetRequiredService<MainView>();
this.ConfigureRootView(this.View);
return this.View;
}
} }

View file

@ -2,8 +2,10 @@ using System;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs; using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
using Yavsc.Api.Client; using Yavsc.Api.Client;
@ -11,7 +13,7 @@ namespace PostIt.Helpers;
public static class ServiceCollectionHelpers public static class ServiceCollectionHelpers
{ {
public static IServiceProvider BuildServices(this ServiceCollection services) public static IServiceProvider BuildPostItServices(this ServiceCollection services)
{ {
var settings = new Settings(); var settings = new Settings();
settings.Load(); settings.Load();
@ -40,6 +42,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton<MainView>(); services.AddSingleton<MainView>();
services.AddSingleton<BlogsPage>(); services.AddSingleton<BlogsPage>();
services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindow>();
services.AddSingleton<ChatViewModel>();
// SettingsPage is a singleton: there must be one and only one // SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app. // instance of the settings UI for the lifetime of the app.
@ -59,6 +62,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton<CirclesPage>(); services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>(); services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>(); services.AddTransient<CommandFormsPage>();
services.AddTransient<ChatPage>();
services.AddTransient<RdvPage>(); services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>(); services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>(); services.AddTransient<BillingQueriesPage>();

View file

@ -16,7 +16,7 @@ public sealed class StatusNotice
public string BorderBrush { get; } public string BorderBrush { get; }
public string Foreground { get; } public string Foreground { get; }
private StatusNotice(string message, StatusSeverity severity) public StatusNotice(string message, StatusSeverity severity)
{ {
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message; Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
Severity = severity; Severity = severity;

View file

@ -33,11 +33,13 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="LibVLCSharp.Avalonia" /> <PackageReference Include="LibVLCSharp.Avalonia" />
<PackageReference Include="Live.Avalonia" />
<PackageReference Include="Mapsui" /> <PackageReference Include="Mapsui" />
<PackageReference Include="Mapsui.Avalonia12" /> <PackageReference Include="Mapsui.Avalonia12" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" /> <PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" /> <PackageReference Include="Mono.Options" />
<PackageReference Include="PvRecorder" /> <PackageReference Include="PvRecorder" />
@ -60,5 +62,6 @@
<AutoGen>True</AutoGen> <AutoGen>True</AutoGen>
<DependentUpon>Lang/Resources.resx</DependentUpon> <DependentUpon>Lang/Resources.resx</DependentUpon>
</Compile> </Compile>
<Watch Include="**\*.xaml" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -5,8 +5,10 @@ using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs; using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
namespace PostIt; namespace PostIt;
@ -58,6 +60,7 @@ public class ViewLocator : IDataTemplate
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(), EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(), MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
ChatViewModel => services.GetRequiredService<ChatPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace PostIt.ViewModels.Chat
{
// DTO minimal calqué sur votre logique serveur ChatRoomInfo
public class ChatRoomInfo
{
public string? Name { get; set; }
public List<string> Users { get; set; } = new();
}
}

View file

@ -0,0 +1,135 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.AspNetCore.SignalR.Client;
namespace PostIt.ViewModels.Chat
{
public partial class ChatViewModel : ViewModelBase
{
private HubConnection? _connection;
[ObservableProperty]
public partial string ServerUrl { get; protected set; } = "http://localhost:5000/chathub";
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
[ObservableProperty]
public partial string NickName { get; set; } = "";
[ObservableProperty]
public partial StatusNotice Status { get; set; }
= new StatusNotice("Déconnecté", StatusSeverity.Info);
[ObservableProperty]
public partial bool IsConnected { get; set; } = false;
// Listes dynamiques pour l'UI
public ObservableCollection<string> Messages { get; } = new();
public ObservableCollection<string> ConnectedUsers { get; } = new();
// Commandes pour les boutons
[RelayCommand]
public async Task ConnectCommand() => await ConnectAsync();
[RelayCommand]
public async Task JoinRoomCommand() => await JoinRoomAsync();
[RelayCommand]
public async Task ChangeNickCommand() => await ChangeNickAsync();
public override bool CanNavigateNext { get; protected set; } = false;
public override bool CanNavigatePrevious { get; protected set; } = true;
private async Task ConnectAsync()
{
try
{
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info);
_connection = new HubConnectionBuilder()
.WithUrl(ServerUrl)
.WithAutomaticReconnect()
.Build();
// 1. Ecoute des notifications globales (notifyUser)
_connection.On<string, string, string>("notifyUser", (type, user, msg) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Messages.Add($"[{type}] {user}: {msg}"));
});
// 2. Ecoute des notifications de salon (notifyRoom)
_connection.On<string, string, string>("notifyRoom", (type, room, user) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
Messages.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
if (type == "UserJoin" && !ConnectedUsers.Contains(user))
ConnectedUsers.Add(user);
});
});
// 3. Réception de l'état complet suite au Join réussi (joint)
_connection.On<ChatRoomInfo>("joint", (roomInfo) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
Messages.Add($"Salon rejoint avec succès : {roomInfo.Name}");
ConnectedUsers.Clear();
foreach (var u in roomInfo.Users) ConnectedUsers.Add(u);
});
});
await _connection.StartAsync();
IsConnected = true;
Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info);
}
catch (Exception ex)
{
Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error);
IsConnected = false;
}
}
private async Task JoinRoomAsync()
{
if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return;
try
{
// Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo)
var info = await _connection.InvokeAsync<ChatRoomInfo>("Join", CurrentRoom);
if (info == null)
{
Messages.Add("Erreur lors de la validation du salon par le serveur.");
}
}
catch (Exception ex)
{
Messages.Add($"Erreur Join: {ex.Message}");
}
}
private async Task ChangeNickAsync()
{
if (_connection == null || string.IsNullOrWhiteSpace(NickName)) return;
try
{
// Appel de la méthode publique 'Nick' du Hub (void)
await _connection.InvokeAsync("Nick", NickName);
Messages.Add($"Demande de changement de pseudo vers '{NickName}' envoyée.");
}
catch (Exception ex)
{
Messages.Add($"Erreur Nick: {ex.Message}");
}
}
}
}

View file

@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -36,7 +37,11 @@ public class HomePageViewModel : ViewModelBase
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
} }
public IAsyncRelayCommand OpenChat { get; }
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenProviderRequests { get; }
@ -103,6 +108,14 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
private async Task OpenChatAsync()
{
var app = (App?)Application.Current;
var vm = app!.ServiceProvider?.GetRequiredService<ChatViewModel>();
await app.PushPageAsync(vm!);
}
/// <summary> /// <summary>
/// Ouvre la page « Mes fichiers » (espace de stockage personnel), /// Ouvre la page « Mes fichiers » (espace de stockage personnel),
/// servie par le host Blogs via <c>api/v1/fs</c>. /// servie par le host Blogs via <c>api/v1/fs</c>.

View file

@ -5,15 +5,12 @@ using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Controls.Platform; using Avalonia.Controls.Platform;
using Avalonia.Input;
using System;
using Avalonia;
namespace PostIt.Views.Blogs; namespace PostIt.Views.Blogs;
public partial class BlogsPage : ContentPage public partial class BlogsPage : ContentPage
{ {
private IInputPane _inputPane; private IInputPane? _inputPane;
public BlogsPage() public BlogsPage()
{ {
@ -28,7 +25,7 @@ public partial class BlogsPage : ContentPage
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
if (topLevel != null) if (topLevel != null)
{ {
_inputPane = topLevel.InputPane; _inputPane = topLevel!.InputPane;
if (_inputPane != null) if (_inputPane != null)
{ {
// S'abonne aux changements d'état du clavier virtuel // S'abonne aux changements d'état du clavier virtuel

View file

@ -0,0 +1,60 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:md="using:MarkdownViewer.Core.Controls"
xmlns:models="using:Yavsc.Blogspot"
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Chat"
xmlns:vm="using:PostIt.ViewModels.Chat"
x:Class="PostIt.Views.Chat.ChatPage"
x:DataType="vm:ChatViewModel" >
<Grid RowDefinitions="Auto, *, Auto" Margin="10">
<!-- SECTION 1 : BARRE DE CONNEXION & PSEUDO -->
<Border Grid.Row="0" Background="#F5F5F5" CornerRadius="5" Padding="10" Margin="0,0,0,10">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBox Text="{Binding ServerUrl}" Watermark="URL du Hub SignalR" Width="250" VerticalAlignment="Center"/>
<Button Content="Connexion" Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsConnected}"/>
<Separator Background="Gray" Width="1" Margin="5,0"/>
<TextBox Text="{Binding NickName}" Watermark="Nouveau Pseudo" Width="150" VerticalAlignment="Center"/>
<Button Content="Changer Nick" Command="{Binding ChangeNickCommand}"/>
</StackPanel>
</Border>
<!-- SECTION 2 : ESPACE CENTRAL DU CHAT -->
<Grid Grid.Row="1" ColumnDefinitions="*, 200" Margin="0,0,0,10">
<!-- Journal des flux de messages & notifications -->
<Grid Grid.Column="0" RowDefinitions="Auto, *">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="10" Margin="0,0,0,5">
<TextBox Text="{Binding CurrentRoom}" Watermark="Nom du salon" Width="200" VerticalAlignment="Center"/>
<Button Content="Rejoindre Salon (Join)" Command="{Binding JoinRoomCommand}"/>
</StackPanel>
<ListBox Grid.Row="1" ItemsSource="{Binding Messages}" Background="#FAFAFA" BorderBrush="#E0E0E0" BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<!-- Liste des utilisateurs présents (MàJ via l'événement 'joint') -->
<Border Grid.Column="1" Margin="10,0,0,0" BorderBrush="#E0E0E0" BorderThickness="1" Background="#FFF">
<Grid RowDefinitions="Auto, *">
<TextBlock Grid.Row="0" Text="Membres Présents" FontWeight="Bold" Margin="5" HorizontalAlignment="Center"/>
<ListBox Grid.Row="1" ItemsSource="{Binding ConnectedUsers}" BorderThickness="0"/>
</Grid>
</Border>
</Grid>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding Status+ "Raaaahhh!"}" x:Name="StatusBar" />
</Grid>
</ContentPage>

View file

@ -0,0 +1,10 @@
using Avalonia.Controls;
namespace PostIt.Views.Chat;
public partial class ChatPage : ContentPage
{
public ChatPage()
{
InitializeComponent();
}
}

View file

@ -15,6 +15,11 @@
FontSize="22" FontSize="22"
FontWeight="SemiBold" FontWeight="SemiBold"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
<Button Content="Chat"
Command="{Binding OpenChat}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Open Blog Interface" <Button Content="Open Blog Interface"
Command="{Binding OpenBlogs}" Command="{Binding OpenBlogs}"
HorizontalAlignment="Center" HorizontalAlignment="Center"