diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml
index e1e936204..408212098 100644
--- a/.forgejo/workflows/buildAndTest.yml
+++ b/.forgejo/workflows/buildAndTest.yml
@@ -27,7 +27,7 @@ jobs:
build:
runs-on: docker
container:
- image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
+ image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
steps:
- name: Clone yavsc
run: |
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
index 7d0670a17..de61bd43d 100644
--- a/.forgejo/workflows/release.yml
+++ b/.forgejo/workflows/release.yml
@@ -52,7 +52,7 @@ jobs:
release:
runs-on: docker
container:
- image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
+ image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
steps:
- name: Clone du repo au tag demandé
env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e36d75dd3..8f77b68af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,88 @@
# Changelog
+## [1.0.8-rc22] - unstable
-## [1.0.8-rc19] - Blogs UX
+### Fixed
+
+User experience login into PostIt
+
+## [1.0.8-rc21] - unstable
+
+### Fixed
+
+User experience editing the blog article.
+
+## [1.0.8-rc20] - unstable
+
+Chat SignalR fonctionnel de bout en bout (connexion, salons multiples,
+envoi/réception de messages), enregistreur audio et localisation dans
+PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx
+apprêté pour WebSocket.
+
+### Added
+
+* [PostIt] Interface de chat : page `ChatPage` (connexion au hub,
+ saisie du nom de salon, liste des messages et des membres présents),
+ entrée depuis la page d'accueil.
+* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage`
+ (Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec
+ envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en
+ focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`).
+* [PostIt] Modèle multi-salles côté client : `JoinedRooms`,
+ `SelectedRoom` (salle en focus), messages et utilisateurs indexés par
+ salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la
+ salle active ; un `Join` sur une salle déjà jointe ne fait que
+ changer le focus.
+* [PostIt] Enregistreur audio (`AudioRecorder`, interface
+ `IPCMProcessor`) et ressources de localisation
+ (`Resources.{resx,en-EN,pt-PT}`).
+* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à
+ chaud ; encore KO).
+* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade
+ WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et
+ `proxy_buffering off` — sans quoi la connexion SignalR tombait au
+ premier silence ou l'upgrade échouait derrière nginx.
+
+### Changed
+
+* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et
+ enrichi d'une commande d'action optionnelle (`CheckCommand` +
+ `CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut
+ propose désormais l'action contextuelle (Connecter / Déconnecter /
+ Reconnecter / Vérifier la connexion).
+* [PostIt] `ChatViewModel` suit le cycle de vie complet de la
+ connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de
+ `WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un
+ `IsConnected` optimiste.
+* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` :
+ `ChatRoomInfo` n'expose que des champs publics, que System.Text.Json
+ ne sérialise pas par défaut — le client recevait des objets vides
+ (`Name` null, `Users` vide) et la liste des salons joints n'était
+ jamais alimentée.
+* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName`
+ en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le
+ relire depuis un dictionnaire jamais alimenté.
+* [PostIt.Android] `network_security_config.xml` déplacé sous
+ `Resources/xml/` (chemin canonique Android).
+
+### Fixed
+
+* [Yavsc.Server] `KeyNotFoundException` dans
+ `HubConnectionManager.OnConnected` à chaque connexion SignalR :
+ `ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique
+ d'un `SetUserName` disparu lors d'une refactorisation. L'exception
+ dans `OnConnectedAsync` faisait fermer la connexion par le hub, et
+ PostIt affichait « Déconnecté (The server closed the connection…) »
+ immédiatement après la connexion.
+* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de
+ `ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à
+ la racine du service, pas sous le préfixe versionné).
+* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"`
+ sur une grille à 3 rangées (rangée hors limites, bandeau invisible).
+
+## [1.0.8-rc19] - unstable
+
+[PostIt] Blogs UX
### Fixed
diff --git a/README.md b/README.md
index fefbdd48d..1cee99bbb 100644
--- a/README.md
+++ b/README.md
@@ -232,6 +232,12 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
* ~/Views/Home/Index.cshtml
* ~/Views/Home/About.cshtml
+# 👥 Crédits & Co-autoraClaudt
+
+Ce projet a été développé de manière hybride :
+- **Conception, architecture et revue de code :** Paul Schneider
+- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf))
+
# Remerciements
Photo de Dewang Guptasur Unsplash
diff --git a/contrib/Makefile b/contrib/Makefile
index deb55bcc6..5ccf773a1 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -14,7 +14,7 @@ reinstall: copy-binaries
@echo "💯 backend started"
reinstall-prod:
- make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
+ make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
diff --git a/contrib/template.vhost b/contrib/template.vhost
index ea2c2cb6d..b4c9c139e 100644
--- a/contrib/template.vhost
+++ b/contrib/template.vhost
@@ -57,5 +57,25 @@ server {
proxy_read_timeout 100s;
proxy_buffering off;
}
+
+ # SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout
+ # long, sinon la connexion tombe au premier silence ou l'upgrade échoue.
+ # Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui
+ # exigerait une directive map {} globale hors de ce vhost) : SignalR
+ # négocie toujours un upgrade sur ce chemin, et la négociation initiale
+ # (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet
+ # en-tête côté Kestrel.
+ location /chathub {
+ proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 3600s;
+ proxy_buffering off;
+ }
include server-status.conf;
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props
index 42d09cd55..a353e4b3a 100644
--- a/src/PostIt/Directory.Packages.props
+++ b/src/PostIt/Directory.Packages.props
@@ -4,7 +4,7 @@
true
- 12.1.2
+ 12.1.3
@@ -13,16 +13,21 @@
-
+
+
+
-
-
+
+
+
+
+
@@ -31,4 +36,4 @@
-
\ No newline at end of file
+
diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml
index 8793aae8a..256555df1 100644
--- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml
+++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml
@@ -1,6 +1,10 @@
-
+
diff --git a/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml
new file mode 100644
index 000000000..6d2c97559
--- /dev/null
+++ b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+ 10.0.2.2
+
+
diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs
index a2b8cd66f..1c5aa0c27 100644
--- a/src/PostIt/PostIt.Desktop/Program.cs
+++ b/src/PostIt/PostIt.Desktop/Program.cs
@@ -1,6 +1,7 @@
using System;
using Avalonia;
using PostIt.Services;
+using PostIt.CLIOptions;
namespace PostIt.Desktop;
diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
index 17efd391b..31c82387d 100644
--- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
+++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
@@ -1,4 +1,5 @@
using System.Net.Http;
+using PostIt.Models;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt.Tests/MD/le-chant-1.md b/src/PostIt/PostIt.Tests/MD/le-chant-1.md
new file mode 100644
index 000000000..f4a7506fe
--- /dev/null
+++ b/src/PostIt/PostIt.Tests/MD/le-chant-1.md
@@ -0,0 +1,34 @@
+
+ Loin vers l'infini s'étendent
+ Les grands prés marécageux .
+ Pas un seul oiseau ne chante
+ Sur les arbres secs et creux !
+
+*(Refrain)*
+
+ Ô terre de détresse,
+ Où nous devons sans cesse,
+ Piocher, piocher .
+
+ Dans ce camp morne et sauvage
+ Entouré de murs de fer,
+ Il nous semble vivre en cage
+ Au milieu d'un grand désert .
+
+ Bruit des pas et bruit des armes,
+ Sentinelles jours et nuits,
+ Et du sang, et des cris, des larmes,
+ La mort pour celui qui fuit .
+
+ Mais un jour dans notre vie,
+ Le printemps refleurira .
+ Liberté, liberté chérie
+ Je dirai : « Tu es à moi ! »
+
+*(Dernier refrain)*
+
+ Ô terre d'allégresse,
+ Où nous pourrons sans cesse,
+ Aimer, aimer !
+
+[audio : Mario Hacquard - Youtube](https://www.youtube.com/watch?v=ogw_mgk1pFs)
diff --git a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs
index cc08304d5..3516e93d6 100644
--- a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs
+++ b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs
@@ -101,7 +101,7 @@ public class SessionStatusBannerTests
{
MainWindow window = new MainWindow();
window.Show();
- var banner = window.MainView.SessionBanner;
+ var banner = new SessionStatusBanner();
var status = new SessionStatusViewModel();
banner.DataContext = status;
return banner;
diff --git a/src/PostIt/PostIt.Tests/StatusBarTests.cs b/src/PostIt/PostIt.Tests/StatusBarTests.cs
index 4053dbaf3..15889e0b0 100644
--- a/src/PostIt/PostIt.Tests/StatusBarTests.cs
+++ b/src/PostIt/PostIt.Tests/StatusBarTests.cs
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
+using PostIt.Models;
using PostIt.ViewModels;
namespace PostIt.Tests;
@@ -21,7 +22,9 @@ public class StatusBarTests
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
- var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
+ var bar = new StatusBar {
+ DataContext =
+ StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
@@ -32,4 +35,4 @@ public class StatusBarTests
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index 9a04ffd03..fca8e0313 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -6,18 +6,26 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
+using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Helpers;
+using System.Globalization;
+using Live.Avalonia;
+using System.Diagnostics;
+using System.Linq;
+using ReactiveUI;
+using System.Reactive;
namespace PostIt;
-public partial class App : Application
+public partial class App : Application, ILiveView, IVMPusherApp
{
+ public static bool LiveMode { get; private set; }
private int _bootStarted;
- public string? CliConfigFileSpecification { get; private set; }
+ public static string? CliConfigFileSpecification { get; private set; }
///
/// DI container the platform entry points hand to ViewModels so
@@ -36,36 +44,132 @@ public partial class App : Application
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
+
+#if DEBUG
+ this.AttachDeveloperTools();
+#endif
}
public override void OnFrameworkInitializationCompleted()
{
if (TryHandOffCustomSchemeUrl()) return;
- this.ServiceProvider = new ServiceCollection().BuildServices();
+ this.ServiceProvider = new ServiceCollection().BuildPostItServices();
var settings = ServiceProvider.GetRequiredService();
- if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ if (!LiveMode)
{
- InitializeClassicDesktopLifetime(settings, desktop);
+ // Debugging requires pdb loading etc, so we disable live reloading
+ // during a test run with an attached debugger.
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ InitializeClassicDesktopLifetime(settings, desktop);
+ }
+ else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
+ {
+ InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
+ }
+ else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
+ {
+ InitializeSingleViewLifetime(settings, singleViewPlatform);
+ }
}
- else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
+ else
{
- InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
- }
- else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
- {
- InitializeSingleViewLifetime(settings, singleViewPlatform);
+ // Here, we create a new LiveViewHost, located in the 'Live.Avalonia'
+ // namespace, and pass an ILiveView implementation to it. The ILiveView
+ // implementation should have a parameterless constructor! Next, we
+ // start listening for any changes in the source files. And then, we
+ // show the LiveViewHost window. Simple enough, huh?
+ var window = new LiveViewHost(this, Console.WriteLine);
+ var view = ServiceProvider!.GetRequiredService();
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ InitializeClassicDesktopLifetime(window, view, settings, desktop);
+ }
+ else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
+ {
+ InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
+ }
+ else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
+ {
+ InitializeSingleViewLifetime(settings, singleViewPlatform);
+ }
+
+ window.StartWatchingSourceFilesForHotReloading();
+ Console.WriteLine("LiveViewHost started for hot reloading.");
+ window.Show();
}
+
+#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.
+ Console.WriteLine("Debug mode: ReactiveUI default exception handler is set.");
+ RxApp.DefaultExceptionHandler = Observer.Create(Console.WriteLine);
+#endif
+
base.OnFrameworkInitializationCompleted();
}
+ private bool IsProduction()
+ {
+#if DEBUG
+ return false;
+#else
+ return true;
+#endif
+ }
+
+ public async Task PushPageAsync(ViewModelBase viewModel)
+ {
+ var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel));
+ if (template is null)
+ {
+ throw new InvalidOperationException($"No IDataTemplate found for {viewModel.GetType().Name}.");
+ }
+
+ Control? view = template.Build(viewModel) ;
+ if (view is null)
+ {
+ throw new InvalidOperationException(
+ $"Template for {viewModel.GetType().Name} returned .");
+ }
+
+ var page = view as Page;
+ if (page is null)
+ {
+ // NavigationPage expects Page instances. Wrap any fallback control
+ // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
+ page = new ContentPage { Content = view };
+ }
+
+ page.DataContext = viewModel;
+
+ MainView? mainView = (Current as App).View;
+
+ // Avoid stacking the same singleton page twice (e.g. SettingsPage).
+ var stack = mainView.NavRoot.NavigationStack;
+ if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
+ {
+ return page;
+ }
+
+ await mainView.NavRoot.PushAsync(page);
+ return page;
+ }
+
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService();
ConfigureRootView(View);
- ApplyDarkMode(settings);
+ ApplyStettings(settings);
}
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
@@ -74,7 +178,7 @@ public partial class App : Application
() =>
{
this.ConfigureRootView(ServiceProvider!.GetRequiredService());
- ApplyDarkMode(settings);
+ ApplyStettings(settings);
return View!;
};
}
@@ -82,37 +186,35 @@ public partial class App : Application
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{
var window = ServiceProvider!.GetRequiredService();
- desktop.MainWindow = window;
- this.ConfigureRootView(window.MainView);
+ var mainView = (MainView)CreateView(window);
+ InitializeClassicDesktopLifetime(window, mainView, settings, desktop);
+ }
- ApplyDarkMode(settings);
+ public void InitializeClassicDesktopLifetime(Window window, MainView mainView, Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ this.ConfigureRootView(mainView);
+ ApplyStettings(settings);
+ desktop.MainWindow = window;
+ window.Content = mainView;
+ window.Show();
}
public void ConfigureRootView(MainView rootView)
-{
- // Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
- rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
-
- var sessionStatus = ServiceProvider!.GetRequiredService();
- sessionStatus.LogoutCompleted += () =>
{
- // Remplacer Window.NavRoot par rootView.NavRoot
- rootView.NavRoot.PopToRootAsync();
- };
+ rootView.AttachedToVisualTree += (_, _) =>
+ Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
- rootView.SessionBanner.DataContext = sessionStatus;
- this.View = rootView;
-}
-
- private async Task BootOnceAsync()
- {
- if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
+ var sessionStatus = ServiceProvider!.GetRequiredService();
+ sessionStatus.LogoutCompleted += () =>
{
- return;
- }
+ // Remplacer Window.NavRoot par rootView.NavRoot
+ rootView.NavRoot.PopToRootAsync();
+ };
+ if (rootView.DataContext==null)
+ rootView.DataContext = ServiceProvider!.GetRequiredService();
- var api = ServiceProvider!.GetRequiredService();
- await BootAsync(this.ServiceProvider!, api);
+ rootView.SessionBanner.DataContext = sessionStatus;
+ this.View = rootView;
}
///
@@ -126,10 +228,24 @@ public partial class App : Application
View = mainView ?? throw new ArgumentNullException(nameof(mainView));
}
- private static void ApplyDarkMode(Settings settings)
+ private static void ApplyStettings(Settings settings)
{
- Application.Current!.RequestedThemeVariant =
- settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
+ if (!string.IsNullOrWhiteSpace(settings.SelectedLanguage))
+ Yavsc.Lang.Resources.Culture = new CultureInfo(settings.SelectedLanguage);
+ Current!.RequestedThemeVariant =
+ settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
+ }
+
+
+ private async Task BootOnceAsync()
+ {
+ if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
+ {
+ return;
+ }
+
+ var api = ServiceProvider!.GetRequiredService();
+ await BootAsync(this.ServiceProvider!, api);
}
///
@@ -147,9 +263,9 @@ public partial class App : Application
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService();
sessionStatus.Refresh();
- var homePage = provider.GetRequiredService();
+ var homePageVm = provider.GetRequiredService();
var app = (App)Current!;
- await app.PushPageAsync(homePage);
+ await app.PushPageAsync(homePageVm);
}
///
@@ -205,8 +321,31 @@ public partial class App : Application
await View!.NavRoot.PopAsync();
}
- internal void UseConfigFileWhenLoading(string configFile)
+ internal static void UseConfigFileWhenLoading(string configFile)
{
- this.CliConfigFileSpecification = configFile;
+ CliConfigFileSpecification = configFile;
+ }
+
+ internal static void EnableLiveMode()
+ {
+ LiveMode = true;
+ }
+
+ 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/CLIOptions/CLIOptions.cs b/src/PostIt/PostIt/CLIOptions/CLIOptions.cs
index c0bea85ff..643102b38 100644
--- a/src/PostIt/PostIt/CLIOptions/CLIOptions.cs
+++ b/src/PostIt/PostIt/CLIOptions/CLIOptions.cs
@@ -4,15 +4,21 @@ using Mono.Options;
using PostIt;
using PostIt.ViewModels;
+namespace PostIt.CLIOptions;
+
public static class CLIOptionHandler
{
public static void HandleArgs(string[] args)
{
bool showHelp = false;
string configFile = null;
+ bool liveMode = false;
var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
+ { "l|live", "Enable live mode", v => liveMode = true },
+ { "v|version", "Show the application version", v => Console.WriteLine("PostIt version "
+ + typeof(App).Assembly.GetName().Version?.ToString()) },
{ "h|help", "Show this message and exit", v => showHelp = true }
};
@@ -26,8 +32,11 @@ public static class CLIOptionHandler
}
if (configFile != null)
{
- var app = App.Current as App;
- app.UseConfigFileWhenLoading(configFile);
+ App.UseConfigFileWhenLoading(configFile);
+ }
+ if (liveMode)
+ {
+ App.EnableLiveMode();
}
}
}
diff --git a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml
new file mode 100644
index 000000000..9e8df6dd1
--- /dev/null
+++ b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs
new file mode 100644
index 000000000..0431b726e
--- /dev/null
+++ b/src/PostIt/PostIt/Controls/MarkdownEditorControl.axaml.cs
@@ -0,0 +1,88 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using AvaloniaEdit.Document;
+
+namespace PostIt.Controls;
+
+public partial class MarkdownEditorControl : UserControl
+{
+ public static readonly StyledProperty MarkdownTextProperty =
+ AvaloniaProperty.Register(nameof(MarkdownText));
+
+ public static readonly StyledProperty DocumentProperty =
+ AvaloniaProperty.Register(nameof(Document));
+
+ public static readonly StyledProperty IsPreviewVisibleProperty =
+ AvaloniaProperty.Register(nameof(IsPreviewVisible), false);
+
+ public string? MarkdownText
+ {
+ get => GetValue(MarkdownTextProperty);
+ set => SetValue(MarkdownTextProperty, value);
+ }
+
+ public TextDocument? Document
+ {
+ get => GetValue(DocumentProperty);
+ set => SetValue(DocumentProperty, value);
+ }
+
+ public bool IsPreviewVisible
+ {
+ get => GetValue(IsPreviewVisibleProperty);
+ set => SetValue(IsPreviewVisibleProperty, value);
+ }
+
+ public MarkdownEditorControl()
+ {
+ InitializeComponent();
+ PropertyChanged += OnPropertyChanged;
+ SizeChanged += OnSizeChanged;
+ UpdateEditorLimits();
+ }
+
+ private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == MarkdownTextProperty)
+ {
+ var text = e.NewValue as string ?? string.Empty;
+ if (Document is null || !string.Equals(Document.Text, text, StringComparison.Ordinal))
+ {
+ Document = new TextDocument(text);
+ }
+ }
+ else if (e.Property == DocumentProperty)
+ {
+ var newDocument = e.NewValue as TextDocument;
+ var text = newDocument?.Text ?? string.Empty;
+ if (!string.Equals(MarkdownText, text, StringComparison.Ordinal))
+ {
+ MarkdownText = text;
+ }
+ }
+ }
+
+ private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
+ {
+ UpdateEditorLimits();
+ }
+
+ private void UpdateEditorLimits()
+ {
+ var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260;
+ visibleHeight = Math.Max(visibleHeight, 80);
+
+ if (SourceEditor is not null)
+ {
+ SourceEditor.MaxHeight = visibleHeight;
+ SourceEditor.Height = double.NaN;
+ }
+
+ if (PreviewViewer is not null)
+ {
+ PreviewViewer.MaxHeight = visibleHeight;
+ PreviewViewer.Height = double.NaN;
+ }
+ }
+}
diff --git a/src/PostIt/PostIt/Controls/StatusBar.axaml b/src/PostIt/PostIt/Controls/StatusBar.axaml
index 2f46fee33..755ff7d3e 100644
--- a/src/PostIt/PostIt/Controls/StatusBar.axaml
+++ b/src/PostIt/PostIt/Controls/StatusBar.axaml
@@ -10,16 +10,19 @@
Padding="8">
-
+ FontWeight="Bold"
+ Foreground="{Binding Foreground}"
+ VerticalAlignment="Top"/>
+ Foreground="{Binding Foreground}"
+ TextWrapping="Wrap"
+ VerticalAlignment="Center"/>
+
diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
index f6fcc3e3b..778f2c4ed 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();
@@ -37,9 +39,13 @@ public static class ServiceCollectionHelpers
var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+
+ // Models
+ 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 +65,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/Helpers/ViewModelBaseHelpers.cs b/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
deleted file mode 100644
index f30e021d9..000000000
--- a/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading.Tasks;
-using Avalonia.Controls;
-using PostIt.ViewModels;
-
-namespace PostIt.Helpers;
-
-public static class ViewModelBaseHelpers
-{
- public static async Task PushPageAsync(this App app, ViewModelBase vm)
- {
- var window = app.View;
- if (window is null)
- {
- throw new InvalidOperationException("MainWindow is not initialized yet.");
- }
-
- var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
- if (template is null)
- {
- throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
- }
-
- var view = template.Build(vm);
- if (view is null)
- {
- throw new InvalidOperationException(
- $"Template for {vm.GetType().Name} returned .");
- }
-
- var page = view as Page;
- if (page is null)
- {
- // NavigationPage expects Page instances. Wrap any fallback control
- // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
- page = new ContentPage { Content = view };
- }
-
- page.DataContext = vm;
-
- // Avoid stacking the same singleton page twice (e.g. SettingsPage).
- var stack = window.NavRoot.NavigationStack;
- if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
- {
- return page;
- }
-
- await window.NavRoot.PushAsync(page);
-
- return page;
- }
-}
diff --git a/src/PostIt/PostIt/IVMPusherApp.cs b/src/PostIt/PostIt/IVMPusherApp.cs
new file mode 100644
index 000000000..3fc0f474f
--- /dev/null
+++ b/src/PostIt/PostIt/IVMPusherApp.cs
@@ -0,0 +1,10 @@
+using System.Threading.Tasks;
+using PostIt.ViewModels;
+using Avalonia.Controls;
+
+namespace PostIt;
+
+internal interface IVMPusherApp
+{
+ Task PushPageAsync(ViewModelBase viewModel);
+}
diff --git a/src/PostIt/PostIt/Interfaces/IPCMProcessor.cs b/src/PostIt/PostIt/Interfaces/IPCMProcessor.cs
new file mode 100644
index 000000000..e8e61070e
--- /dev/null
+++ b/src/PostIt/PostIt/Interfaces/IPCMProcessor.cs
@@ -0,0 +1,6 @@
+using System;
+
+public interface IPCMProcessor : IDisposable
+{
+ void Process(short[] audioFrame);
+}
diff --git a/src/PostIt/PostIt/Lang/Resources.Designer.cs b/src/PostIt/PostIt/Lang/Resources.Designer.cs
new file mode 100644
index 000000000..7d600fd2a
--- /dev/null
+++ b/src/PostIt/PostIt/Lang/Resources.Designer.cs
@@ -0,0 +1,72 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace Yavsc.Lang {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ public Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Yavsc.Lang.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Bienvenue dans PostIt !!!.
+ ///
+ public static string GreetingText {
+ get {
+ return ResourceManager.GetString("GreetingText", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/src/PostIt/PostIt/Lang/Resources.en-EN.resx b/src/PostIt/PostIt/Lang/Resources.en-EN.resx
new file mode 100644
index 000000000..6128bca8d
--- /dev/null
+++ b/src/PostIt/PostIt/Lang/Resources.en-EN.resx
@@ -0,0 +1,5 @@
+
+
+ Welcome to PostIt!
+
+
diff --git a/src/PostIt/PostIt/Lang/Resources.pt-PT.resx b/src/PostIt/PostIt/Lang/Resources.pt-PT.resx
new file mode 100644
index 000000000..049c25316
--- /dev/null
+++ b/src/PostIt/PostIt/Lang/Resources.pt-PT.resx
@@ -0,0 +1,5 @@
+
+
+ Bemvindo em PostIt!
+
+
diff --git a/src/PostIt/PostIt/Lang/Resources.resx b/src/PostIt/PostIt/Lang/Resources.resx
new file mode 100644
index 000000000..574138ef6
--- /dev/null
+++ b/src/PostIt/PostIt/Lang/Resources.resx
@@ -0,0 +1,5 @@
+
+
+ Bienvenue dans PostIt !!!
+
+
diff --git a/src/PostIt/PostIt/Models/ChatMessage.cs b/src/PostIt/PostIt/Models/ChatMessage.cs
new file mode 100644
index 000000000..52733311a
--- /dev/null
+++ b/src/PostIt/PostIt/Models/ChatMessage.cs
@@ -0,0 +1,15 @@
+namespace PostIt.ViewModels.Chat
+{
+ ///
+ /// DTO de message de salon, calqué sur la charge envoyée par
+ /// ChatHub.Send côté serveur ({ Name, Room, Message }).
+ /// Le protocole JSON de SignalR sérialise en camelCase ; la
+ /// désérialisation est insensible à la casse par défaut.
+ ///
+ public class ChatMessage
+ {
+ public string? Name { get; set; }
+ public string? Room { get; set; }
+ public string? Message { get; set; }
+ }
+}
diff --git a/src/PostIt/PostIt/Models/ChatMessageDto.cs b/src/PostIt/PostIt/Models/ChatMessageDto.cs
new file mode 100644
index 000000000..b93ccbe29
--- /dev/null
+++ b/src/PostIt/PostIt/Models/ChatMessageDto.cs
@@ -0,0 +1,14 @@
+namespace PostIt.ViewModels.Chat
+{
+ ///
+ /// DTO minimal calqué sur l'objet anonyme émis par le hub
+ /// ({ Name, Room, Message }) via l'événement
+ /// ReceiveMessage.
+ ///
+ public class ChatMessageDto
+ {
+ public string? Name { get; set; }
+ public string? Room { get; set; }
+ public string? Message { get; set; }
+ }
+}
diff --git a/src/PostIt/PostIt/Models/ChatRoomInfo.cs b/src/PostIt/PostIt/Models/ChatRoomInfo.cs
new file mode 100644
index 000000000..548371157
--- /dev/null
+++ b/src/PostIt/PostIt/Models/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/Models/IWithLabel.cs b/src/PostIt/PostIt/Models/IWithLabel.cs
new file mode 100644
index 000000000..b76f912db
--- /dev/null
+++ b/src/PostIt/PostIt/Models/IWithLabel.cs
@@ -0,0 +1,6 @@
+namespace PostIt.ViewModels;
+
+internal interface IWithLabel
+{
+ string Label { get; }
+}
diff --git a/src/PostIt/PostIt/Models/StatusSeverity.cs b/src/PostIt/PostIt/Models/StatusSeverity.cs
new file mode 100644
index 000000000..eb6ef4502
--- /dev/null
+++ b/src/PostIt/PostIt/Models/StatusSeverity.cs
@@ -0,0 +1,8 @@
+namespace PostIt.Models;
+
+public enum StatusSeverity
+{
+ Info,
+ Warning,
+ Error
+}
diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj
index 9cdf71299..89525f190 100644
--- a/src/PostIt/PostIt/PostIt.csproj
+++ b/src/PostIt/PostIt/PostIt.csproj
@@ -7,6 +7,8 @@
1.1.0.0
1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e
1.1.0-estimate.1
+ true
+
@@ -30,16 +32,36 @@
None
All
+
+
+
+
+
-
\ No newline at end of file
+
+
+ PublicResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+
+ True
+ True
+ Lang/Resources.resx
+
+
+
+
diff --git a/src/PostIt/PostIt/Services/AudioRecorder.cs b/src/PostIt/PostIt/Services/AudioRecorder.cs
new file mode 100644
index 000000000..43e7f5c1b
--- /dev/null
+++ b/src/PostIt/PostIt/Services/AudioRecorder.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Threading;
+using Pv;
+
+public class AudioRecorder : IDisposable
+{
+ private int frameLength;
+ PvRecorder recorder;
+
+ Thread thread;
+ short[] audioFrame;
+ IPCMProcessor processor;
+
+ public bool processing { get; private set; }
+
+ public AudioRecorder(IPCMProcessor processor, int frameLength=512, int deviceIndex = -1)
+ {
+ this.frameLength = frameLength;
+ this.recorder = PvRecorder.Create(frameLength: frameLength, deviceIndex: deviceIndex);
+ this.audioFrame = new short[frameLength];
+ this.processor=processor;
+ }
+
+ public static string [] GetAvailableDevices()
+ {
+ return PvRecorder.GetAvailableDevices();
+ }
+
+ public void Start()
+ {
+ recorder.Start();
+ thread = new Thread(ThreadLoop);
+ thread.Start();
+ }
+
+ public void ThreadLoop()
+ {
+ while (recorder.IsRecording)
+ {
+ if (thread.ThreadState != ThreadState.Running)
+ {
+ break;
+ }
+ if (processing)
+ {
+ Thread.Sleep(10);
+ continue;
+ }
+ processing = true;
+ audioFrame = recorder.Read();
+ processor.Process(audioFrame);
+ processing = false;
+ }
+ }
+
+ public void Stop()
+ {
+ thread.Interrupt();
+ thread.Join();
+ recorder.Stop();
+ thread = null;
+ }
+ public void Dispose()
+ {
+ recorder.Dispose();
+ }
+}
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/ACL/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
index cd068b4f4..6201c061b 100644
--- a/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
@@ -5,7 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
-using PostIt.Helpers;
+using PostIt.Models;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
diff --git a/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
index be1b220fa..43ed60823 100644
--- a/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
@@ -12,6 +12,7 @@ using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using System.Net.Http;
+using PostIt.Models;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
index bd13faf9b..1890309ec 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
@@ -8,6 +8,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
index f1b05b749..5cd373708 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
@@ -12,6 +12,7 @@ using PostIt.Helpers;
using Yavsc;
using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow;
+using PostIt.Models;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
index c9f122dce..0df05b21a 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
@@ -388,4 +388,4 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
QueryStatus.Success => "V",
_ => "i",
};
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
index 533fbc380..14e7e74b9 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
@@ -6,6 +6,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
index 1f1e4b4be..f8f94436a 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
@@ -10,6 +10,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
index c90a6d884..766dda903 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
@@ -9,6 +9,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
index 7fd84fd88..360bf54ad 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
@@ -13,6 +13,7 @@ using PostIt.Helpers;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
+using PostIt.Models;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
index a1e016e7f..b827bff26 100644
--- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
@@ -12,6 +12,7 @@ using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using PostIt.Helpers;
using AvaloniaEdit.Document;
+using PostIt.Models;
namespace PostIt.ViewModels;
@@ -38,18 +39,64 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// Editor buffer for the post body. Same pattern as
/// .
- public string DraftArticle
+ public string? DraftArticle
{
- get => DraftArticleDocument.Text;
- set {
+ get => DraftArticleDocument?.Text ?? null;
+ set
+ {
+ var currentText = DraftArticleDocument?.Text;
+ if (string.Equals(currentText, value, StringComparison.Ordinal))
+ {
+ OnPropertyChanged(nameof(DraftArticle));
+ return;
+ }
+
OnPropertyChanging(nameof(DraftArticle));
- DraftArticleDocument = new TextDocument(value);
- this.OnPropertyChanged(nameof(DraftArticle));
+ try
+ {
+ DraftArticleDocument = value is not null ? new TextDocument(value) : null;
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Error setting DraftArticleDocument: {ex.Message}";
+ // Handle any exceptions that might occur when setting the DraftArticleDocument
+ }
+ OnPropertyChanged(nameof(DraftArticle));
}
}
- [ObservableProperty]
- public partial TextDocument DraftArticleDocument {get; set;}
+ private TextDocument? _draftArticleDocument;
+
+ public TextDocument? DraftArticleDocument
+ {
+ get => _draftArticleDocument;
+ set
+ {
+ if (ReferenceEquals(_draftArticleDocument, value))
+ {
+ return;
+ }
+
+ if (_draftArticleDocument is not null)
+ {
+ _draftArticleDocument.TextChanged -= DraftArticleDocument_TextChanged;
+ }
+
+ _draftArticleDocument = value;
+
+ if (_draftArticleDocument is not null)
+ {
+ _draftArticleDocument.TextChanged += DraftArticleDocument_TextChanged;
+ }
+
+ OnPropertyChanged(nameof(DraftArticleDocument));
+ }
+ }
+
+ private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
+ {
+ OnPropertyChanged(nameof(DraftArticle));
+ }
/// Editor buffer for the post's publication state.
/// Reflects the server-side IsPublished flag (the
diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
new file mode 100644
index 000000000..ba452cb4a
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
@@ -0,0 +1,353 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.Extensions.DependencyInjection;
+using PostIt.Models;
+using PostIt.Services;
+using Yavsc;
+
+namespace PostIt.ViewModels.Chat
+{
+ ///
+ /// ViewModel pour la gestion du chat,
+ /// incluant la connexion au Hub SignalR,
+ /// la gestion des salons et des utilisateurs connectés.
+ ///
+
+ public partial class ChatViewModel : ViewModelBase
+ {
+ private readonly Settings _settings;
+ private readonly YavscApiClient? _api;
+
+ // Le paramètre optionnel permet l'instanciation design-time
+ // (Design.DataContext dans ChatPage.axaml) sans conteneur DI.
+ public ChatViewModel(YavscApiClient? api = null)
+ {
+ var app = App.Current as App;
+ _settings = app!.ServiceProvider!.GetRequiredService(); // Assurez-vous que _settings est initialisé avant utilisation.
+ _api = api;
+ Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
+ new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
+ FocusedMessages = _systemMessages;
+ }
+
+ private HubConnection? _connection;
+
+ [ObservableProperty]
+ public partial string CurrentRoom { get; set; } = "General";
+
+ [ObservableProperty]
+ public partial string NickName { get; set; } = "";
+
+ [ObservableProperty]
+ public partial StatusNotice Status { get; set; }
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
+ public partial bool IsConnected { get; set; } = false;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
+ public partial string MessageToSend { get; set; } = "";
+
+ // Salles jointes (choix de la salle en focus dans la vue).
+ public ObservableCollection JoinedRooms { get; } = new();
+
+ // Salle en focus ; null = aucune salle, le journal affiche
+ // alors les notifications système.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
+ public partial string? SelectedRoom { get; set; }
+
+ // Notifications globales (connexion, erreurs de join, ...)
+ private readonly ObservableCollection _systemMessages = new();
+
+ // Messages et utilisateurs présents, indexés par salle.
+ private readonly Dictionary> _messagesByRoom = new();
+ private readonly Dictionary> _usersByRoom = new();
+ private static readonly ObservableCollection _noUsers = new();
+
+ // Vues "focus" exposées à la page : pointent vers la salle
+ // sélectionnée, ou vers le journal système / une liste vide.
+ [ObservableProperty]
+ public partial ObservableCollection FocusedMessages { get; set; }
+
+ [ObservableProperty]
+ public partial ObservableCollection FocusedUsers { get; set; } = _noUsers;
+
+ partial void OnSelectedRoomChanged(string? value)
+ {
+ FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
+ ? msgs : _systemMessages;
+ FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
+ ? users : _noUsers;
+ }
+
+ // Trace une notification système dans la vue courante
+ // (et dans le journal global pour consultation ultérieure).
+ private void Log(string line)
+ {
+ _systemMessages.Add(line);
+ if (!ReferenceEquals(FocusedMessages, _systemMessages))
+ FocusedMessages.Add(line);
+ }
+
+ // Commandes pour les boutons
+ [RelayCommand]
+ public async Task ConnectCommand() => await ConnectAsync();
+
+ [RelayCommand]
+ public async Task JoinRoomCommand() => await JoinRoomAsync();
+
+ // Le serveur (ChatHub.Send) refuse tout message d'un utilisateur
+ // qui n'a pas joint le salon ("NoJoinNoSend") : on garde la
+ // commande inactive tant qu'aucune salle n'est en focus.
+ private bool CanSendMessage() =>
+ IsConnected && SelectedRoom != null && !string.IsNullOrWhiteSpace(MessageToSend);
+
+ [RelayCommand(CanExecute = nameof(CanSendMessage))]
+ private async Task SendMessageAsync()
+ {
+ if (_connection == null || SelectedRoom == null) return;
+ var text = MessageToSend.Trim();
+ try
+ {
+ await _connection.InvokeAsync("Send", SelectedRoom, text);
+ MessageToSend = string.Empty;
+ }
+ catch (Exception ex)
+ {
+ Log($"Erreur d'envoi : {ex.Message}");
+ }
+ }
+
+ public override bool CanNavigateNext { get; protected set; } = false;
+ public override bool CanNavigatePrevious { get; protected set; } = true;
+
+ private async Task DisconnectAsync()
+ {
+ if (_connection != null)
+ {
+ await _connection.StopAsync();
+ await _connection.DisposeAsync();
+ _connection = null;
+ }
+ IsConnected = false;
+ SelectedRoom = null;
+ JoinedRooms.Clear();
+ _messagesByRoom.Clear();
+ _usersByRoom.Clear();
+ _systemMessages.Clear();
+ Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
+ new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
+ }
+
+ private async Task ConnectAsync()
+ {
+ try
+ {
+ Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
+ new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
+
+ if (_api == null || !_api.HasValidSession)
+ {
+ Status = new StatusNotice("Session expirée : reconnectez-vous avant d'ouvrir le chat.", StatusSeverity.Error,
+ new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
+ return;
+ }
+
+ // Le hub est mappé à la racine du service ("/chathub"),
+ // pas sous le préfixe versionné "/api/v1" : on le retire.
+ var hubBase = _settings.ApiUrl.TrimEnd('/');
+ if (hubBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase))
+ hubBase = hubBase[..^"/api/v1".Length];
+ var hubUrl = hubBase + "/" + Constants.ChatHubPath;
+
+ _connection = new HubConnectionBuilder()
+ .WithUrl(hubUrl,
+
+ options =>
+ {
+ // Le hub est [Authorize] : on attache le Bearer OIDC.
+ // AccessTokenProvider est rappelé à chaque reconnexion,
+ // donc on tente un refresh silencieux à chaque appel.
+ // Un refresh mort (invalid_grant) ne doit pas faire
+ // échouer StartAsync avec une exception interne : on
+ // renvoie le token courant (éventuellement expiré) et
+ // le serveur répondra un 401 propre, surfacé à l'UI.
+ options.AccessTokenProvider = async () =>
+ {
+ await _api.TrySilentLoginAsync();
+ return _api.CurrentAccessToken;
+ };
+ })
+ .WithAutomaticReconnect()
+ .Build();
+
+ // 1. Ecoute des notifications globales (notifyUser)
+ _connection.On("notifyUser", (type, user, msg) =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ Log($"[{type}] {user}: {msg}"));
+ });
+
+ // 2. Ecoute des notifications de salon (notifyRoom)
+ _connection.On("notifyRoom", (type, room, user) =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ if (_messagesByRoom.TryGetValue(room, out var roomLog))
+ roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
+ else
+ Log($"*{user} a interagi avec le salon {room} (Action: {type})");
+ if (type == "UserJoin" && _usersByRoom.TryGetValue(room, out var users)
+ && !users.Contains(user))
+ users.Add(user);
+ });
+ });
+
+ // 3. Réception de l'état complet suite au Join réussi (joint)
+ _connection.On("joint", (roomInfo) =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo));
+ });
+
+ // 4. Réception des messages de salon (diffusés par ChatHub.Send)
+ _connection.On("ReceiveMessage", (msg) =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ var room = msg.Room ?? "?";
+ if (!_messagesByRoom.TryGetValue(room, out var roomLog))
+ roomLog = _messagesByRoom[room] = new();
+ roomLog.Add($"{msg.Name} : {msg.Message}");
+ });
+ });
+
+ await _connection.StartAsync();
+ IsConnected = true;
+ Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info, new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
+
+ // WithAutomaticReconnect ne lève pas d'exception en cas de
+ // perte réseau : l'état passe par Reconnecting/Closed. Sans
+ // ces handlers, IsConnected resterait optimiste et Join()
+ // échouerait avec "connection is not active".
+ _connection.Reconnecting += error =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ IsConnected = false;
+ Status = new StatusNotice($"Connexion perdue ({error?.Message ?? "réseau"}), reconnexion...", StatusSeverity.Error,
+ new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
+ });
+ return Task.CompletedTask;
+ };
+ _connection.Reconnected += _ =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ IsConnected = true;
+ Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
+ new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
+ });
+ return Task.CompletedTask;
+ };
+ _connection.Closed += error =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ IsConnected = false;
+ Status = new StatusNotice($"Déconnecté ({error?.Message ?? "fin de connexion"})", StatusSeverity.Info,
+ new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
+ });
+ return Task.CompletedTask;
+ };
+ }
+ catch (Exception ex)
+ {
+ Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error, new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
+ IsConnected = false;
+ }
+ }
+
+ // Alimente JoinedRooms et les collections par salle à partir
+ // du ChatRoomInfo renvoyé par le hub (valeur de retour du Join
+ // et/ou événement "joint"). Idempotent.
+ private void OnRoomJoined(ChatRoomInfo roomInfo)
+ {
+ // ChatHub.Join renseigne toujours Name (salle existante ou
+ // créée) ; un Name nul signalerait un payload inattendu.
+ var roomName = roomInfo.Name ?? CurrentRoom;
+ if (roomInfo.Name == null)
+ Log("OnRoomJoined : nom de salle absent du payload, repli sur la salle demandée.");
+ if (!_messagesByRoom.TryGetValue(roomName, out var roomLog))
+ roomLog = _messagesByRoom[roomName] = new();
+ roomLog.Add($"Salon rejoint avec succès : {roomName}");
+ var users = _usersByRoom[roomName] = new();
+ foreach (var u in roomInfo.Users) users.Add(u);
+ if (!JoinedRooms.Contains(roomName))
+ JoinedRooms.Add(roomName);
+ SelectedRoom = roomName;
+ }
+
+ private Task CheckConnectionAsync()
+ {
+ var state = _connection?.State.ToString() ?? "aucune connexion";
+ Log($"État de la connexion : {state}");
+ return Task.CompletedTask;
+ }
+
+ private async Task JoinRoomAsync()
+ {
+ if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return;
+
+ // Salle déjà jointe : on se contente de la mettre en focus.
+ if (JoinedRooms.Contains(CurrentRoom))
+ {
+ SelectedRoom = CurrentRoom;
+ return;
+ }
+
+ // InvokeCoreAsync exige une connexion active : avec la
+ // reconnexion automatique, l'état peut être Reconnecting au
+ // moment de l'appel. On tente une (re)connexion si nécessaire.
+ if (_connection.State != HubConnectionState.Connected)
+ {
+ Log($"Connexion non active ({_connection.State}), tentative de reconnexion...");
+ try
+ {
+ await _connection.StartAsync();
+ }
+ catch (Exception ex)
+ {
+ Log($"Reconnexion impossible : {ex.Message}");
+ return;
+ }
+ }
+
+ try
+ {
+ // Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo)
+ var info = await _connection.InvokeAsync("Join", CurrentRoom);
+ if (info == null)
+ {
+ Log("Erreur lors de la validation du salon par le serveur.");
+ }
+ else
+ {
+ // On alimente le modèle depuis la valeur de retour,
+ // sans attendre l'événement "joint".
+ OnRoomJoined(info);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log($"Erreur Join: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs b/src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs
new file mode 100644
index 000000000..909d7a859
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows.Input;
+
+namespace PostIt.ViewModels.Chat
+{
+ internal class RelayCheckCommand : ICommand, IWithLabel
+ {
+ public string Label { get; }
+ private Func value;
+
+ public RelayCheckCommand(string v, Func value)
+ {
+ this.Label = v;
+ this.value = value;
+ }
+
+ public event EventHandler? CanExecuteChanged;
+
+ public bool CanExecute(object? parameter)
+ {
+ return value != null;
+ }
+
+ public void Execute(object? parameter)
+ {
+ if (value != null)
+ {
+ _ = value();
+ }
+ }
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs
index 5ad2b08f4..4898c9560 100644
--- a/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs
@@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using PostIt.Models;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs
index 72d9ce8ea..1b1847e96 100644
--- a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs
@@ -6,6 +6,7 @@ using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
+using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
diff --git a/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs b/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
index 614d477c8..9fe286ac0 100644
--- a/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
@@ -1,3 +1,5 @@
+using PostIt.Models;
+
namespace PostIt.ViewModels;
public interface IActionStatusViewModel
@@ -29,4 +31,4 @@ public static class ActionStatusViewModelExtensions
_ => StatusNotice.Info(normalizedMessage),
};
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
index 2c138a27d..b76f31f27 100644
--- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
@@ -3,8 +3,8 @@ using System.Threading.Tasks;
using Avalonia;
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 +36,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 +107,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/ViewModels/Layout/StatusNotice.cs b/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs
deleted file mode 100644
index c87af390c..000000000
--- a/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-namespace PostIt.ViewModels;
-
-public enum StatusSeverity
-{
- Info,
- Warning,
- Error
-}
-
-public sealed class StatusNotice
-{
- public string Message { get; }
- public StatusSeverity Severity { get; }
- public string Glyph { get; }
- public string Background { get; }
- public string BorderBrush { get; }
- public string Foreground { get; }
-
- private StatusNotice(string message, StatusSeverity severity)
- {
- Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
- Severity = severity;
-
- (Glyph, Background, BorderBrush, Foreground) = severity switch
- {
- StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
- StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
- _ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
- };
- }
-
- public static StatusNotice Info(string message) => new(message, StatusSeverity.Info);
- public static StatusNotice Warning(string message) => new(message, StatusSeverity.Warning);
- public static StatusNotice Error(string message) => new(message, StatusSeverity.Error);
-}
diff --git a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
index cc4b46828..169b29b16 100644
--- a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
@@ -7,6 +7,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
@@ -226,4 +227,4 @@ public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
index 5576f01cd..72eea5d35 100644
--- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
@@ -8,6 +8,8 @@ using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Globalization;
+using PostIt.Models;
[assembly: InternalsVisibleTo("PostIt.Tests")]
@@ -44,6 +46,7 @@ public partial class Settings : ViewModelBase
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
+ [JsonIgnore]
public bool Loaded { get; private set; } = false;
@@ -59,8 +62,16 @@ public partial class Settings : ViewModelBase
/// .
///
[ObservableProperty]
+ [JsonIgnore]
public partial bool IsDirty { get; private set; } = false;
+ [JsonIgnore]
+ public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
+
+ [JsonIgnore]
+ public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
+
+ public string SelectedLanguage { get; internal set; } = CultureInfo.CurrentUICulture.Name;
///
/// Guards every mutation of the observable state. [ObservableProperty]
@@ -261,10 +272,9 @@ public partial class Settings : ViewModelBase
}
var app = App.Current as App;
- if (app!=null)
- if (app.CliConfigFileSpecification!=null)
+ if (App.CliConfigFileSpecification!=null)
{
- SettingsFileFullName = app.CliConfigFileSpecification;
+ SettingsFileFullName = App.CliConfigFileSpecification;
}
if (SettingsFileFullName is not null)
@@ -532,7 +542,4 @@ public partial class Settings : ViewModelBase
///
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
-
- public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
- public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
}
diff --git a/src/PostIt/PostIt/ViewModels/StatusNotice.cs b/src/PostIt/PostIt/ViewModels/StatusNotice.cs
new file mode 100644
index 000000000..404287dbe
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/StatusNotice.cs
@@ -0,0 +1,40 @@
+using System.Windows.Input;
+using PostIt.Models;
+
+namespace PostIt.ViewModels;
+
+public sealed class StatusNotice
+{
+ public string Message { get; }
+ public StatusSeverity Severity { get; }
+ public string Glyph { get; }
+ public string Background { get; }
+ public string BorderBrush { get; }
+ public string Foreground { get; }
+
+ public bool HasCheckCommand { get => CheckCommand != null; }
+
+ public ICommand? CheckCommand { get; }
+
+ public string CheckCommandLabel { get; }
+
+ public StatusNotice(string message, StatusSeverity severity, ICommand? checkCommand)
+ {
+ Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
+ Severity = severity;
+
+ (Glyph, Background, BorderBrush, Foreground) = severity switch
+ {
+ StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
+ StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
+ _ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
+ };
+ CheckCommand = checkCommand;
+ CheckCommandLabel = HasCheckCommand ?
+ (checkCommand is IWithLabel labeled ? labeled.Label : "Check") : string.Empty;
+ }
+
+ public static StatusNotice Info(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Info, checkCommand);
+ public static StatusNotice Warning(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Warning, checkCommand);
+ public static StatusNotice Error(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Error, checkCommand);
+}
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
index 90397f09d..5c926bdee 100644
--- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
@@ -17,10 +17,14 @@
-
-
+
+
-
+
@@ -103,7 +107,7 @@
-
+
@@ -137,90 +141,61 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ IsPreviewVisible="{Binding IsMarkdownViewerVisible, Mode=TwoWay}"
+ MarkdownText="{Binding DraftArticle, Mode=TwoWay}"
+ Document="{Binding DraftArticleDocument, Mode=TwoWay}" />
-
-
-
-
-
-
-
-
-
-
-
+ VerticalAlignment="Stretch">
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
index 89743d0ad..b40021276 100644
--- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
@@ -4,72 +4,16 @@ using Avalonia.Controls;
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;
-
public BlogsPage()
{
InitializeComponent();
}
- protected override void OnLoaded(RoutedEventArgs e)
- {
- base.OnLoaded(e);
-
- // Récupère l'accès au TopLevel de la fenêtre/application
- var topLevel = TopLevel.GetTopLevel(this);
- if (topLevel != null)
- {
- _inputPane = topLevel.InputPane;
- if (_inputPane != null)
- {
- // S'abonne aux changements d'état du clavier virtuel
- _inputPane.StateChanged += OnInputPaneStateChanged;
-
- // Applique la configuration initiale si le clavier est déjà présent
- UpdateLayoutForKeyboard(_inputPane.OccludedRect.Height);
- }
- }
- }
-
- protected override void OnUnloaded(RoutedEventArgs e)
- {
- if (_inputPane != null)
- {
- _inputPane.StateChanged -= OnInputPaneStateChanged;
- }
- base.OnUnloaded(e);
- }
-
-
- private void UpdateLayoutForKeyboard(double keyboardHeight)
- {
- double visibleHeight = this.Bounds.Height - keyboardHeight;
- if (keyboardHeight > 0)
- {
- DraftArticleTextEditor.MaxHeight = visibleHeight;
- }
- else
- {
- DraftArticleTextEditor.MaxHeight = double.PositiveInfinity;
- }
- }
-
- private void OnInputPaneStateChanged(object? sender, InputPaneStateEventArgs e)
- {
- // e.EndRect contient le rectangle final du clavier virtuel
- // e.EndRect.Height vous donne sa hauteur exacte en pixels indépendants du périphérique (pixels logiques)
- UpdateLayoutForKeyboard(e.EndRect.Height);
- }
-
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
new file mode 100644
index 000000000..4749736a7
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
new file mode 100644
index 000000000..f73e1f827
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+namespace PostIt.Views.Chat;
+public partial class ChatPage : ContentPage
+{
+
+ public ChatPage()
+ {
+ InitializeComponent();
+ }
+
+}
diff --git a/src/PostIt/PostIt/Views/Layout/HomePage.axaml b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
index 31dd5851b..1de3ece68 100644
--- a/src/PostIt/PostIt/Views/Layout/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
@@ -1,6 +1,7 @@
@@ -10,10 +11,15 @@
-
+
+