diff --git a/src/MoissonMontreal.Desktop/App.axaml b/src/MoissonMontreal.Desktop/App.axaml index f0cf256..72b7a9c 100644 --- a/src/MoissonMontreal.Desktop/App.axaml +++ b/src/MoissonMontreal.Desktop/App.axaml @@ -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"> - + RequestedThemeVariant="Light"> diff --git a/src/MoissonMontreal.Desktop/App.axaml.cs b/src/MoissonMontreal.Desktop/App.axaml.cs index 6dcf77a..0628a89 100644 --- a/src/MoissonMontreal.Desktop/App.axaml.cs +++ b/src/MoissonMontreal.Desktop/App.axaml.cs @@ -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; +/// +/// Entry point of the Avalonia application: reads the configuration, sets up the services and opens the main window. +/// public partial class App : Application { + /// + /// Loads the application's XAML (styles and resources). + /// public override void Initialize() { AvaloniaXamlLoader.Load(this); } + /// + /// Runs once Avalonia is ready: reads appsettings.json, registers the services and opens the main window. + /// + /// Thrown when appsettings.json is missing, or when Api:BaseUrl is missing or invalid. 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(client => + { + client.BaseAddress = apiBaseUri; + client.Timeout = TimeSpan.FromSeconds(15); + }); + services.AddSingleton>(sp => () => sp.GetRequiredService()); + services.AddSingleton(); + + var serviceProvider = services.BuildServiceProvider(); + desktop.MainWindow = new MainWindow { - DataContext = new MainViewModel(), + DataContext = serviceProvider.GetRequiredService(), }; } diff --git a/src/MoissonMontreal.Desktop/Design/DesignData.cs b/src/MoissonMontreal.Desktop/Design/DesignData.cs new file mode 100644 index 0000000..b805689 --- /dev/null +++ b/src/MoissonMontreal.Desktop/Design/DesignData.cs @@ -0,0 +1,24 @@ +using MoissonMontreal.Desktop.ViewModels; + +namespace MoissonMontreal.Desktop.Design; + +/// +/// Ready-made ViewModels for the IDE previewer. Never used when the app runs. +/// +public static class DesignData +{ + /// + /// Main window ViewModel backed by a . + /// + public static MainViewModel Main { get; } = new(() => new FakeApiClient()); + + /// + /// Home page ViewModel backed by a . + /// + public static HomeViewModel Home { get; } = new(new FakeApiClient()); + + /// + /// Login page ViewModel whose sign-in does nothing. + /// + public static LoginViewModel Login { get; } = new(() => { }); +} diff --git a/src/MoissonMontreal.Desktop/Design/FakeApiClient.cs b/src/MoissonMontreal.Desktop/Design/FakeApiClient.cs new file mode 100644 index 0000000..6c974bf --- /dev/null +++ b/src/MoissonMontreal.Desktop/Design/FakeApiClient.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using MoissonMontreal.Desktop.Services; + +namespace MoissonMontreal.Desktop.Design; + +/// +/// Fake API client for the IDE previewer. Always reports the API as online, without any network call. +/// +public class FakeApiClient : IApiClient +{ + /// + public Task IsApiReachableAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(true); + } +} diff --git a/src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj b/src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj index 3ffe9ac..96b0f45 100644 --- a/src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj +++ b/src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj @@ -9,6 +9,7 @@ + @@ -21,6 +22,9 @@ All + + + diff --git a/src/MoissonMontreal.Desktop/Program.cs b/src/MoissonMontreal.Desktop/Program.cs index 122194e..9795526 100644 --- a/src/MoissonMontreal.Desktop/Program.cs +++ b/src/MoissonMontreal.Desktop/Program.cs @@ -3,16 +3,27 @@ namespace MoissonMontreal.Desktop; +/// +/// Starts the desktop application. +/// 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. + /// + /// Program entry point: starts Avalonia with a classic desktop window. + /// + /// + /// 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. + /// + /// Command-line arguments. [STAThread] public static void Main(string[] args) => BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); - // Avalonia configuration, don't remove; also used by visual designer. + /// + /// Configures Avalonia. Don't remove: the visual designer uses it too. + /// + /// The configured application builder. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() diff --git a/src/MoissonMontreal.Desktop/Services/ApiClient.cs b/src/MoissonMontreal.Desktop/Services/ApiClient.cs new file mode 100644 index 0000000..2e421da --- /dev/null +++ b/src/MoissonMontreal.Desktop/Services/ApiClient.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using System.Net.Http; +using System.Threading; + +namespace MoissonMontreal.Desktop.Services; + +/// +/// Calls the central API over HTTP. +/// +public class ApiClient : IApiClient +{ + /// + /// HTTP client configured in App with the API's base address and timeout. + /// + private readonly HttpClient _httpClient; + + /// + /// Creates the client. + /// + /// HTTP client provided by the dependency injection container. + public ApiClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// + public async Task 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; + } + } +} \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/Services/IApiClient.cs b/src/MoissonMontreal.Desktop/Services/IApiClient.cs new file mode 100644 index 0000000..1d6976d --- /dev/null +++ b/src/MoissonMontreal.Desktop/Services/IApiClient.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace MoissonMontreal.Desktop.Services; + +/// +/// Talks to the central Moisson Montréal API. +/// +public interface IApiClient +{ + /// + /// Checks whether the API answers. + /// + /// Lets the caller stop the check early (for example when leaving the page). If cancelled, the method returns false. + /// true if the API answered successfully; false if it is unreachable or returned an error. + Task IsApiReachableAsync(CancellationToken cancellationToken = default); +} + + diff --git a/src/MoissonMontreal.Desktop/ViewLocator.cs b/src/MoissonMontreal.Desktop/ViewLocator.cs index a7a5bce..8821c7b 100644 --- a/src/MoissonMontreal.Desktop/ViewLocator.cs +++ b/src/MoissonMontreal.Desktop/ViewLocator.cs @@ -14,6 +14,11 @@ namespace MoissonMontreal.Desktop; Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] public class ViewLocator : IDataTemplate { + /// + /// Creates the view that matches a ViewModel (for example HomeViewModel → HomeView). + /// + /// The ViewModel to show. + /// The matching view, a "Not Found" text if there is none, or null if is null. public Control? Build(object? param) { if (param is null) @@ -30,6 +35,11 @@ public class ViewLocator : IDataTemplate return new TextBlock { Text = "Not Found: " + name }; } + /// + /// Tells Avalonia whether this locator can show the given object. + /// + /// The object to show. + /// true if the object is a ViewModel. public bool Match(object? data) { return data is ViewModelBase; diff --git a/src/MoissonMontreal.Desktop/ViewModels/DonationsViewModel.cs b/src/MoissonMontreal.Desktop/ViewModels/DonationsViewModel.cs new file mode 100644 index 0000000..464e97f --- /dev/null +++ b/src/MoissonMontreal.Desktop/ViewModels/DonationsViewModel.cs @@ -0,0 +1,12 @@ +namespace MoissonMontreal.Desktop.ViewModels; + +/// +/// Logic of the donations page (placeholder for now). +/// +public class DonationsViewModel : ViewModelBase +{ + /// + /// Title shown at the top of the donations page. + /// + public string Title => "Donations"; +} \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/ViewModels/DonorsViewModel.cs b/src/MoissonMontreal.Desktop/ViewModels/DonorsViewModel.cs new file mode 100644 index 0000000..f2299e1 --- /dev/null +++ b/src/MoissonMontreal.Desktop/ViewModels/DonorsViewModel.cs @@ -0,0 +1,12 @@ +namespace MoissonMontreal.Desktop.ViewModels; + +/// +/// Logic of the donors page (placeholder for now). +/// +public class DonorsViewModel : ViewModelBase +{ + /// + /// Title shown at the top of the donors page. + /// + public string Title => "Donors"; +} \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/ViewModels/HomeViewModel.cs b/src/MoissonMontreal.Desktop/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..3387ceb --- /dev/null +++ b/src/MoissonMontreal.Desktop/ViewModels/HomeViewModel.cs @@ -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; + +/// +/// Logic of the home page: shows whether the API is reachable. +/// +public partial class HomeViewModel : ViewModelBase +{ + /// + /// Client used to check whether the API is reachable. + /// + private readonly IApiClient _apiClient; + + /// + /// Status message shown while the API check is running. + /// + private const string CheckingApiMessage = "Checking the API..."; + + /// + /// Status message shown when the API cannot be reached. + /// + private const string ServerUnavailableMessage = "Server unavailable"; + + /// + /// Status message shown when the API answered successfully. + /// + private const string ApiOnlineMessage = "API online"; + + /// + /// Title shown at the top of the home page. + /// + public string Title => "Welcome"; + + /// + /// API status message shown to the user. + /// + [ObservableProperty] + public partial string ApiStatus { get; set; } = CheckingApiMessage; + + /// + /// Creates the home page. The API check starts when the view is loaded. + /// + /// Client used to call the API. + public HomeViewModel(IApiClient apiClient) + { + _apiClient = apiClient; + } + + /// + /// Checks the API and updates . Any error shows instead of crashing the app. + /// + /// Cancelled when the user leaves the home page, so the check doesn't keep running. + [RelayCommand] + private async Task CheckApiAsync(CancellationToken cancellationToken) + { + ApiStatus = CheckingApiMessage; + try + { + var isReachable = await _apiClient.IsApiReachableAsync(cancellationToken); + ApiStatus = isReachable ? ApiOnlineMessage : ServerUnavailableMessage; + } + catch (Exception) + { + ApiStatus = ServerUnavailableMessage; + } + } +} \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/ViewModels/LoginViewModel.cs b/src/MoissonMontreal.Desktop/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..93d6f49 --- /dev/null +++ b/src/MoissonMontreal.Desktop/ViewModels/LoginViewModel.cs @@ -0,0 +1,38 @@ +using System; +using CommunityToolkit.Mvvm.Input; + +namespace MoissonMontreal.Desktop.ViewModels; + +/// +/// Logic of the login page. Sign-in is simulated for now (real sign-in comes with Entra ID). +/// +public partial class LoginViewModel : ViewModelBase +{ + /// + /// Title shown at the top of the login page. + /// + public string Title => "Moisson Montréal"; + + /// + /// Action to run once the user is signed in (opens the home page). + /// + private readonly Action _onSignedIn; + + /// + /// Creates the login page. + /// + /// Action to run once the user is signed in. + public LoginViewModel(Action onSignedIn) + { + _onSignedIn = onSignedIn; + } + + /// + /// Signs the user in (simulated) and runs the "signed in" action. + /// + [RelayCommand] + private void SignIn() + { + _onSignedIn(); + } +} \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/ViewModels/MainViewModel.cs b/src/MoissonMontreal.Desktop/ViewModels/MainViewModel.cs index 4c69889..cef0f71 100644 --- a/src/MoissonMontreal.Desktop/ViewModels/MainViewModel.cs +++ b/src/MoissonMontreal.Desktop/ViewModels/MainViewModel.cs @@ -1,9 +1,81 @@ +using System; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MoissonMontreal.Desktop.Services; namespace MoissonMontreal.Desktop.ViewModels; +/// +/// Logic of the main window: decides which page is shown and handles navigation. +/// public partial class MainViewModel : ViewModelBase { + /// + /// Creates a new API client each time the home page is opened. + /// + private readonly Func _createApiClient; + + /// + /// Page currently shown in the main window. + /// [ObservableProperty] - public partial string Greeting { get; set; } = "Welcome to Avalonia!"; + [NotifyPropertyChangedFor(nameof(IsSignedIn))] + public partial ViewModelBase CurrentPage {get; set;} + + /// + /// true when the user is past the login page; shows the navigation bar and the Sign out button. + /// + public bool IsSignedIn => CurrentPage is not LoginViewModel; + + /// + /// Creates the main ViewModel and shows the login page. + /// + /// Function that returns a new API client on each call. + public MainViewModel(Func createApiClient) + { + _createApiClient = createApiClient; + CurrentPage = CreateLoginPage(); + } + + /// + /// Creates the login page, which opens the home page after sign-in. + /// + /// A new login page. + private LoginViewModel CreateLoginPage() => new LoginViewModel(ShowHome); + + /// + /// Shows the home page with a new API client. + /// + [RelayCommand] + private void ShowHome() + { + CurrentPage = new HomeViewModel(_createApiClient()); + } + + /// + /// Shows the donations page. + /// + [RelayCommand] + private void ShowDonations() + { + CurrentPage = new DonationsViewModel(); + } + + /// + /// Shows the donors page. + /// + [RelayCommand] + private void ShowDonors() + { + CurrentPage = new DonorsViewModel(); + } + + /// + /// Signs the user out and goes back to the login page. + /// + [RelayCommand] + private void SignOut() + { + CurrentPage = CreateLoginPage(); + } } diff --git a/src/MoissonMontreal.Desktop/ViewModels/ViewModelBase.cs b/src/MoissonMontreal.Desktop/ViewModels/ViewModelBase.cs index c178d94..c490cd6 100644 --- a/src/MoissonMontreal.Desktop/ViewModels/ViewModelBase.cs +++ b/src/MoissonMontreal.Desktop/ViewModels/ViewModelBase.cs @@ -2,6 +2,9 @@ namespace MoissonMontreal.Desktop.ViewModels; +/// +/// Base class for every ViewModel. Lets the screen update when a property changes. +/// public abstract class ViewModelBase : ObservableObject { } diff --git a/src/MoissonMontreal.Desktop/Views/DonationsView.axaml b/src/MoissonMontreal.Desktop/Views/DonationsView.axaml new file mode 100644 index 0000000..42cd8d1 --- /dev/null +++ b/src/MoissonMontreal.Desktop/Views/DonationsView.axaml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/Views/DonationsView.axaml.cs b/src/MoissonMontreal.Desktop/Views/DonationsView.axaml.cs new file mode 100644 index 0000000..6a037bc --- /dev/null +++ b/src/MoissonMontreal.Desktop/Views/DonationsView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; + +namespace MoissonMontreal.Desktop.Views; + +/// +/// Donations page screen. +/// +public partial class DonationsView : UserControl +{ + /// + /// Creates the view and loads its XAML. + /// + public DonationsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/Views/DonorsView.axaml b/src/MoissonMontreal.Desktop/Views/DonorsView.axaml new file mode 100644 index 0000000..731a1f4 --- /dev/null +++ b/src/MoissonMontreal.Desktop/Views/DonorsView.axaml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/Views/DonorsView.axaml.cs b/src/MoissonMontreal.Desktop/Views/DonorsView.axaml.cs new file mode 100644 index 0000000..6de75fa --- /dev/null +++ b/src/MoissonMontreal.Desktop/Views/DonorsView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; + +namespace MoissonMontreal.Desktop.Views; + +/// +/// Donors page screen. +/// +public partial class DonorsView : UserControl +{ + /// + /// Creates the view and loads its XAML. + /// + public DonorsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/MoissonMontreal.Desktop/Views/HomeView.axaml b/src/MoissonMontreal.Desktop/Views/HomeView.axaml new file mode 100644 index 0000000..87271ae --- /dev/null +++ b/src/MoissonMontreal.Desktop/Views/HomeView.axaml @@ -0,0 +1,17 @@ + + + + + +