Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/MoissonMontreal.Desktop/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MoissonMontreal.Desktop.App"
xmlns:local="using:MoissonMontreal.Desktop"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
RequestedThemeVariant="Light">

<Application.DataTemplates>
<local:ViewLocator />
Expand Down
44 changes: 43 additions & 1 deletion src/MoissonMontreal.Desktop/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,67 @@
using System;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MoissonMontreal.Desktop.Services;
using MoissonMontreal.Desktop.ViewModels;
using MoissonMontreal.Desktop.Views;


namespace MoissonMontreal.Desktop;

/// <summary>
/// Entry point of the Avalonia application: reads the configuration, sets up the services and opens the main window.
/// </summary>
public partial class App : Application
{
/// <summary>
/// Loads the application's XAML (styles and resources).
/// </summary>
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}

/// <summary>
/// Runs once Avalonia is ready: reads <c>appsettings.json</c>, registers the services and opens the main window.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when <c>appsettings.json</c> is missing, or when <c>Api:BaseUrl</c> is missing or invalid.</exception>
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.Build();

var apiBaseUrl = configuration["Api:BaseUrl"]
?? throw new InvalidOperationException(
$"Missing 'Api:BaseUrl': check that appsettings.json exists in '{AppContext.BaseDirectory}' and contains it.");

if (!Uri.TryCreate(apiBaseUrl, UriKind.Absolute, out var apiBaseUri)
|| (apiBaseUri.Scheme != Uri.UriSchemeHttp && apiBaseUri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException($"Invalid 'Api:BaseUrl' in appsettings.json: '{apiBaseUrl}'. It must be a full URL starting with http:// or https://.");
}

var services = new ServiceCollection();

services.AddHttpClient<IApiClient, ApiClient>(client =>
{
client.BaseAddress = apiBaseUri;
client.Timeout = TimeSpan.FromSeconds(15);
});
services.AddSingleton<Func<IApiClient>>(sp => () => sp.GetRequiredService<IApiClient>());
services.AddSingleton<MainViewModel>();

@lauriechammah lauriechammah Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typed client registered with AddHttpClient<IApiClient, ApiClient> is transient, but MainViewModel is a singleton that holds onto it for the app's whole lifetime (captive dependency). The handler is never rotated, so if the API's DNS/IP changes during a long session, the app will be stuck on "Server unavailable".

Suggestion: inject IHttpClientFactory or a Func<IApiClient> into MainViewModel, or register HomeViewModel in the container and resolve it on each navigation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3833ade: MainViewModel now takes a Func<IApiClient> instead of an IApiClient, registered as services.AddSingleton<Func<IApiClient>>(sp => () => sp.GetRequiredService<IApiClient>()). ShowHome calls it on each navigation, so every HomeViewModel gets a fresh ApiClient from IHttpClientFactory and the handler gets rotated.


var serviceProvider = services.BuildServiceProvider();

desktop.MainWindow = new MainWindow
{
DataContext = new MainViewModel(),
DataContext = serviceProvider.GetRequiredService<MainViewModel>(),
};
}

Expand Down
24 changes: 24 additions & 0 deletions src/MoissonMontreal.Desktop/Design/DesignData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using MoissonMontreal.Desktop.ViewModels;

namespace MoissonMontreal.Desktop.Design;

/// <summary>
/// Ready-made ViewModels for the IDE previewer. Never used when the app runs.
/// </summary>
public static class DesignData
{
/// <summary>
/// Main window ViewModel backed by a <see cref="FakeApiClient"/>.
/// </summary>
public static MainViewModel Main { get; } = new(() => new FakeApiClient());

/// <summary>
/// Home page ViewModel backed by a <see cref="FakeApiClient"/>.
/// </summary>
public static HomeViewModel Home { get; } = new(new FakeApiClient());

/// <summary>
/// Login page ViewModel whose sign-in does nothing.
/// </summary>
public static LoginViewModel Login { get; } = new(() => { });
}
17 changes: 17 additions & 0 deletions src/MoissonMontreal.Desktop/Design/FakeApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using MoissonMontreal.Desktop.Services;

namespace MoissonMontreal.Desktop.Design;

/// <summary>
/// Fake API client for the IDE previewer. Always reports the API as online, without any network call.
/// </summary>
public class FakeApiClient : IApiClient
{
/// <inheritdoc/>
public Task<bool> IsApiReachableAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(true);
}
}
4 changes: 4 additions & 0 deletions src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup>
Expand All @@ -21,6 +22,9 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.14" />
</ItemGroup>

<ItemGroup>
Expand Down
19 changes: 15 additions & 4 deletions src/MoissonMontreal.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,27 @@

namespace MoissonMontreal.Desktop;

/// <summary>
/// Starts the desktop application.
/// </summary>
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
/// <summary>
/// Program entry point: starts Avalonia with a classic desktop window.
/// </summary>
/// <remarks>
/// Don't use any Avalonia, third-party APIs or any SynchronizationContext-reliant code
/// before AppMain is called: things aren't initialized yet and stuff might break.
/// </remarks>
/// <param name="args">Command-line arguments.</param>
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);

// Avalonia configuration, don't remove; also used by visual designer.
/// <summary>
/// Configures Avalonia. Don't remove: the visual designer uses it too.
/// </summary>
/// <returns>The configured application builder.</returns>
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
Expand Down
44 changes: 44 additions & 0 deletions src/MoissonMontreal.Desktop/Services/ApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Threading.Tasks;
using System.Net.Http;
using System.Threading;

namespace MoissonMontreal.Desktop.Services;

/// <summary>
/// Calls the central API over HTTP.
/// </summary>
public class ApiClient : IApiClient
{
/// <summary>
/// HTTP client configured in <c>App</c> with the API's base address and timeout.
/// </summary>
private readonly HttpClient _httpClient;

/// <summary>
/// Creates the client.
/// </summary>
/// <param name="httpClient">HTTP client provided by the dependency injection container.</param>
public ApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}

/// <inheritdoc/>
public async Task<bool> IsApiReachableAsync(CancellationToken cancellationToken = default)
{
try
{
// Any endpoint that answers successfully proves the API is reachable.
using var response = await _httpClient.GetAsync("WeatherForecast", cancellationToken);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
catch (TaskCanceledException)
{
return false;
}
}
}
19 changes: 19 additions & 0 deletions src/MoissonMontreal.Desktop/Services/IApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;

namespace MoissonMontreal.Desktop.Services;

/// <summary>
/// Talks to the central Moisson Montréal API.
/// </summary>
public interface IApiClient
{
/// <summary>
/// Checks whether the API answers.
/// </summary>
/// <param name="cancellationToken">Lets the caller stop the check early (for example when leaving the page). If cancelled, the method returns <c>false</c>.</param>
/// <returns><c>true</c> if the API answered successfully; <c>false</c> if it is unreachable or returned an error.</returns>
Task<bool> IsApiReachableAsync(CancellationToken cancellationToken = default);
}


10 changes: 10 additions & 0 deletions src/MoissonMontreal.Desktop/ViewLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ namespace MoissonMontreal.Desktop;
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
/// <summary>
/// Creates the view that matches a ViewModel (for example <c>HomeViewModel</c> → <c>HomeView</c>).
/// </summary>
/// <param name="param">The ViewModel to show.</param>
/// <returns>The matching view, a "Not Found" text if there is none, or <c>null</c> if <paramref name="param"/> is <c>null</c>.</returns>
public Control? Build(object? param)
{
if (param is null)
Expand All @@ -30,6 +35,11 @@ public class ViewLocator : IDataTemplate
return new TextBlock { Text = "Not Found: " + name };
}

/// <summary>
/// Tells Avalonia whether this locator can show the given object.
/// </summary>
/// <param name="data">The object to show.</param>
/// <returns><c>true</c> if the object is a ViewModel.</returns>
public bool Match(object? data)
{
return data is ViewModelBase;
Expand Down
12 changes: 12 additions & 0 deletions src/MoissonMontreal.Desktop/ViewModels/DonationsViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace MoissonMontreal.Desktop.ViewModels;

/// <summary>
/// Logic of the donations page (placeholder for now).
/// </summary>
public class DonationsViewModel : ViewModelBase
{
/// <summary>
/// Title shown at the top of the donations page.
/// </summary>
public string Title => "Donations";
}
12 changes: 12 additions & 0 deletions src/MoissonMontreal.Desktop/ViewModels/DonorsViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace MoissonMontreal.Desktop.ViewModels;

/// <summary>
/// Logic of the donors page (placeholder for now).
/// </summary>
public class DonorsViewModel : ViewModelBase
{
/// <summary>
/// Title shown at the top of the donors page.
/// </summary>
public string Title => "Donors";
}
73 changes: 73 additions & 0 deletions src/MoissonMontreal.Desktop/ViewModels/HomeViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MoissonMontreal.Desktop.Services;

namespace MoissonMontreal.Desktop.ViewModels;

/// <summary>
/// Logic of the home page: shows whether the API is reachable.
/// </summary>
public partial class HomeViewModel : ViewModelBase
{
/// <summary>
/// Client used to check whether the API is reachable.
/// </summary>
private readonly IApiClient _apiClient;

/// <summary>
/// Status message shown while the API check is running.
/// </summary>
private const string CheckingApiMessage = "Checking the API...";

/// <summary>
/// Status message shown when the API cannot be reached.
/// </summary>
private const string ServerUnavailableMessage = "Server unavailable";

/// <summary>
/// Status message shown when the API answered successfully.
/// </summary>
private const string ApiOnlineMessage = "API online";

/// <summary>
/// Title shown at the top of the home page.
/// </summary>
public string Title => "Welcome";

/// <summary>
/// API status message shown to the user.
/// </summary>
[ObservableProperty]
public partial string ApiStatus { get; set; } = CheckingApiMessage;

/// <summary>
/// Creates the home page. The API check starts when the view is loaded.
/// </summary>
/// <param name="apiClient">Client used to call the API.</param>
public HomeViewModel(IApiClient apiClient)
{
_apiClient = apiClient;
}

/// <summary>
/// Checks the API and updates <see cref="ApiStatus"/>. Any error shows <see cref="ServerUnavailableMessage"/> instead of crashing the app.
/// </summary>
/// <param name="cancellationToken">Cancelled when the user leaves the home page, so the check doesn't keep running.</param>
[RelayCommand]
private async Task CheckApiAsync(CancellationToken cancellationToken)
{
ApiStatus = CheckingApiMessage;
try
{
var isReachable = await _apiClient.IsApiReachableAsync(cancellationToken);
ApiStatus = isReachable ? ApiOnlineMessage : ServerUnavailableMessage;
}
catch (Exception)
{
ApiStatus = ServerUnavailableMessage;
}
}
}
Loading