Let's create a stock tracking app in .NET MAUI - part 2

Categories: .NET C#, .NET .NET MAUI
We continue the journey of developing a stock tracking app. In the last part we created the layout, integrated with a third-party API to load stock data, and implemented some caching mechanisms to reduce the request rate. In this part we implement the value change for the different periodicities and a sorting functionality for all columns. We will also implement an add button, so that we no longer have to fiddle around in json.

Implementing % change

The logic to implement % change for the different periodicities can be made generic so that it can be reused for all periodicities. Let's create a new method GetStockCloseDifferenceByPeriod that takes a timespan (the periodicity) as parameter. The desired value is the difference between the last value of the stock at close (Close) and the value of the stock at close, with its date least as old as the periodicity. This difference relative to the latest close is the % change.

The data binding for these properties was already done in part 1.

C#
using CommunityToolkit.Mvvm.ComponentModel;
using IFiDemo.Domain.ApiResponse;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

namespace IFiDemo.Presentation.VM.Maui.ViewModels
{
    public partial class StockPosition : ObservableObject
    {
        //other code omitted
        public float Change1Day => GetStockCloseDifferenceByPeriod(TimeSpan.FromDays(1));
        public float Change7Days => GetStockCloseDifferenceByPeriod(TimeSpan.FromDays(7));
        public float Change1Month => GetStockCloseDifferenceByPeriod(TimeSpan.FromDays(30));
        public float Change3Months => GetStockCloseDifferenceByPeriod(TimeSpan.FromDays(90));
        public float Change1Year => GetStockCloseDifferenceByPeriod(TimeSpan.FromDays(365));
        //other code omitted
        private float GetStockCloseDifferenceByPeriod(TimeSpan period)
        {
            if (HistoricalData.Length <= 1)
                return 0;
            Stock newest = HistoricalData.Last();
            DateTimeOffset targetDateFrom = newest.Date.Add(period.Negate());
            Stock oldest = null;
            for (int i = HistoricalData.Length - 2; i >= 0; --i)
            {
                if (i == 0 || HistoricalData[i].Date <= targetDateFrom)
                {
                    oldest = HistoricalData[i];
                    break;
                }
            }
            return (float)((newest.Close - oldest.Close) / oldest.Close);
        }
    }
}

When we run the app we will see that all columns are now implemented and it looks much better.

Sorting stocks by column

When you have a large list of stocks to track, being able to sort them becomes pretty important.

The sorting itself can be done with one command (SortCommand) and one command parameter.

We should also indicate the sort order for the different columns in the UI, which can also be expressed by the same boolean property (SortOrder). Technically, we could sort by multiple columns in case of equal values (ThenBy...), but I believe that would make things unnecessarily complex.

I decided to keep track of the currently sorted column by using a boolean for each columns. I am sure that this could be done in a better way, however keep in mind that we will be using triggers, and trigger conditions are much more limited in MAUI than in WPF and UWP (no Binding.ConverterParameter etc.).

We can then use these two properties in the BindingCondition of a MultiTrigger to determine whether the button should show ↓ or ↑.

XAML
<?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"
             x:Class="IFiDemo.Presentation.Maui.MainPage"
             xmlns:views="clr-namespace:IFiDemo.Presentation.Maui">
    <Grid RowDefinitions="auto, auto, *">
        <Grid Grid.Row="1" RowDefinitions="auto" ColumnDefinitions="2*,*,*,*,*,*,*,*,*,*">
            <Grid.Resources>
                <Style TargetType="Button">
                    <Setter Property="Padding" Value="5"></Setter>
                    <Setter Property="CornerRadius" Value="0"></Setter>
                </Style>
            </Grid.Resources>
            <Button Grid.Column="0" Text="Symbol" Command="{Binding SortCommand}" CommandParameter="Stock.Symbol">
                <Button.Triggers>
                    <MultiTrigger TargetType="Button">
                        <MultiTrigger.Conditions>
                            <BindingCondition Binding="{Binding IsSorted_StockSymbol}" Value="True" />
                            <BindingCondition Binding="{Binding SortOrder}" Value="Desc" />
                        </MultiTrigger.Conditions>
                        <Setter Property="Text" Value="Symbol ↓" />
                    </MultiTrigger>
                    <MultiTrigger TargetType="Button">
                        <MultiTrigger.Conditions>
                            <BindingCondition Binding="{Binding IsSorted_StockSymbol}" Value="True" />
                            <BindingCondition Binding="{Binding SortOrder}" Value="Asc" />
                        </MultiTrigger.Conditions>
                        <Setter Property="Text" Value="Symbol ↑" />
                    </MultiTrigger>
                    <DataTrigger TargetType="Button" Binding="{Binding IsSorted_StockSymbol}" Value="False">
                        <Setter Property="Text" Value="Symbol" />
                    </DataTrigger>
                </Button.Triggers>
            </Button>
            <Button Grid.Column="1" Text="Position" Command="{Binding SortCommand}" CommandParameter="Position">
                <Button.Triggers>
                    <MultiTrigger TargetType="Button">
                        <MultiTrigger.Conditions>
                            <BindingCondition Binding="{Binding IsSorted_Position}" Value="True" />
                            <BindingCondition Binding="{Binding SortOrder}" Value="Desc" />
                        </MultiTrigger.Conditions>
                        <Setter Property="Text" Value="Position ↓" />
                    </MultiTrigger>
                    <MultiTrigger TargetType="Button">
                        <MultiTrigger.Conditions>
                            <BindingCondition Binding="{Binding IsSorted_Position}" Value="True" />
                            <BindingCondition Binding="{Binding SortOrder}" Value="Asc" />
                        </MultiTrigger.Conditions>
                        <Setter Property="Text" Value="Position ↑" />
                    </MultiTrigger>
                    <DataTrigger TargetType="Button" Binding="{Binding IsSorted_Position}" Value="False">
                        <Setter Property="Text" Value="Position" />
                    </DataTrigger>
                </Button.Triggers>
            </Button>
            <!--other buttons omitted for brevity-->
        </Grid>
        <CollectionView Grid.Row="2" ItemsSource="{Binding StockPositions}">
            <!--omitted for brevity-->
        </CollectionView>
    </Grid>
</ContentPage>

From the view model side, we have to implement the SortCommand, SortOrder and IsSorted_ properties.
To sort we look at the command parameter (field, a hardcoded string that doesn't necessarily have to match any property) and have a switch over the different cases to do the sorting. To have this sorting reflected in the UI the StockPositions have to be updated. We clear the collection and copy over the local stockPositions one by one.

Note: This is a pretty inefficient way to replace an ObservableCollection. We will replace this ObservableCollection by a more efficient container in another article.

C#
//using namespaces...
namespace IFiDemo.Presentation.VM.Maui.ViewModels
{
    public partial class MainVM : ObservableObject
    {
        //other properties omitted
        public ICommand SortCommand { get; }
        [ObservableProperty]
        private string _sortOrder;
        #region _isSorted_
        [ObservableProperty]
        private bool _isSorted_StockSymbol;
        [ObservableProperty]
        private bool _isSorted_Position;
        //other properties omitted for brevity
        #endregion #region _isSorted_
        public MainVM()
        {
            SortCommand = new Command<string>(Sort);
            _fileService = new(StockPositions);
        }

        private void Sort(string field)
        {
            SortOrder = SortOrder == "Desc" ? "Asc" : "Desc";
            bool desc = SortOrder == "Desc";
            IsSorted_StockSymbol = IsSorted_Position = IsSorted_StockClose = IsSorted_Value = IsSorted_TargetValue =
                IsSorted_Change1Day = IsSorted_Change7Days = IsSorted_Change1Month = IsSorted_Change3Months = IsSorted_Change1Year = false;
            IOrderedEnumerable<StockPosition> stockPositions = null;
            switch (field)
            {
                case "Stock.Symbol":
                    {
                        if (desc)
                            stockPositions = StockPositions.OrderByDescending(x => x.Stock.Symbol);
                        else
                            stockPositions = StockPositions.OrderBy(x => x.Stock.Symbol);
                        IsSorted_StockSymbol = true;
                        break;
                    }
                case "Position":
                    {
                        if (desc)
                            stockPositions = StockPositions.OrderByDescending(x => x.Position);
                        else
                            stockPositions = StockPositions.OrderBy(x => x.Position);
                        IsSorted_Position = true;
                        break;
                    }
                //other cases omitted for brevity
            }
            if (stockPositions != null)
            {
                //local copy because it references the StockPositions instance and Clear() would yield 0 results
                var sp = stockPositions.ToList();
                StockPositions.Clear();
                foreach (var stockPosition in sp)
                {
                    StockPositions.Add(stockPosition);
                }
            }
        }
        //other code omitted
    }
}

We can now sort stocks by columns ☺

Adding new stocks from the home page

One of the essential features of a stock tracking app is an add and remove button. I suggest to add the add button on top of the main page ("+"). If we make it obvious, but keep the text limited then this will better translate to mobile devices if we ever decide to do that in the future.
Adding a stock will happen on a different page. This is not the only time that the user will navigate to another page, so it might make sense to make navigation generic and pass the type of the page as a parameter.

XAML
<?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"
             x:Class="IFiDemo.Presentation.Maui.MainPage"
             xmlns:views="clr-namespace:IFiDemo.Presentation.Maui">
    <Grid RowDefinitions="auto, auto, *">
        <Button Grid.Row="0" Text="+" Command="{Binding NavigateCommand}" CommandParameter="{x:Type views:AddStock}" Margin="5" />
        <!--other code omitted-->
    </Grid>
</ContentPage>

Navigation

.NET MAUI has built-in navigation support in many controls. Every NavigableElement (which every page is) has an INavigation property that gives full control over page navigation (go back, go forward, navigate to a specific page by url or type etc.). Every specific navigation adds a page to the navigation stack which allows the user to go back and forward. You can associate pages with their url. For more info see .NET MAUI Shell navigation.
Because the INavigation property is strictly tied to the page, we need to pass it to the view model to control navigation in the view model.

C#
using IFiDemo.Presentation.VM.Maui.ViewModels;

namespace IFiDemo.Presentation.Maui
{
    public partial class MainPage : ContentPage
    {
        private readonly MainVM _vm;
        public MainPage()
        {
            _vm = new MainVM(Navigation.PushAsync);
            BindingContext = _vm;
            InitializeComponent();
            Task.Run(_vm.InitializeAsync);
        }
    }
}
C#
namespace IFiDemo.Presentation.VM.Maui.ViewModels
{
    public partial class MainVM : ObservableObject
    {
        private readonly StockMarketDataFileService _fileService;
        public ObservableCollection<StockPosition> StockPositions { get; } = new ObservableCollection<StockPosition>();
        private readonly Func<Page, Task> _navigate;
        public ICommand NavigateCommand { get; }
        public ICommand SortCommand { get; }
        [ObservableProperty]
        private string _sortOrder;
        #region _isSorted_
        //omitted
        #endregion #region _isSorted_
        public MainVM(Func<Page, Task> navigate)
        {
            _navigate = navigate;
            NavigateCommand = new Command<Type>(
                async (Type pageType) =>
                {
                    Page page = (Page)Activator.CreateInstance(pageType, this);
                    await _navigate(page);
                });
            SortCommand = new Command<string>(Sort);
            _fileService = new(StockPositions/*, StockPosition_PropertyChanged*/);
        }
        //other code omitted
    }
}

Creating the "Add stock" page

Not every user will know the exact ticker symbol of his or her stock. So we have to support searching for stocks by symbol or name.
To keep it simple we will restrict this page to three elements:

XAML
<?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"
             x:Class="IFiDemo.Presentation.Maui.AddStock"
             Title="Add stock">
    <Grid RowDefinitions="auto, *, auto">
        <SearchBar Grid.Row="0" Placeholder="Search stock..." SearchCommand="{Binding SearchCommand}" SearchCommandParameter="{Binding Source={RelativeSource Self}, Path=Text}" SearchButtonPressed="SearchBar_SearchButtonPressed" />
        <ListView Grid.Row="1" ItemsSource="{Binding TickersResult}" SelectedItem="{Binding Ticker, Mode=TwoWay}" x:Name="lstTickersResult" />
        <Button Grid.Row="2" Text="add" Command="{Binding AddStockCommand}">
            <Button.Triggers>
                <DataTrigger TargetType="Button" Binding="{Binding Ticker, Converter={StaticResource isNullConverter}}" Value="True">
                    <Setter Property="IsEnabled" Value="False"/>
                </DataTrigger>
            </Button.Triggers>
        </Button>
    </Grid>
</ContentPage>
C#
using IFiDemo.Presentation.VM.Maui.ViewModels;
using Microsoft.Maui.Platform;

namespace IFi.Presentation.Maui;

public partial class AddStock : ContentPage
{
	public AddStock(MainVM mainVm)
	{
		BindingContext = new AddStockVM(mainVm, Navigation.PopAsync);
		InitializeComponent();
	}

    private void SearchBar_SearchButtonPressed(object sender, EventArgs e)
    {
        ((SearchBar)sender).Unfocus();
    }
}

As you may have seen in the code, the add button should only be enabled once a ticker is selected. There's no built-in converter to convert null/non-null to a boolean in .NET MAUI, but luckily the .NET MAUI Community Toolkit has it. To use this toolkit we have to call .UseMauiCommunityToolkit() in MauiProgram.cs.

C#
using CommunityToolkit.Maui;

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

            return builder.Build();
        }
    }
}

I suggest to add all converters once in a resource dictionary and register them in App.xaml. 

XAML
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
             x:Class="IFiDemo.Presentation.Maui.Resources.Converters.Converters">
    <toolkit:IsNullConverter x:Key="isNullConverter" />
</ResourceDictionary>
XAML
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:IFiDemo.Presentation.Maui"
             x:Class="IFiDemo.Presentation.Maui.App">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
                <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
                <ResourceDictionary Source="Resources/Converters/Converters.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

When implementing the view model we will notice that there are dependencies to two new methods: adding the stock (from the main view model, which calls StockMarketDataFileService) and searching for the ticker (from the repository).
Note: currencies are treated a a special, hardcoded kind of stock. Hence they have to be concatenated to the search result.

C#
using CommunityToolkit.Mvvm.ComponentModel;
using IFiDemo.Domain.Models;
using IFiDemo.Domain;
using System.Windows.Input;
using IFiDemo.Domain.ApiResponse;

namespace IFiDemo.Presentation.VM.Maui.ViewModels
{
    public partial class AddStockVM : ObservableObject
    {
        private readonly MainVM _mainVm;
        public ICommand AddStockCommand { get; }
        public ICommand SearchCommand { get; }
        [ObservableProperty]
        private IEnumerable<Ticker> _tickersResult;
        [ObservableProperty]
        private Ticker _ticker;
        private readonly StockMarketDataRepository _repo = new();
        public AddStockVM(MainVM mainVm, Func<Task<Page>> navigateBack)
        {
            _mainVm = mainVm;
            AddStockCommand = new Command(async () =>
            {
                await _mainVm.AddStockPositionAsync(Ticker);
                await navigateBack();
            });
            SearchCommand = new Command<string>(async s => await Search(s));
        }
        private async Task Search(string search)
        {
            Ticker = null;
            var tickers = await _repo.GetTickersAsync(search);
            TickersResult = tickers
                .Concat(Currency.AllTickers.Where(x =>
                    x.Symbol.Contains(search, StringComparison.OrdinalIgnoreCase)
                    || x.Name.Contains(search, StringComparison.OrdinalIgnoreCase)))
                .OrderBy(x => x.Name).ToList();
        }
    }
}

Adding a stock to the portfolio is really just a subset of the initialization logic:

C#
namespace IFiDemo.Presentation.VM.Maui.ViewModels
{
    public partial class MainVM : ObservableObject
    {
        //other code omitted
        internal Task AddStockPositionAsync(Ticker ticker) => _fileService.AddStockPositionAsync(ticker);
    }
}
C#
internal async Task AddStockPositionAsync(Ticker ticker)
{
    Stock[] historicalData = null;
    Stock stock = null;
    if (Currency.IsCurrency(ticker))
    {
        historicalData = new Stock[0];
        stock = Currency.AsStock(ticker);
    }
    else
    {
        historicalData = (await GetHistoricalDataAsync(HistoricalDataFrom, HistoricalDataTo, ticker.Symbol)).Values.First();
        stock = historicalData.OrderByDescending(x => x.Date).FirstOrDefault();
    }
    stock = stock ?? (await _repo.GetStocksAsync(new[] { ticker.Symbol })).Single();
    var stockPosition = new StockPosition(stock, ticker);
    stockPosition.HistoricalData = historicalData;
    _stockPositions.Add(stockPosition);
    foreach (var _stockPosition in _stockPositions)
        _stockPosition.Refresh(_stockPositions);
    await SaveStockPositionsAsync(_stockPositions);
}

Marketstack has a "tickers" endpoint that returns information about one or more tickers (depending on the parameters). It also has a "search" parameter so that we can search for tickers by symbol or name. This is exactly what we need.

C#
public async Task<Ticker[]> GetTickersAsync(string search)
{
    try
    {
        string requestUri = $"tickers?search={search}";
        var response = await _httpClient.GetAsync(requestUri);
        if (response.IsSuccessStatusCode)
        {
            string json = await response.Content.ReadAsStringAsync();

            var tickers = JsonSerializer.Deserialize<Tickers>(json, DateTimeOffsetConverter_ISO8601.DefaultJsonSerializerOptions);
            return tickers.Data;
        }
        return new Ticker[0];
    }
    catch
    {
        return new Ticker[0];
    }
}

I will repeat what I mentioned earlier: You have to provide an access token if you are making a call to Marketstack.

You should now have a fully functional add functionality. Pressing enter or clicking the search icon searches for the tickers by the entered search term.