From 68b224323bda12cdd6707a823bb736aba5122ef8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 29 Sep 2026 01:26:37 +0100 Subject: [PATCH] Live.Avalonia integration --- src/PostIt/Directory.Packages.props | 2 + .../Properties/AndroidManifest.xml | 5 +- .../Resources/network_security_config.xml | 6 + src/PostIt/PostIt/App.axaml.cs | 63 +++++++- .../Helpers/ServiceCollectionHelpers.cs | 6 +- .../Layout => Models}/StatusNotice.cs | 2 +- src/PostIt/PostIt/PostIt.csproj | 3 + src/PostIt/PostIt/ViewLocator.cs | 3 + .../PostIt/ViewModels/Chat/ChatRoomInfo.cs | 11 ++ .../PostIt/ViewModels/Chat/ChatViewModel.cs | 135 ++++++++++++++++++ .../ViewModels/Layout/HomePageViewModel.cs | 13 ++ .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 7 +- src/PostIt/PostIt/Views/Chat/ChatPage..axaml | 60 ++++++++ .../PostIt/Views/Chat/ChatPage.axaml.cs | 10 ++ src/PostIt/PostIt/Views/Layout/HomePage.axaml | 5 + 15 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 src/PostIt/PostIt.Android/Resources/network_security_config.xml rename src/PostIt/PostIt/{ViewModels/Layout => Models}/StatusNotice.cs (94%) create mode 100644 src/PostIt/PostIt/ViewModels/Chat/ChatRoomInfo.cs create mode 100644 src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs create mode 100644 src/PostIt/PostIt/Views/Chat/ChatPage..axaml create mode 100644 src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index c9e77bf1d..386d8924e 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -17,12 +17,14 @@ + + diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 8793aae8a..46f0bc9bb 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,6 +1,9 @@  - + diff --git a/src/PostIt/PostIt.Android/Resources/network_security_config.xml b/src/PostIt/PostIt.Android/Resources/network_security_config.xml new file mode 100644 index 000000000..6d2c97559 --- /dev/null +++ b/src/PostIt/PostIt.Android/Resources/network_security_config.xml @@ -0,0 +1,6 @@ + + + + 10.0.2.2 + + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 0c4f0a7ca..4769fedf3 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -12,10 +12,14 @@ using PostIt.ViewModels; using PostIt.Views; using PostIt.Helpers; using System.Globalization; +using Live.Avalonia; +using System.Diagnostics; +using ReactiveUI; +using System.Reactive; namespace PostIt; -public partial class App : Application +public partial class App : Application, ILiveView { private int _bootStarted; public string? CliConfigFileSpecification {  get; private set; } @@ -38,13 +42,29 @@ public partial class App : Application { AvaloniaXamlLoader.Load(this); +#if DEBUG + this.AttachDeveloperTools(); +#endif } 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; - this.ServiceProvider = new ServiceCollection().BuildServices(); + this.ServiceProvider = new ServiceCollection().BuildPostItServices(); var settings = ServiceProvider.GetRequiredService(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) @@ -60,9 +80,30 @@ public partial class App : Application 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(Console.WriteLine); +#endif + base.OnFrameworkInitializationCompleted(); } + private bool IsProduction() + { +#if DEBUG + return false; +#else + return true; +#endif + } + public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform) { singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService(); @@ -213,4 +254,22 @@ public partial class App : Application { 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(); + + // 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(); + this.ConfigureRootView(this.View); + return this.View; + } } diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index f6fcc3e3b..55dad555b 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -2,8 +2,10 @@ using System; using Microsoft.Extensions.DependencyInjection; using PostIt.Services; using PostIt.ViewModels; +using PostIt.ViewModels.Chat; using PostIt.Views; using PostIt.Views.Blogs; +using PostIt.Views.Chat; using PostIt.Views.Commands; using Yavsc.Api.Client; @@ -11,7 +13,7 @@ namespace PostIt.Helpers; public static class ServiceCollectionHelpers { - public static IServiceProvider BuildServices(this ServiceCollection services) + public static IServiceProvider BuildPostItServices(this ServiceCollection services) { var settings = new Settings(); settings.Load(); @@ -40,6 +42,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // SettingsPage is a singleton: there must be one and only one // instance of the settings UI for the lifetime of the app. @@ -59,6 +62,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(); services.AddSingleton(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs b/src/PostIt/PostIt/Models/StatusNotice.cs similarity index 94% rename from src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs rename to src/PostIt/PostIt/Models/StatusNotice.cs index c87af390c..c50ac3503 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs +++ b/src/PostIt/PostIt/Models/StatusNotice.cs @@ -16,7 +16,7 @@ public sealed class StatusNotice public string BorderBrush { get; } public string Foreground { get; } - private StatusNotice(string message, StatusSeverity severity) + public StatusNotice(string message, StatusSeverity severity) { Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message; Severity = severity; diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 5057bde8c..89525f190 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -33,11 +33,13 @@ All + + @@ -60,5 +62,6 @@ True Lang/Resources.resx + diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index ae4662072..ffaaadc66 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -5,8 +5,10 @@ using Avalonia.Controls.Templates; using Microsoft.Extensions.DependencyInjection; using PostIt.ViewModels; using PostIt.ViewModels.Commands; +using PostIt.ViewModels.Chat; using PostIt.Views; using PostIt.Views.Blogs; +using PostIt.Views.Chat; using PostIt.Views.Commands; namespace PostIt; @@ -58,6 +60,7 @@ public class ViewLocator : IDataTemplate EstimateListPageViewModel => services.GetRequiredService(), EstimateValidationPageViewModel => services.GetRequiredService(), MyFilesViewModel => services.GetRequiredService(), + ChatViewModel => 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/Chat/ChatRoomInfo.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatRoomInfo.cs new file mode 100644 index 000000000..548371157 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Chat/ChatRoomInfo.cs @@ -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 Users { get; set; } = new(); + } +} diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs new file mode 100644 index 000000000..85392cfb1 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs @@ -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 Messages { get; } = new(); + public ObservableCollection 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("notifyUser", (type, user, msg) => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + Messages.Add($"[{type}] {user}: {msg}")); + }); + + // 2. Ecoute des notifications de salon (notifyRoom) + _connection.On("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("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("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}"); + } + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 2c138a27d..19ed05833 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using PostIt.Helpers; using PostIt.Services; +using PostIt.ViewModels.Chat; using Yavsc.Api.Client; namespace PostIt.ViewModels; @@ -36,7 +37,11 @@ public class HomePageViewModel : ViewModelBase OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); + OpenChat = new AsyncRelayCommand(OpenChatAsync); } + + + public IAsyncRelayCommand OpenChat { get; } public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenProviderRequests { get; } @@ -103,6 +108,14 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + private async Task OpenChatAsync() + { + var app = (App?)Application.Current; + var vm = app!.ServiceProvider?.GetRequiredService(); + await app.PushPageAsync(vm!); + + } + /// /// Ouvre la page « Mes fichiers » (espace de stockage personnel), /// servie par le host Blogs via api/v1/fs. diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs index 89743d0ad..629727870 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs @@ -5,15 +5,12 @@ using Avalonia.Platform.Storage; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; using Avalonia.Controls.Platform; -using Avalonia.Input; -using System; -using Avalonia; namespace PostIt.Views.Blogs; public partial class BlogsPage : ContentPage { - private IInputPane _inputPane; + private IInputPane? _inputPane; public BlogsPage() { @@ -28,7 +25,7 @@ public partial class BlogsPage : ContentPage var topLevel = TopLevel.GetTopLevel(this); if (topLevel != null) { - _inputPane = topLevel.InputPane; + _inputPane = topLevel!.InputPane; if (_inputPane != null) { // S'abonne aux changements d'état du clavier virtuel diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage..axaml b/src/PostIt/PostIt/Views/Chat/ChatPage..axaml new file mode 100644 index 000000000..927187de8 --- /dev/null +++ b/src/PostIt/PostIt/Views/Chat/ChatPage..axaml @@ -0,0 +1,60 @@ + + + + + + + + +