Skip to content

Instructions 06 MAUI2

Sebastian Szvetecz edited this page Sep 28, 2023 · 4 revisions

Part 6 - .NET MAUI Part 2: View-Models

Implement the View-Models for the game

Implement a View-Model for the GamePage

If you are not interested in developing the view-model library on your own, because you want to focus on something else or you simply have not enough time, you can use our NuGet package CNInnovation.Codebreaker.ViewModels. Bear in mind, that you still need to register the view-models in the DI container.

  1. Open the View-Model library CodeBreaker.ViewModels
  2. Add a reference to the NuGet package CommunityToolkit.Mvvm and Microsoft.Extensions.Options
  3. Add the IDialogService - this is a contract to open a dialog
  4. Add the InfoMessageViewModel and the InfoBarMessageService - this is an alternative API to show a dialog
  5. Add the SelectedFieldViewModel - this is a simple view-model which will be used for a selection.
  6. Add the GamePageViewModel
  7. Add the GameViewModel - this is a view-model just to show information
  8. Add the MoveViewModel - this is a view-model to make a move

Setup the View-Models in the MAUI Application

  1. Create a custom implementation of the IDialogService

ViewModel Library Code

IDialogService

public interface IDialogService
{
    Task ShowMessageAsync(string message);
}

InfoBarMessageService

public class InfoBarMessageService
{
    public ObservableCollection<InfoMessageViewModel> Messages { get; } = new();

    public void ShowMessage(InfoMessageViewModel message)
    {
        message.ContainingCollection = Messages;
        Messages.Add(message);
    }

    public void ShowInformation(string content) => ShowMessage(InfoMessageViewModel.Information(content));

    public void ShowWarning(string content) => ShowMessage(InfoMessageViewModel.Warning(content));

    public void ShowError(string content) => ShowMessage(InfoMessageViewModel.Error(content));

    public void ShowSuccess(string content) => ShowMessage(InfoMessageViewModel.Success(content));

    public void Clear() =>
        Messages.Clear();
}

InfoMessageViewModel

public enum InfoMessageSeverity
{
    Info,
    Success,
    Warning,
    Error
}

public partial class InfoMessageViewModel : ObservableObject
{
    public static InfoMessageViewModel Error(string content)
    {
        InfoMessageViewModel message = new()
        {
            Title = "Error",
            Message = content,
            Severity = InfoMessageSeverity.Error,
            ActionTitle = "OK"
        };
        message.ActionCommand = new RelayCommand(() => message.Close());
        return message;
    }

    public static InfoMessageViewModel Warning(string content)
    {
        InfoMessageViewModel message = new()
        {
            Title = "Warning",
            Message = content,
            Severity = InfoMessageSeverity.Warning,
            ActionTitle = "OK"
        };
        message.ActionCommand = new RelayCommand(() => message.Close());
        return message;
    }

    public static InfoMessageViewModel Information(string content)
    {
        InfoMessageViewModel message = new()
        {
            Title = "Information",
            Message = content,
            Severity = InfoMessageSeverity.Info,
            ActionTitle = "OK"
        };
        message.ActionCommand = new RelayCommand(() => message.Close());
        return message;
    }

    public static InfoMessageViewModel Success(string content)
    {
        InfoMessageViewModel message = new()
        {
            Title = "Success",
            Message = content,
            Severity = InfoMessageSeverity.Success,
            ActionTitle = "OK"
        };
        message.ActionCommand = new RelayCommand(() => message.Close());
        return message;
    }

    internal ICollection<InfoMessageViewModel>? ContainingCollection { get; set; }

    [ObservableProperty]
    private InfoMessageSeverity _severity = InfoMessageSeverity.Info;

    [ObservableProperty]
    private string _message = string.Empty;

    [ObservableProperty]
    private string _title = string.Empty;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasAction))]
    private ICommand? _actionCommand;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasAction))]
    private string? _actionTitle = "OK";

    public bool HasAction =>
        ActionCommand is not null && ActionTitle is not null;

    public void Close() =>
        ContainingCollection?.Remove(this);
}

SelectedFieldViewModel

public partial class SelectedFieldViewModel : ObservableObject
{
    [ObservableProperty]
    private string? _value;

    public bool IsSet =>
        Value is not null && Value != string.Empty;

    public void Reset() =>
        Value = null;
}

GamePageViewModel

public enum GameMode
{
    NotRunning,
    Started,
    MoveSet,
    Lost,
    Won
}

public enum GameMoveValue
{
    Started,
    Completed
}

public class GamePageViewModelOptions
{
    public bool EnableDialogs { get; set; } = false;
}

public partial class GamePageViewModel : ObservableObject
{
    private readonly IGameClient _client;
    private int _moveNumber = 0;
    private GameDto? _game;
    private readonly bool _enableDialogs = false;
    private readonly IDialogService _dialogService;

    public GamePageViewModel(
        IGameClient client,
        IOptions<GamePageViewModelOptions> options,
        IDialogService dialogService)
    {
        _client = client;
        _dialogService = dialogService;
        _enableDialogs = options.Value.EnableDialogs;

        PropertyChanged += (sender, e) =>
        {
            if (e.PropertyName == nameof(GameStatus))
                WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(GameStatus));
        };
    }

    public InfoBarMessageService InfoBarMessageService { get; } = new();

    public GameDto? Game
    {
        get => _game;
        set
        {
            OnPropertyChanging(nameof(Game));
            OnPropertyChanging(nameof(Fields));
            _game = value;

            Fields.Clear();

            for (int i = 0; i < value?.Holes; i++)
            {
                SelectedFieldViewModel field = new();
                field.PropertyChanged += (sender, e) => SetMoveCommand.NotifyCanExecuteChanged();
                Fields.Add(field);
            }

            OnPropertyChanged(nameof(Game));
            OnPropertyChanged(nameof(Fields));
        }
    }

    [ObservableProperty]
    private string _name = string.Empty;

    [NotifyPropertyChangedFor(nameof(IsNameEnterable))]
    [ObservableProperty]
    private bool _isNamePredefined = false;

    public ObservableCollection<SelectedFieldViewModel> Fields { get; } = new(); 

    public ObservableCollection<SelectionAndKeyPegs> GameMoves { get; } = new();

    [ObservableProperty]
    private GameMode _gameStatus = GameMode.NotRunning;

    [NotifyPropertyChangedFor(nameof(IsNameEnterable))]
    [ObservableProperty]
    private bool _inProgress = false;

    [ObservableProperty]
    private bool _isCancelling = false;

    public bool IsNameEnterable => !InProgress && !IsNamePredefined;

    [RelayCommand(AllowConcurrentExecutions = false, FlowExceptionsToTaskScheduler = true)]
    private async Task StartGameAsync()
    {
        try
        {
            InitializeValues();

            InProgress = true;
            CreateGameResponse response = await _client.StartGameAsync(Name);

            GameStatus = GameMode.Started;

            Game = response.Game;
            _moveNumber++;
        }
        catch (Exception ex)
        {
            InfoMessageViewModel message = InfoMessageViewModel.Error(ex.Message);
            message.ActionCommand = new RelayCommand(() =>
            {
                GameStatus = GameMode.NotRunning;
                message.Close();
            });
            InfoBarMessageService.ShowMessage(message);

            if (_enableDialogs)
                await _dialogService.ShowMessageAsync(ex.Message);
        }
        finally
        {
            InProgress = false;
        }
    }

    [RelayCommand(CanExecute = nameof(CanSetMove), AllowConcurrentExecutions = false, FlowExceptionsToTaskScheduler = true)]
    private async Task SetMoveAsync()
    {
        try
        {
            InProgress = true;
            WeakReferenceMessenger.Default.Send(new GameMoveMessage(GameMoveValue.Started));

            if (Game is null)
                throw new InvalidOperationException("No game running");

            if (Fields.Count != Game.Holes || Fields.Any(x => !x.IsSet))
                throw new InvalidOperationException("All colors need to be selected before invoking this method");

            string[] selection = Fields.Select(x => x.Value!).ToArray();

            CreateMoveResponse response = await _client.SetMoveAsync(Game.GameId, selection);

            SelectionAndKeyPegs selectionAndKeyPegs = new(selection, response.KeyPegs, _moveNumber++);
            GameMoves.Add(selectionAndKeyPegs);
            GameStatus = GameMode.MoveSet;

            WeakReferenceMessenger.Default.Send(new GameMoveMessage(GameMoveValue.Completed, selectionAndKeyPegs));

            if (response.Won)
            {
                GameStatus = GameMode.Won;
                InfoBarMessageService.ShowInformation("Congratulations - you won!");

                if (_enableDialogs)
                    await _dialogService.ShowMessageAsync("Congratulations - you won!");
            }
            else if (response.Ended)
            {
                GameStatus = GameMode.Lost;
                InfoBarMessageService.ShowInformation("Sorry, you didn't find the matching colors!");

                if (_enableDialogs)
                    await _dialogService.ShowMessageAsync("Sorry, you didn't find the matching colors!");
            }
        }
        catch (Exception ex)
        {
            InfoBarMessageService.ShowError(ex.Message);

            if (_enableDialogs)
                await _dialogService.ShowMessageAsync(ex.Message);
        }
        finally
        {
            ClearSelectedColor();
            InProgress = false;
        }
    }

    private bool CanSetMove =>
        Fields.All(s => s is not null && s.IsSet);

    private void ClearSelectedColor()
    {
        for (int i = 0; i < Fields.Count; i++)
            Fields[i].Reset();

        SetMoveCommand.NotifyCanExecuteChanged();
    }

    private void InitializeValues()
    {
        ClearSelectedColor();
        GameMoves.Clear();
        GameStatus = GameMode.NotRunning;
        InfoBarMessageService.Clear();
        _moveNumber = 0;
    }
}

public record SelectionAndKeyPegs(string[] GuessPegs, KeyPegsDto KeyPegs, int MoveNumber);

public record class GameStateChangedMessage(GameMode GameMode);

public record class GameMoveMessage(GameMoveValue GameMoveValue, SelectionAndKeyPegs? SelectionAndKeyPegs = null);

GameViewModel

public class GameViewModel
{
    private readonly GameDto _game;

    public GameViewModel(GameDto game)
    {
        _game = game;
    }

    public Guid GameId => _game.GameId;

    public string Name => _game.Username;

    public IReadOnlyList<string> Code => _game.Code;

    public IReadOnlyList<string> ColorList => _game.Colors;

    public int Holes => _game.Holes;

    public int MaxMoves => _game.MaxMoves;

    public DateTime StartTime => _game.Start;

    public ObservableCollection<MoveViewModel> Moves { get; init; } = new();
}

MoveViewModel

public class MoveViewModel
{
    private readonly MoveDto _move;

    public MoveViewModel(MoveDto move) =>
        _move = move;

    public int MoveNumber => _move.MoveNumber;

    public IReadOnlyList<string> GuessPegs => _move.GuessPegs;

    public KeyPegsDto? KeyPegs => _move.KeyPegs;
}

MAUI Application Code

MAUIDialogService

public class MauiDialogService : IDialogService
{
    public Task ShowMessageAsync(string message)
    {
        WeakReferenceMessenger.Default.Send(new InfoMessage(message));
        return Task.CompletedTask;
    }
}

public record InfoMessage(string Text);

Setup view-models

Configure the application builder to setup the view-models

public static class MauiProgram
{
	public static MauiApp CreateMauiApp()
	{
#if DEBUG
        Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
#else
        Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Production");
#endif

        var builder = MauiApp.CreateBuilder();
		builder
			.UseMauiApp<App>()
			.UseMauiCommunityToolkit()
			.ConfigureFonts(fonts =>
			{
				fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
				fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
			});

        builder.Configuration.AddJsonStream(FileSystem.OpenAppPackageFileAsync("appsettings.json").Result);
        builder.Configuration.AddJsonStream(FileSystem.OpenAppPackageFileAsync($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")}.json").Result);
        builder.Services.Configure<GamePageViewModelOptions>(options => options.EnableDialogs = true);
        builder.Services.AddScoped<IDialogService, MauiDialogService>();
        builder.Services.AddScoped<GamePageViewModel>();
		builder.Services.AddHttpClient<IGameClient, GameClient>(client =>
        {
			client.BaseAddress = new(builder.Configuration.GetRequired("ApiBase"));
		});
		builder.Services.AddTransient<GamePage>();
		return builder.Build();
	}
}