.NET MAUI Services

Friday, June 17, 2022

Okay so we have our separation of concerns, but this app isn’t really an app yet. It doesn’t really do anything. We want to be able to write our own action items and save the state for the next time we launch the app. Well how we do that is with Services. Services allow us to add storage and external APIs to our app. In this blog post we will wire up a database to store the action items and their state.

Database Choice

This was a tough one for me. I have experience with many database systems, but to pick one for a learning application is what’s important here. We could spin up Microsoft SQL Server, or PostgreSQL and use those behind an API. Maybe that’d be an interesting story in the future, but for this article we are going to stick with reliable old SQLite.

Now that we have a database let’s add the NuGet packages that we need to accomplish this. Run the following commands in your project folder:

dotnet add package sqlite-net-pcl
dotnet add package SQLitePCLRaw.provider.dynamic_cdecl

Or you could use the NuGet Package Manager and search for these packages and add them that way. The choice is yours.

Next go to our model ActionItem.cs. Add a using statement for SQLite and then decorate the class with [Table("actionItems")], the ID with [PrimaryKey, AutoIncrement], and the Title with [MaxLength(250)]. Your model should look like this when this is complete:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
using SQLite;

namespace maui_to_do.Models;

[Table("actionItems")]
public class ActionItem
{
    [PrimaryKey, AutoIncrement]
    public int ID { get; set; }

    [MaxLength(250)]
    public string Title { get; set; }

    public bool IsCompleted { get; set; }
}

This will create a table in our SQLite database called actionItems which has three columns one for ID which is the primary key and will auto increment, one for Title which has a max length of 250 characters, and one for IsCompleted.

What about a service?

Create a folder called Services and add a class called ActionItemRepository.cs. That’s right, I’m going to tell you all about the Repository pattern. Basically it just hides all of the database specific “stuff” away from the main code. You can call things like AddActionItem and MarkComplete without your ViewModel to know how to deal with the database. It makes it so that in the future we could replace SQLite with an API and we would only have to rewrite the repository. Simple right?

Now for the repository add this code and I’ll try to explain it afterwards:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using SQLite;
using maui_to_do.Models;

namespace maui_to_do.Services;

public class ActionItemRepository
{
    string _dbPath;

    public string StatusMessage { get; set; }

    SQLiteAsyncConnection conn;

    private void Init()
    {
        if (conn != null) return;

        conn = new SQLiteAsyncConnection(_dbPath);
        conn.CreateTableAsync<ActionItem>();
    }

    public ActionItemRepository(string dbPath)
    {
        _dbPath = dbPath;
    }

    public async Task AddNewActionItemAsync(string title, bool isComplete)
    {
        int result = 0;
        try
        {
            Init();

            if (string.IsNullOrEmpty(title))
                throw new Exception("Valid title required");

            result = await conn.InsertAsync(new ActionItem
            {
                Title = title,
                IsCompleted = isComplete,
            });

            StatusMessage = string.Format("{0} record(s) added", result);
        }
        catch (Exception ex)
        {
            StatusMessage = string.Format("Failed to add. Error: {0}", ex.Message);
        }
    }

    public async Task<List<ActionItem>> GetAllActionItemsAsync()
    {
        try
        {
            Init();
            return await conn.Table<ActionItem>().ToListAsync();
        }
        catch (Exception ex)
        {
            StatusMessage = string.Format("Failed to retrieve data. {0}", ex.Message);
        }
        return new List<ActionItem>();
    }
}

Let’s start by looking at the constructor. It expects a string for the database path to be passed in. This is done using dependency injection. I’ll show you that later. For now we pass it in and save it to a private field.

Next up is the Init function. This gets called before every database action. It first checks to see if there is already a database connection and if so returns early. If there isn’t a database connection it creates one and then tries to create the ActionItem table. This will first check if the table exists and create the table if it does not exist.

Next we create two async functions. One for AddNewActionItemAsync and one for GetAllActionItemsAsync. There’s no point right now for these to be async and you could make the synchronous. I’m used to writing async code so that’s why I chose it. The first function takes a string for the title and a Boolean for isComplete. The ID will be generated by the database when we insert this new action item. The interesting part of this function is on line 37 where we do the actual insert. It reminds me of dealing with List. Only instead of calling Add we are calling InsertAsync with an ActionItem object. The result is the number of records changed. Hopefully this will always be 1.

There is a StatusMessage that we use to store this information. In the ViewModel we can interrogate this parameter for a status bar message in the future.

GetAllActionItemsAsync on line 56 gets a list of all Action Items from the table and returns a List of them to the calling function.

Great, but how do we use it?

Well before we can do that let’s add it to the Dependency Injection services list. This gets a little complicated because we are passing a variable in to the constructor. First, let’s create that variable in the MauiProgram.cs file. It’s the database path, so do something like this:

		var dbPath = Path.Combine(FileSystem.AppDataDirectory, "ActionItems.db3");

This uses a device agnostic path called FileSystem.AppDataDirectory and combines that with the database name. Which I called ActionItems.db3. What do I mean by device agnostic? Well the path to the app data directory is different on Windows, iOS, Android, and MacOS. This is one of the really helpful functions provided by .NET MAUI to make writing cross platform applications easy. You don’t have to think about it and it reduces the number of ugly and confusing #ifdef’s in the code.

Now that we have the path we want to include the ActionItemRepository. Use this line to do that:

		builder.Services.AddSingleton(s => ActivatorUtilities.CreateInstance<ActionItemRepository>(s, dbPath));

This is obviously a bit more complicated than we did for MainPage and MainViewModel. Basically what we are doing is creating an ActionItemRepository factory and pass in an IServiceProvider and that parameter that we need to have passed into the constructor.

Once finished your MauiProgram.cs should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using maui_to_do.Services;
using maui_to_do.ViewModels;

namespace maui_to_do;

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

		var dbPath = Path.Combine(FileSystem.AppDataDirectory, "ActionItems.db3");
		builder.Services.AddSingleton(s => ActivatorUtilities.CreateInstance<ActionItemRepository>(s, dbPath));
        builder.Services.AddSingleton<MainPage>();
		builder.Services.AddSingleton<MainViewModel>();

		return builder.Build();
	}
}

Now what?

Feeling the complexity growing now? There are changes to two more files to go. First we want to update our view model. Then we’ll update the view. First the ViewModel. Open MainViewModel.cs and first thing we need to do is get our repository into the view model. Create a private variable to hold the repository:

    private ActionItemRepository Repo;

Then create a constructor for the view model. It will take in the repository. Save the repository that’s passed in to the private variable created previously.

    public MainViewModel(ActionItemRepository air)
    {
        Repo = air;
    }

Next, delete the GetActionItems() function and replace it with this:

    [RelayCommand]
    async void GetActionItems()
    {
        Items.Clear();
        var ai = await Repo.GetAllActionItemsAsync();

        foreach (var item in ai)
        {
            Items.Add(item);
        }
    }

Again if you prefer synchronous calls you don’t need to make this async. The first thing we clear the Items. Next we call GetAllActionItemsAsync on the repository and store the action items in a temporary variable. Unfortunately, ObservableObject doesn’t include an AddRange method, only an Add method. So we have to loop over all of the items in the ai variable and add them to the Items variable. Not terrible, but it adds a little code smell.

The other thing we want to do in this blog post is add an item. An item consists of a title and an isComplete bool. Add a couple of fields at the top to hold these values and then we’ll create the AddActionItem method.

    [ObservableProperty]
    private string title;

    [ObservableProperty]
    private bool isComplete;

By marking these private fields as ObservableProperty it allows us to data-bind to them. This is more magic that the MVVM Community Toolkit gives us. It saves us from writing a bunch of lines of boilerplate code for INotifyPropertyChanged and related events. Next, add the AddActionItem method:

    [RelayCommand]
    async void AddActionItem()
    {
        await Repo.AddNewActionItemAsync(Title, IsComplete);
        Title = string.Empty;
        IsComplete = false;
    }

This method is fairly straight forward. We create the new database entry by calling AddNewActionItemAsync with the Title and IsComplete properties. Then we reset those properties back to their defaults.

After all of this your ViewModel should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using maui_to_do.Models;
using maui_to_do.Services;
using System.Collections.ObjectModel;

namespace maui_to_do.ViewModels;

public partial class MainViewModel : ObservableObject
{
    public ObservableCollection<ActionItem> Items { get; } = new();

    [ObservableProperty]
    private string title;

    [ObservableProperty]
    private bool isComplete;

    private ActionItemRepository Repo;

    public MainViewModel(ActionItemRepository air)
    {
        Repo = air;
    }

    [RelayCommand]
    async void GetActionItems()
    {
        Items.Clear();
        var ai = await Repo.GetAllActionItemsAsync();

        foreach (var item in ai)
        {
            Items.Add(item);
        }
    }

    [RelayCommand]
    async void AddActionItem()
    {
        await Repo.AddNewActionItemAsync(Title, IsComplete);
        Title = string.Empty;
        IsComplete = false;
    }
}

Are we done yet?

Almost there. We just have to update our View with the new fields for adding an action item to our to-do list. We already have a grid with two rows. Let’s add another row at the top and make it a grid with three columns like the highlighted code below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:models="clr-namespace:maui_to_do.Models"
             xmlns:viewmodels="clr-namespace:maui_to_do.ViewModels"
             x:Class="maui_to_do.MainPage"
             x:DataType="viewmodels:MainViewModel">
    <Grid RowDefinitions="100,auto,*">
        <Grid ColumnDefinitions="100,*,100">
            <CheckBox IsChecked="{Binding IsComplete}" />
            <Entry Text="{Binding Title}" Grid.Column="1" />
            <Button Text="Add Item" Command="{Binding AddActionItemCommand}" Grid.Column="2" />
        </Grid>
        <Button Text="Load Data" Command="{Binding GetActionItemsCommand}" Grid.Row="1" />
        <CollectionView ItemsSource="{Binding Items}" Grid.Row="2">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:ActionItem">
                    <HorizontalStackLayout Padding="10">
                        <CheckBox IsChecked="{Binding IsCompleted}" />
                        <Label VerticalOptions="Center" Text="{Binding Title}" />
                    </HorizontalStackLayout>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </Grid>

</ContentPage>

Don’t forget to update the Grid.Row definitions as well as adding the row to the main grid’s RowDefinitions. Now, with that all in place we can launch the app. Add an item to the ActionItems (remember your DB is empty after first launch). Finally click the Load Data button to load your new rows. It should look something like this:

Running app with a few action items listedRunning app with a few action items listed

Wow! We’ve come a long way to making a functional application. In the next post I’ll tackle launching a new page where we can update the text and completion check box. If you are enjoying this series let me know . Also, if you’d like a companion video of me going through this let me know that as well. I could put one together on my YouTube channel.

.NET MAUIServicesRepository

This work is licensed under CC BY-NC-SA 4.0

.NET MAUI Dependency Injection