diff --git a/README.md b/README.md index d338117..b4419f1 100644 --- a/README.md +++ b/README.md @@ -225,10 +225,46 @@ navigationService.Navigate(navigationUri); ``` ## Choosing the start page of your app +### (navigationService, serviceProvider) + +In the below example, we use both an `INavigationService` and an `IServiceProvider`. The `IServiceProvider` is used to resolve the .NET MAUI service, [`IPreferences`](https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/storage/preferences?tabs=android). If a username is stored in preferences, we use the `INavigationService` to go to the `HomePage` of the app. Otherwise, we go to the `LoginPage`. + +``` csharp +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .UseBurkusMvvm(burkusMvvm => + { + burkusMvvm.OnStart(async (navigationService, serviceProvider) => + { + var preferences = serviceProvider.GetRequiredService(); + + if (preferences.ContainsKey(PreferenceKeys.Username)) + { + // we are logged in to the app + await navigationService.Push(); + } + else + { + // logged out so we need to get the user to login + await navigationService.Push(); + } + }); + }) + ... +``` +### (IServiceProvider serviceProvider) + It is possible to have a service that decides which page is most appropriate to navigate to. This service could decide to: - Navigate to the "Terms & Conditions" page if the user has not agreed to the latest terms yet - Navigate to the "Signup / Login" page if the user is logged out - Navigate to the "Home" page if the user has used the app before and doesn't need to do anything + +In the below example, we only resolve a `IServiceProvider` which allows us to resolve `IAppStartupService`. The `IAppStartupService` will call the `INavigationService` internally to do the navigation. ```csharp public static class MauiProgram { @@ -238,9 +274,9 @@ public static class MauiProgram .UseMauiApp() .UseBurkusMvvm(burkusMvvm => { - burkusMvvm.OnStart(async (navigationService) => + burkusMvvm.OnStart(async (IServiceProvider serviceProvider) => { - var appStartupService = ServiceResolver.Resolve(); + var appStartupService = serviceProvider.GetRequiredService(); await appStartupService.NavigateToFirstPage(); }); }) diff --git a/samples/DemoApp/MauiProgram.cs b/samples/DemoApp/MauiProgram.cs index ef4b7b7..1022062 100644 --- a/samples/DemoApp/MauiProgram.cs +++ b/samples/DemoApp/MauiProgram.cs @@ -1,4 +1,5 @@ using DemoApp.Abstractions; +using DemoApp.Models; using DemoApp.Services; using DemoApp.ViewModels; using DemoApp.Views; @@ -15,9 +16,19 @@ public static MauiApp CreateMauiApp() .UseMauiApp() .UseBurkusMvvm(burkusMvvm => { - burkusMvvm.OnStart(async (navigationService) => + burkusMvvm.OnStart(async (navigationService, serviceProvider) => { - await navigationService.Push(); + var preferences = serviceProvider.GetRequiredService(); + + if (preferences.ContainsKey(PreferenceKeys.Username)) + { + // we are logged in to the app + await navigationService.Push(); + } + else + { + await navigationService.Push(); + } }); }) .RegisterViewModels() @@ -62,6 +73,7 @@ public static MauiAppBuilder RegisterViews(this MauiAppBuilder mauiAppBuilder) public static MauiAppBuilder RegisterServices(this MauiAppBuilder mauiAppBuilder) { + mauiAppBuilder.Services.AddSingleton(Preferences.Default); mauiAppBuilder.Services.AddSingleton(); return mauiAppBuilder; diff --git a/samples/DemoApp/Models/NavigationParameterKeys.cs b/samples/DemoApp/Models/NavigationParameterKeys.cs new file mode 100644 index 0000000..9031853 --- /dev/null +++ b/samples/DemoApp/Models/NavigationParameterKeys.cs @@ -0,0 +1,6 @@ +namespace DemoApp.Models; + +public static class NavigationParameterKeys +{ + public static readonly string Username = "username"; +} diff --git a/samples/DemoApp/Models/PreferenceKeys.cs b/samples/DemoApp/Models/PreferenceKeys.cs new file mode 100644 index 0000000..d389e1a --- /dev/null +++ b/samples/DemoApp/Models/PreferenceKeys.cs @@ -0,0 +1,10 @@ +namespace DemoApp.Models; + +public static class PreferenceKeys +{ + #region Preference keys + + public static readonly string Username = "username"; + + #endregion Preference keys +} diff --git a/samples/DemoApp/ViewModels/ChangeUsernameViewModel.cs b/samples/DemoApp/ViewModels/ChangeUsernameViewModel.cs index cbd8e43..cdd68b0 100644 --- a/samples/DemoApp/ViewModels/ChangeUsernameViewModel.cs +++ b/samples/DemoApp/ViewModels/ChangeUsernameViewModel.cs @@ -1,5 +1,8 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using Burkus.Mvvm.Maui; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using DemoApp.Models; +using DemoApp.Properties; namespace DemoApp.ViewModels; @@ -7,6 +10,8 @@ public partial class ChangeUsernameViewModel : BaseViewModel { #region Fields + private IPreferences preferences { get; } + private bool wasAnimatedNavigationUsed; #endregion Fields @@ -21,9 +26,11 @@ public partial class ChangeUsernameViewModel : BaseViewModel #region Constructors public ChangeUsernameViewModel( - INavigationService navigationService) + INavigationService navigationService, + IPreferences preferences) : base(navigationService) { + this.preferences = preferences; } #endregion Constructors @@ -43,9 +50,15 @@ public override async Task OnNavigatingFrom(NavigationParameters parameters) { await base.OnNavigatingFrom(parameters); - // pass 'Username' back regardless if the user presses the button - // or uses a different method of closing the modal (e.g. Android back button) - parameters.Add("username", Username); + if (IsValidUsername()) + { + // save username as a preference + preferences.Set(PreferenceKeys.Username, Username); + + // pass 'Username' back regardless if the user presses the button + // or uses a different method of closing the modal (e.g. Android back button) + parameters.Add(NavigationParameterKeys.Username, Username); + } // this is a modal, so we need to close it modally parameters.UseModalNavigation = true; @@ -70,4 +83,13 @@ private async Task Finish() } #endregion Commands + + #region Private methods + + private bool IsValidUsername() + { + return !string.IsNullOrWhiteSpace(Username); + } + + #endregion Private methods } diff --git a/samples/DemoApp/ViewModels/HomeViewModel.cs b/samples/DemoApp/ViewModels/HomeViewModel.cs index 39bf093..a1c26ee 100644 --- a/samples/DemoApp/ViewModels/HomeViewModel.cs +++ b/samples/DemoApp/ViewModels/HomeViewModel.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DemoApp.Abstractions; +using DemoApp.Models; using DemoApp.Views; namespace DemoApp.ViewModels; @@ -9,7 +10,9 @@ public partial class HomeViewModel : BaseViewModel { #region Fields - protected IWeatherService weatherService { get; } + private IPreferences preferences { get; } + + private IWeatherService weatherService { get; } #endregion Fields @@ -27,9 +30,11 @@ public partial class HomeViewModel : BaseViewModel public HomeViewModel( INavigationService navigationService, + IPreferences preferences, IWeatherService weatherService) : base(navigationService) { + this.preferences = preferences; this.weatherService = weatherService; } @@ -41,11 +46,14 @@ public override async Task OnNavigatedTo(NavigationParameters parameters) { await base.OnNavigatedTo(parameters); - var usernameValue = parameters.GetValue("username"); - - if (!string.IsNullOrWhiteSpace(usernameValue)) + if (parameters.ContainsKey(NavigationParameterKeys.Username)) { - Username = usernameValue; + Username = parameters.GetValue(NavigationParameterKeys.Username); + } + else + { + // load the username from preferences + Username = preferences.Get(PreferenceKeys.Username, default); } CurrentWeatherDescription = weatherService.GetWeatherDescription(); @@ -95,6 +103,9 @@ private async Task GoToTabbedPageDemo() [RelayCommand] private async Task Logout() { + // remove the username preference + preferences.Remove(PreferenceKeys.Username); + // use the navigate URI syntax to logout with an absolute URI await navigationService.Navigate("/LoginPage"); } diff --git a/samples/DemoApp/ViewModels/LoginViewModel.cs b/samples/DemoApp/ViewModels/LoginViewModel.cs index 2cdb82c..6e9f84b 100644 --- a/samples/DemoApp/ViewModels/LoginViewModel.cs +++ b/samples/DemoApp/ViewModels/LoginViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using DemoApp.Models; using DemoApp.Properties; using DemoApp.Views; @@ -9,7 +10,9 @@ public partial class LoginViewModel : BaseViewModel { #region Fields - protected IDialogService dialogService { get; } + private IDialogService dialogService { get; } + + private IPreferences preferences { get; } #endregion Fields @@ -27,10 +30,12 @@ public partial class LoginViewModel : BaseViewModel public LoginViewModel( IDialogService dialogService, - INavigationService navigationService) + INavigationService navigationService, + IPreferences preferences) : base(navigationService) { this.dialogService = dialogService; + this.preferences = preferences; } #endregion Constructors @@ -49,9 +54,12 @@ private async Task Login() return; } + // save username as a preference + preferences.Set(PreferenceKeys.Username, Username); + var navigationParameters = new NavigationParameters { - { "username", Username }, + { NavigationParameterKeys.Username, Username }, }; // after we login, we replace the stack so the user can't go back to the Login page @@ -73,7 +81,7 @@ private async Task Register() private bool IsValidLoginForm() { - if (string.IsNullOrEmpty(Username)) + if (string.IsNullOrWhiteSpace(Username)) { dialogService.DisplayAlert( Resources.Error, diff --git a/src/Abstractions/IBurkusMvvmBuilder.cs b/src/Abstractions/IBurkusMvvmBuilder.cs index 26bca97..78279b7 100644 --- a/src/Abstractions/IBurkusMvvmBuilder.cs +++ b/src/Abstractions/IBurkusMvvmBuilder.cs @@ -2,5 +2,5 @@ internal interface IBurkusMvvmBuilder { - Func onStartFunc { get; set; } + Func onStartFunc { get; set; } } diff --git a/src/Builders/InternalBurkusMvvmBuilder.cs b/src/Builders/InternalBurkusMvvmBuilder.cs index 942c655..c9bd258 100644 --- a/src/Builders/InternalBurkusMvvmBuilder.cs +++ b/src/Builders/InternalBurkusMvvmBuilder.cs @@ -5,5 +5,5 @@ /// internal class InternalBurkusMvvmBuilder : BurkusMvvmBuilder, IBurkusMvvmBuilder { - public Func onStartFunc { get; set; } + public Func onStartFunc { get; set; } } \ No newline at end of file diff --git a/src/Extensions/BurkusMvvmBuilderExtensions.cs b/src/Extensions/BurkusMvvmBuilderExtensions.cs index 8a9e12b..09e6053 100644 --- a/src/Extensions/BurkusMvvmBuilderExtensions.cs +++ b/src/Extensions/BurkusMvvmBuilderExtensions.cs @@ -6,9 +6,9 @@ public static class BurkusMvvmBuilderExtensions /// Define where the app should go first when starting. You must navigate to a page when starting. /// /// BurkusMvvmBuilder - /// Function to perform when starting with access to the + /// Function to perform when starting with access to and /// - public static BurkusMvvmBuilder OnStart(this BurkusMvvmBuilder builder, Func onStartFunc) + public static BurkusMvvmBuilder OnStart(this BurkusMvvmBuilder builder, Func onStartFunc) { var internalBuilder = builder as InternalBurkusMvvmBuilder; @@ -19,4 +19,26 @@ public static BurkusMvvmBuilder OnStart(this BurkusMvvmBuilder builder, Func + /// Define where the app should go first when starting. You must navigate to a page when starting. + /// + /// BurkusMvvmBuilder + /// Function to perform when starting with access to . + /// + public static BurkusMvvmBuilder OnStart(this BurkusMvvmBuilder builder, Func onStartFunc) + { + return OnStart(builder, (nav, sp) => onStartFunc(nav)); + } + + /// + /// Define where the app should go first when starting. You must navigate to a page when starting. + /// + /// BurkusMvvmBuilder + /// Function to perform when starting with access to . + /// + public static BurkusMvvmBuilder OnStart(this BurkusMvvmBuilder builder, Func onStartFunc) + { + return OnStart(builder, (nav, sp) => onStartFunc(sp)); + } } diff --git a/src/Models/BurkusMvvmApplication.cs b/src/Models/BurkusMvvmApplication.cs index ecdbc87..3769479 100644 --- a/src/Models/BurkusMvvmApplication.cs +++ b/src/Models/BurkusMvvmApplication.cs @@ -8,11 +8,12 @@ protected override Window CreateWindow(IActivationState? activationState) var burkusMvvmBuilder = ServiceResolver.Resolve(); var navigationService = ServiceResolver.Resolve(); + var serviceProvider = ServiceResolver.GetServiceProvider(); // perform the user's desired initialization logic if (burkusMvvmBuilder.onStartFunc != null) { - burkusMvvmBuilder.onStartFunc.Invoke(navigationService); + burkusMvvmBuilder.onStartFunc.Invoke(navigationService, serviceProvider); } return base.CreateWindow(activationState); diff --git a/tests/DemoApp.UnitTests/ViewModels/ChangeUsernameViewModelTests.cs b/tests/DemoApp.UnitTests/ViewModels/ChangeUsernameViewModelTests.cs index 0ef1252..8d7b74d 100644 --- a/tests/DemoApp.UnitTests/ViewModels/ChangeUsernameViewModelTests.cs +++ b/tests/DemoApp.UnitTests/ViewModels/ChangeUsernameViewModelTests.cs @@ -1,20 +1,21 @@ -using DemoApp.Abstractions; using DemoApp.ViewModels; -using DemoApp.Views; namespace DemoApp.UnitTests.Services; public class ChangeUsernameViewModelTests { private readonly INavigationService mockNavigationService; + private readonly IPreferences mockPreferences; public ChangeUsernameViewModelTests() { mockNavigationService = Substitute.For(); + mockPreferences = Substitute.For(); } public ChangeUsernameViewModel ViewModel => new ChangeUsernameViewModel( - mockNavigationService); + mockNavigationService, + mockPreferences); [Fact] public void Constructor_WhenResolved_ShouldSetNoProperties() @@ -42,6 +43,8 @@ public async Task OnNavigatingFrom_WhenAnimationNotUsed_ShouldAddNavigationParam Assert.Equal("Burkus", parameters.GetValue("username")); Assert.True(parameters.GetValue("UseModalNavigation")); Assert.False(parameters.GetValue("UseAnimatedNavigation")); + + mockPreferences.Received().Set("username", "Burkus"); } [Fact] @@ -64,6 +67,29 @@ public async Task OnNavigatingFrom_WhenAnimationUsed_ShouldAddNavigationParamete Assert.Equal("Burkus", navigatingFromParameters.GetValue("username")); Assert.True(navigatingFromParameters.GetValue("UseModalNavigation")); Assert.True(navigatingFromParameters.GetValue("UseAnimatedNavigation")); + + mockPreferences.Received().Set("username", "Burkus"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task OnNavigatingFrom_WhenInvalidUsername_ShouldSaveUsername( + string username) + { + // Arrange + var viewModel = ViewModel; + viewModel.Username = username; + var parameters = new NavigationParameters(); + + // Act + await viewModel.OnNavigatingFrom(parameters); + + // Assert + Assert.False(parameters.ContainsKey("username")); + + mockPreferences.DidNotReceiveWithAnyArgs(); } [Fact] diff --git a/tests/DemoApp.UnitTests/ViewModels/HomeViewModelTests.cs b/tests/DemoApp.UnitTests/ViewModels/HomeViewModelTests.cs index c84d43d..d1657fe 100644 --- a/tests/DemoApp.UnitTests/ViewModels/HomeViewModelTests.cs +++ b/tests/DemoApp.UnitTests/ViewModels/HomeViewModelTests.cs @@ -8,15 +8,18 @@ public class HomeViewModelTests { private readonly INavigationService mockNavigationService; private readonly IWeatherService mockWeatherService; + private readonly IPreferences mockPreferences; public HomeViewModelTests() { mockNavigationService = Substitute.For(); + mockPreferences = Substitute.For(); mockWeatherService = Substitute.For(); } public HomeViewModel ViewModel => new HomeViewModel( mockNavigationService, + mockPreferences, mockWeatherService); [Fact] @@ -53,20 +56,18 @@ public async Task OnNavigatedTo_WhenUsernamePassed_SetsUsernameValue() } [Fact] - public async Task OnNavigatedTo_WhenNoUsernamePassed_DoesNotSetUsernameValue() + public async Task OnNavigatedTo_WhenNoUsernamePassed_SetsUsernameFromPreferences() { // Arrange var viewModel = ViewModel; - var parameters = new NavigationParameters - { - { "username", string.Empty }, - }; + var parameters = new NavigationParameters(); + mockPreferences.Get("username", null).Returns("Smee"); // Act await viewModel.OnNavigatedTo(parameters); // Assert - Assert.Null(viewModel.Username); + Assert.Equal("Smee", viewModel.Username); } [Fact] @@ -108,6 +109,7 @@ public void LogoutCommand_WhenCalled_NavigatesToLoginPage() // Assert mockNavigationService.Received().Navigate("/LoginPage"); + mockPreferences.Received().Remove("username"); } [Fact] diff --git a/tests/DemoApp.UnitTests/ViewModels/LoginViewModelTests.cs b/tests/DemoApp.UnitTests/ViewModels/LoginViewModelTests.cs index 0e9854d..7ddbba6 100644 --- a/tests/DemoApp.UnitTests/ViewModels/LoginViewModelTests.cs +++ b/tests/DemoApp.UnitTests/ViewModels/LoginViewModelTests.cs @@ -7,16 +7,19 @@ public class LoginViewModelTests { private readonly IDialogService mockDialogService; private readonly INavigationService mockNavigationService; + private readonly IPreferences mockPreferences; public LoginViewModelTests() { mockDialogService = Substitute.For(); mockNavigationService = Substitute.For(); + mockPreferences = Substitute.For(); } public LoginViewModel ViewModel => new LoginViewModel( mockDialogService, - mockNavigationService); + mockNavigationService, + mockPreferences); [Fact] public void Constructor_WhenResolved_ShouldSetNoProperties() @@ -30,60 +33,72 @@ public void Constructor_WhenResolved_ShouldSetNoProperties() Assert.Null(viewModel.Password); } - [Fact] - public void LoginCommand_WhenMissingUsername_ShouldShowErrorMessage() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void LoginCommand_WhenMissingUsername_ShouldShowErrorMessage( + string username) { // Arrange var viewModel = ViewModel; + viewModel.Username = username; viewModel.Password = "secure123"; // Act viewModel.LoginCommand.Execute(null); // Assert + mockPreferences.DidNotReceiveWithAnyArgs(); mockDialogService.Received().DisplayAlert( "Error", "You must enter a username.", "OK"); - mockNavigationService.DidNotReceive().Push(); + mockNavigationService.DidNotReceiveWithAnyArgs(); } - [Fact] - public void LoginCommand_WhenMissingPassword_ShouldShowErrorMessage() + [Theory] + [InlineData(null)] + [InlineData("")] + public void LoginCommand_WhenMissingPassword_ShouldShowErrorMessage( + string password) { // Arrange var viewModel = ViewModel; viewModel.Username = "burkus@test.com"; + viewModel.Password = password; // Act viewModel.LoginCommand.Execute(null); // Assert + mockPreferences.DidNotReceiveWithAnyArgs(); mockDialogService.Received().DisplayAlert( "Error", "You must enter a password.", "OK"); - mockNavigationService.DidNotReceive().Push(); + mockNavigationService.DidNotReceiveWithAnyArgs(); } - [Fact] - public void LoginCommand_WhenValidLoginData_ShouldNavigateToHomePage() + [Theory] + [InlineData("secure123")] + [InlineData(" ")] + public void LoginCommand_WhenValidLoginData_ShouldNavigateToHomePage( + string password) { // Arrange var viewModel = ViewModel; viewModel.Username = "burkus@test.com"; - viewModel.Password = "secure123"; + viewModel.Password = password; // Act viewModel.LoginCommand.Execute(null); // Assert + mockPreferences.Received().Set("username", "burkus@test.com"); mockNavigationService.Received().ResetStackAndPush( Arg.Is(x => x.GetValue("username") == "burkus@test.com")); - mockDialogService.DidNotReceive().DisplayAlert( - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockDialogService.DidNotReceiveWithAnyArgs(); } [Fact]