In this chapter, we will write our first app for the fictional company UnoBookRail, which will be targeting desktops and the web. We will write a typical line of business (LOB) app that allows us to view, enter, and edit data. In addition to that, we will also cover how to export data in PDF format since this is a common requirement for LOB apps.
In this chapter, we'll cover the following topics:
By the end of this chapter, you'll have created a desktop-focused app that can also run on the web that displays data, allows you to edit the data, and also export the data in PDF format.
This chapter assumes that you already have your development environment set up, as well as the project templates installed, as we covered in Chapter 1, Introducing Uno Platform. The source code for this chapter can be found at https://github.com/PacktPublishing/Creating-Cross-Platform-C-Sharp-Applications-with-Uno-Platform/tree/main/Chapter03.
The code in this chapter makes use of the following library: https://github.com/PacktPublishing/Creating-Cross-Platform-C-Sharp-Applications-with-Uno-Platform/tree/main/SharedLibrary.
Check out the following video to see the code in action: https://bit.ly/3fWYRai
In this chapter, we will build the UnoBookRail ResourcePlanner app, which will be used internally, inside UnoBookRail. UnoBookRail employees will be able to use this app to manage any resources within UnoBookRail, such as trains and stations. In this chapter, we will develop the issue-managing part of the app. While a real version of this app would have a lot more features, in this chapter, we will only develop the following features:
Since this application is a typical line of business app, the app will be targeting UWP, macOS, and WASM. Let's continue by creating the app.
Let's start by creating the solution for the app:
<assembly fullname="UnoBookRail.Common" />
This code is needed to bind objects from the UnoBookRail.Common library so that they work properly on WASM. The LinkerConfig.xml file tells the WebAssembly Linker to include the types in the compiled source code, even though the classes are not currently being used. If we don't specify these entries, the types that are defined in the assembly will not be included as the linker removes the code. This is because it doesn't find a direct reference to it. When using other packages or libraries, you may also need to specify entries for those libraries. For this chapter, though, the preceding entry is enough.
For our app, we will use the Model-View-ViewModel (MVVM) pattern. This means that our app will mostly be split into three areas:
To make development easier, we will use the Microsoft.Toolkit.MVVM package, which we will add now. This package helps us write our ViewModels and takes care of the boilerplate code that is needed to support bindings with XAML:
Now that we've set up the project, let's start by adding the first pieces of code to our app. As is typical with line of business apps, we will be using the MenuBar control as the main way of switching views inside our app:
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Input;
using System.Windows.Input;
using Windows.UI.Xaml;
namespace ResourcePlanner.ViewModels
{
public class NavigationViewModel :
ObservableObject
{
private FrameworkElement content;
public FrameworkElement Content
{
Get
{
return content;
}
Set
{
SetProperty(ref content, value);
}
}
public ICommand Issues_OpenNewIssueViewCommand
{ get; }
public ICommand Issues_ExportIssueViewCommand
{ get; }
public ICommand Issues_OpenAllIssuesCommand {
get; }
public ICommand Issues_OpenTrainIssuesCommand
{ get; }
public ICommand
Issues_OpenStationIssuesCommand { get; }
public ICommand Issues_Open OtherIssuesCommand
{ get; }
public NavigationViewModel()
{
Issues_OpenNewIssueViewCommand =
new RelayCommand(() => { });
Issues_ExportIssueViewCommand =
new RelayCommand(() => { });
Issues_OpenAllIssuesCommand =
new RelayCommand(() => { });
Issues_OpenAllTrainIssuesCommand =
new RelayCommand(() => { });
Issues_OpenAllStationIssuesCommand =
new RelayCommand(() =>{ });
Issues_OpenAllOtherIssuesCommand =
new RelayCommand(() =>{ });
}
}
}
This is the class that will handle navigating to different controls. As we implement more views later in this chapter, we will update the Command objects so that they point to the correct views.
using ResourcePlanner.ViewModels;
...
private NavigationViewModel navigationVM = new NavigationViewModel();
This will add a NavigationViewModel object to the MainPage class that we can bind to in our XAML.
...
xmlns:muxc="using:Microsoft.UI.Xaml.Controls">
<Grid Background="{ThemeResource
ApplicationPageBackgroundThemeBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<muxc:MenuBar>
<muxc:MenuBar.Items>
<muxc:MenuBarItem Title="Issues">
<MenuFlyoutItem Text="New"
Command="{x:Bind
navigationVM.Issues_
OpenNewIssueViewCommand}"/>
<MenuFlyoutItem Text="Export to
PDF" Command="{x:Bind
navigationVM.Issues_
ExportIssueViewCommand}"/>
<MenuFlyoutSeparator/>
<MenuFlyoutItem Text="All"
Command="{x:Bind
navigationVM.Issues_
OpenAllIssuesCommand}"/>
<MenuFlyoutItem Text="Train
issues" Command="{x:Bind
navigationVM.Issues_
OpenTrainIssuesCommand}"/>
<MenuFlyoutItem Text="Station
issues" Command="{x:Bind
navigationVM.Issues_
OpenStationIssuesCommand}"/>
<MenuFlyoutItem Text="Other
issues" Command="{x:Bind
navigationVM.Issues_
OpenOtherIssuesCommand}"/>
</muxc:MenuBarItem>
<muxc:MenuBarItem Title="Trains"
IsEnabled="False"/>
<muxc:MenuBarItem Title="Staff"
IsEnabled="False"/>
<muxc:MenuBarItem Title="Depots"
IsEnabled="False"/>
<muxc:MenuBarItem Title="Stations"
IsEnabled="False"/>
</muxc:MenuBar.Items>
</muxc:MenuBar>
<ContentPresenter Grid.Row="1"
Content="{x:Bind navigationVM.Content,
Mode=OneWay}"/>
</Grid>
This code adds MenuBar, which users can use to navigate to different views. ContentPresenter, at the bottom, is used to display the content that was navigated to.
Now, if you start the app, you will see something similar to the following:
In the next section, we will add our first view to the app, which will allow users to create new issues.
A typical requirement for line of business apps is to enter data and also provide input validation for said data. Uno Platform provides a variety of different controls to allow users to enter data, in addition to dozens of libraries that support Uno Platform.
Note
While at the time of writing, there is no built-in support for input validation, input validation is planned to be supported by Uno Platform. This is because neither UWP nor WinUI 3 fully support input validation right now. To learn more about the upcoming input validation support, take a look at the following issue in the WinUI repository: https://github.com/microsoft/microsoft-ui-xaml/issues/179. The progress that's being made on this as part of Uno Platform is being tracked through this issue: https://github.com/unoplatform/uno/issues/4839.
To make our development process easier, first, let's add a reference to the Windows Community Toolkit controls:
Note
While the Windows Community Toolkit only supports UWP, thanks to the effort of the Uno Platform team, we can also use the Windows Community Toolkit inside our Uno Platform app on all the supported platforms. The Uno Platform team maintains Uno Platform-compatible versions of the Windows Community Toolkit packages based on the original packages and updates them accordingly.
This allows us to use the Windows Community Toolkit controls inside our app. Since we also want to use these controls on macOS and WASM, we also installed the Uno Platform versions of those two packages. Since we added the Windows Community Toolkit control packages, we can start creating the Create Issue view:
using System.Collections.Generic;
using UnoBookRail.Common.Issues;
namespace ResourcePlanner.Models
{
public class IssuesRepository
{
private static List<Issue> issues = new
List<Issue>();
public static List<Issue> GetAllIssues()
{
return issues;
}
public static void AddIssue(Issue issue)
{
issues.Add(issue);
}
}
}
This is the model that will collect issues. In a real-world app, this code would communicate with a database or API to persist issues, but for simplicity, we will only save them in a list.
Now that we've created the necessary Model and ViewModel, we will continue by adding the user interface to create a new issue.
For the user interface, we will implement input validation as this is typical for data entry forms in a line of business app. For this, we will implement the following behavior: if the user clicks on the Create Issue button, we will validate the data using a function in code behind. If we determine that the data is valid, we will create a new issue; otherwise, we will show an error message below every field that failed our custom validation using code behind. In addition to that, we will validate an input field every time the entered input changes.
Let's continue by creating the user interface:
<UserControl
x:Class="ResourcePlanner.Views.CreateIssueView"
xmlns="http://schemas.microsoft.com/winfx/2006
/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/
winfx/2006/xaml"
xmlns:local="using:ResourcePlanner.Views"
xmlns:d="http://schemas.microsoft.com/
expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/
markup-compatibility/2006"
xmlns:wctcontrols="using:Microsoft.Toolkit.
Uwp.UI.Controls"
xmlns:wctui="using:Microsoft.Toolkit.Uwp.UI"
xmlns:ubrcissues="using:UnoBookRail.Common.Issues"
mc:Ignorable="d"
d:DesignHeight="300"
d:DesignWidth="400">
<StackPanel Orientation="Vertical" Padding="20">
<TextBlock Text="Create new issue"
FontSize="24"/>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBox x:Name="TitleTextBox"
Header="Title"
Text="{x:Bind createIssueVM.Title,
Mode=TwoWay}"
HorizontalAlignment="Stretch"
TextChanged="FormInput_TextChanged"/>
<TextBlock x:Name="titleErrorNotification"
Grid.Row="1"Foreground="{ThemeResource
SystemErrorTextColor}"/>
<ComboBox Header="Type" Grid.Column="1"
ItemsSource="{wctui:EnumValues
Type=ubrcissues:IssueType}"
HorizontalAlignment="Stretch"
SelectedItem="{x:Bind
createIssueVM.IssueType,
Mode=TwoWay}"/>
</Grid>
<TextBox Header="Description"
Text="{x:Bind createIssueVM.Description,
Mode=TwoWay}"
MinWidth="410" MaxWidth="800"
HorizontalAlignment="Left"/>
<Button Content="Create new issue"
Margin="0,20,0,0" Width="410"
HorizontalAlignment="Left"
Click="CreateIssueButton_Click"/>
</StackPanel>
</UserControl>
This is a basic UI that allows users to enter a title and description and lets the user choose the issue's type. Note that we have a TextBlock control below the text inputs so that we can show error messages to the user if the provided input is not valid. In addition to that, we have also added a TextChanged listener to Title to be able to update the error message when the text changes.
using ResourcePlanner.ViewModels;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
namespace ResourcePlanner.Views
{
public sealed partial class CreateIssueView :
UserControl
{
private CreateIssueViewModel createIssueVM;
public CreateIssueView(CreateIssueViewModel
viewModel)
{
this.createIssueVM = viewModel;
this.InitializeComponent();
}
private void FormInput_TextChanged(object
sender, TextChangedEventArgs args)
{
EvaluateFieldsValid(sender);
}
private bool EvaluateFieldsValid(object
sender)
{
bool allValid = true;
if(sender == TitleTextBox || sender ==
null)
{
if (TitleTextBox.Text.Length == 0)
{
allValid = false;
titleErrorNotification.Text =
"Title must not be empty.";
}
Else
{
titleErrorNotification.Text = "";
}
}
return allValid;
}
private void CreateIssueButton_Click(object
sender, RoutedEventArgs args)
{
if (EvaluateFieldsValid(null))
{
createIssueVM.CreateIssueCommand.
Execute(null);
}
}
}
}
With this code, we now have input validation that's run when the text of an input field changes or when the user clicks on the Create Issue button. Only if all the input fields (right now, this is only the Title input field) are valid will we create an issue and execute CreateIssueCommand on our ViewModel.
Issues_OpenNewIssueViewCommand = new RelayCommand(() =>
{
Content = new CreateIssueView(new
CreateIssueViewModel(this));
});
Now, if you start the app and click on the New Issue option from the Issue dropdown, you will see something similar to the following Figure 3.2:
If you try to click on the Create new issue button, you will see a short message below the title input field that states "Title must not be empty". Upon entering text into the Title field, the message will disappear. While we have added simple inputs, we will now add more input options using the Windows Community Toolkit.
So far, users can only enter a title and description and choose the issue's type. However, we also want to allow users to input specific data based on the issue's. For this, we will use one of the controls the Windows Community Toolkit provides: SwitchPresenter. The SwitchPresenter control allows us to render a certain part of the UI based on a property that's been set, similar to how a switch case in C# works.
Of course, SwitchPresenter is not the only control that's available from the Windows Community Toolkit; there are many more, such as GridSplitter, MarkdownTextBlock, and DataGrid, which we will use in the Displaying data using DataGrid section. Since we've already installed the necessary packages earlier in this chapter, we will add the controls to our user interface. Let's get started:
<wctcontrols:SwitchPresenter Value="{x:Bind createIssueVM.IssueType, Mode=OneWay}">
<wctcontrols:SwitchPresenter.SwitchCases>
<wctcontrols:Case Value="{x:Bind
ubrcissues:IssueType.Train}">
<StackPanel Orientation="Horizontal"
Spacing="10">
<StackPanel MinWidth="410"
MaxWidth="800">
<TextBox x:Name=
"TrainNumberTextBox"
Header="Train number"
Text="{x:Bind
createIssueVM.TrainNumber,
Mode=TwoWay}"
HorizontalAlignment="Stretch"
TextChanged=
"FormInput_TextChanged"/>
<TextBlock x:Name=
"trainNumberErrorNotification"
Foreground="{ThemeResource
SystemErrorTextColor}"/>
</StackPanel>
</StackPanel>
</wctcontrols:Case>
<wctcontrols:Case Value="{x:Bind
ubrcissues:IssueType.Station}">
<StackPanel MinWidth="410" MaxWidth="800"
HorizontalAlignment="Left">
<TextBox x:Name="StationNameTextBox"
Header="Station name" Text="{x:Bind
createIssueVM.StationName,
Mode=TwoWay}"
HorizontalAlignment="Stretch"
TextChanged=
"FormInput_TextChanged"/>
<TextBlock x:Name=
"stationNameErrorNotification"
Foreground="{ThemeResource
SystemErrorTextColor}"/>
</StackPanel>
</wctcontrols:Case>
<wctcontrols:Case Value="{x:Bind
ubrcissues:IssueType.Other}">
<StackPanel MinWidth="410" MaxWidth="800"
HorizontalAlignment="Left">
<TextBox x:Name="LocationTextBox"
Header="Location" Text="{x:Bind
createIssueVM.Location,
Mode=TwoWay}"
HorizontalAlignment="Stretch"
TextChanged=
"FormInput_TextChanged"/>
<TextBlock x:Name=
"locationErrorNotification"
Foreground="{ThemeResource
SystemErrorTextColor}"/>
</StackPanel>
</wctcontrols:Case>
</wctcontrols:SwitchPresenter.SwitchCases>
</wctcontrols:SwitchPresenter>
This allows us to display specific input fields, depending on the issue type that's selected by the user. This is because SwitchPresenter renders a specific Case based on the Value property that's been set. Since we bind it to the IssueType property of our ViewModel, any time the user changes the issue type, it will update accordingly. Note that this binding only works if we specify the mode to be OneWay since the default binding mode of x:Bind is OneTime and, as such, wouldn't update.
if (sender == TrainNumberTextBox || sender == null)
{
if (TrainNumberTextBox.Text.Length == 0)
{
if (createIssueVM.IssueType ==
UnoBookRail.Common.Issues.IssueType.Train)
{
allValid = false;
}
trainNumberErrorNotification.Text =
"Train number must not be empty.";
}
else
{
trainNumberErrorNotification.Text = "";
}
}
if (sender == StationNameTextBox || sender == null)
{
if (StationNameTextBox.Text.Length == 0)
{
if (createIssueVM.IssueType ==
UnoBookRail.Common.Issues.IssueType.Station)
{
allValid = false;
}
stationNameErrorNotification.Text =
"Station name must not be empty.";
}
else
{
stationNameErrorNotification.Text = "";
}
}
if (sender == LocationTextBox || sender == null)
{
if (LocationTextBox.Text.Length == 0)
{
if (createIssueVM.IssueType ==
UnoBookRail.Common.Issues.IssueType.Other)
{
allValid = false;
}
locationErrorNotification.Text =
"Location must not be empty.";
}
else
{
locationErrorNotification.Text = "";
}
}
Now, our input validation will also take the newly added input fields into account. Note that we will only block the creation of an issue if input that does not meet the validation process is relevant to the issue. For example, if the issue type is Train, we will ignore whether the location text is passing validation or not and users can create a new issue, regardless of whether the location input passes the validation stage.
Now, if you start the app and navigate to the Create new issue view, you will see something similar to the following Figure 3.3:
When you change the issue type, you will notice that the form will change and show the correct input field, depending on the issue type. While we allow users to create a new issue, we currently have no way of displaying them. In the next section, we will change this by adding a new view to show the list of issues.
Since UnoBookRail employees will use this app to manage existing issues, it is important for them to view all the issues to easily get an overview of their current status. While there is no built-in UWP and Uno Platform control that makes this easy to implement, luckily, the Windows Community Toolkit contains the right control for this case: DataGrid.
The DataGrid control allows us to render data as a table, specify which columns to display, and allows users to sort the table based on a column. Before we start using the DataGrid control, though, we need to create the ViewModel and prepare the views:
using System.Collections.Generic;
using UnoBookRail.Common.Issues;
namespace ResourcePlanner.ViewModels
{
public class IssueListViewModel
{
public readonly IList<Issue> Issues;
public IssueListViewModel(IList<Issue> issues)
{
this.Issues = issues;
}
}
}
Since we only want to show a subset of issues, such as when navigating to the train issues list, the list of issues to display will be passed as a constructor parameter.
Issues_OpenAllIssuesCommand = new RelayCommand(() =>
{
Content = new IssueListView(new IssueListViewModel
(IssuesRepository.GetAllIssues()), this);
});
Issues_OpenTrainIssuesCommand = new RelayCommand(() =>
{
Content = new IssueListView(new IssueListViewModel
(IssuesRepository.GetAllIssues().Where(issue
=> issue.IssueType ==
IssueType.Train).ToList()), this);
});
Issues_OpenStationIssuesCommand = new RelayCommand(() =>
{
Content = new IssueListView(new IssueListViewModel
(IssuesRepository.GetAllIssues().Where(issue
=> issue.IssueType ==
IssueType.Station).ToList()), this);
});
Issues_OpenOtherIssuesCommand = new RelayCommand(() =>
{
Content = new IssueListView(new IssueListViewModel
(IssuesRepository.GetAllIssues().Where(issue
=> issue.IssueType ==
IssueType.Other).ToList()), this);
});
This allows the user to navigate to the issue list when the user clicks on the corresponding elements from the navigation, while also ensuring that we only show the issues in the list that are relevant to the navigation option. Note that we have chosen to create the commands using inline lambdas. However, you can also declare functions and use them to create the RelayCommand objects.
Now that we've added the necessary ViewModel and updated NavigationViewModel to allow us to navigate to the issue list view, we can continue writing the UI of our issue list view.
Before we implement the issue list view, let's quickly cover the basic features of DataGrid that we will use. There are two ways to get started with DataGrid:
Specifying the columns of a DataGrid can be done by setting the DataGrid's Columns property and providing a collection of DataGridColumn objects. For certain data types, there are already built-in columns you can use, such as DataGridTextColumn for text-based data. Every column allows you to customize the header being displayed by specifying the Header property and whether users can sort the column through the CanUserSort property. For more complex data where there is no built-in DataGridColumn type, you can also implement your own DataGridColumn object. Alternatively, you can also use DataGridTemplateColumn, which allows you to render cells based on a specified template. For this, you can specify a CellTemplate object, which will be used to render cells, and a CellEditTemplate object, which will be used to let users edit the current cell's value.
In addition to specifying columns, the DataGrid controls also have more features you can customize. For example, the DataGrid allows you to select rows and customize the row and cell backgrounds. Now, let's continue by writing our issue list.
Now that we've covered the basics of DataGrid, let's continue by writing our issue list display interface:
using Microsoft.Toolkit.Uwp.UI.Controls;
using ResourcePlanner.ViewModels;
using UnoBookRail.Common.Issues;
using Windows.UI.Xaml.Controls;
namespace ResourcePlanner.Views
{
public sealed partial class IssueListView :
UserControl
{
private IssueListViewModel issueListVM;
private NavigationViewModel navigationVM;
public IssueListView(IssueListViewModel
viewModel, NavigationViewModel
navigationViewModel)
{
this.issueListVM = viewModel;
this.navigationVM = navigationViewModel;
this.InitializeComponent();
}
private void IssueList_SelectionChanged(object
sender, SelectionChangedEventArgs e)
{
navigationVM.SetSelectedIssue((sender as
DataGrid).SelectedItem as Issue);
}
}
}
This allows us to create a binding from the DataGrid to the list issues. Note that we will also add a SelectionChanged handler function so that we can notify NavigationViewModel whether an issue has been selected. We're doing this since some options only make sense if an issue is selected. One of these options is the Export to PDF option, which we will implement in the Exporting issues in PDF format section.
xmlns:wct="using:Microsoft.Toolkit.Uwp.UI.Controls"
<wct:DataGrid
SelectionChanged="IssueList_SelectionChanged"
SelectionMode="Single"
AutoGenerateColumns="False"
ItemsSource="{x:Bind
issueListVM.Issues,Mode=OneWay}">
<wct:DataGrid.Columns>
<wct:DataGridTextColumn Header="Title"
Binding="{Binding Title}"
IsReadOnly="True" CanUserSort="True"/>
<wct:DataGridTextColumn Header="Type"
Binding="{Binding IssueType}"
IsReadOnly="True" CanUserSort="True"/>
<wct:DataGridTextColumn Header="Creator"
Binding="{Binding OpenedBy.FormattedName}"
IsReadOnly="True" CanUserSort="True"/>
<wct:DataGridTextColumn Header="Created on"
Binding="{Binding OpenDate}"
IsReadOnly="True" CanUserSort="True"/>
<wct:DataGridCheckBoxColumn Header="Open"
Binding="{Binding IsOpen}"
IsReadOnly="True" CanUserSort="True"/>
<wct:DataGridTextColumn Header="Closed by"
Binding="{Binding ClosedBy.FormattedName}"
IsReadOnly="True" CanUserSort="True"/>
<wct:DataGridTextColumn Header="Closed on"
Binding="{Binding CloseDateReadable}"
IsReadOnly="True" CanUserSort="True"/>
</wct:DataGrid.Columns>
</wct:DataGrid>
Here, we added columns for the most important fields of our issue. Note that we only allow the title to be changed since the other fields would require more logic than what can easily be displayed as part of the DataGrid table layout. Since x:Bind is not supported in this case, we are using Binding to bind the properties to the columns.
Now, if you start the app and create an issue, you will see something similar to the following Figure 3.4:
In this section, we only covered the basics of using the Windows Community Toolkit DataGrid control. If you wish to learn more about the DataGrid control, the official documentation contains hands-on examples covering the different APIs that are available for it. You can find out more here: https://docs.microsoft.com/en-us/windows/communitytoolkit/controls/datagrid. Now that we can display the list of existing issues, we will continue by writing a PDF export for issues. As part of this, we will also learn how to write a custom Uno Platform control that we will only use for the web.
In addition to being able to view data inside a line of business app, often, it is desired to be able to be export data, for example, as a PDF, so that you can print it or send it via email. For this, we will write an interface that allows users to export a given issue to PDF. Since there are no built-in APIs for this, we will use the iText library for this. Note that if you want to use the library in your application, you either need to follow the AGPL license or buy a commercial license for the library. However, before we can write the code to generate the PDF, we will need to prepare the project:
using iText.Kernel.Pdf;
using iText.Layout;
using iText.Layout.Element;
using Microsoft.Toolkit.Mvvm.Input;
using System;
using System.IO;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Windows.Input;
using UnoBookRail.Common.Issues;
namespace ResourcePlanner.ViewModels
{
public class ExportIssueViewModel
{
public readonly Issue Issue;
public ICommand SavePDFClickedCommand;
public ExportIssueViewModel(Issue issue)
{
Issue = issue;
SavePDFClickedCommand =
new RelayCommand(async () => { });
}
}
}
Note that we are adding those using statements now as we will need them later in this section.
using ResourcePlanner.ViewModels;
using Windows.UI.Xaml.Controls;
namespace ResourcePlanner.Views
{
public sealed partial class ExportIssueView :
UserControl
{
private ExportIssueViewModel exportIssueVM;
public ExportIssueView(ExportIssueViewModel
viewModel)
{
this.exportIssueVM = viewModel;
this.InitializeComponent();
}
}
}
Issues_ExportIssueViewCommand = new RelayCommand(() =>
{
Content = new ExportIssueView(new
ExportIssueViewModel(this.selectedIssue));
});
Now that we've added the necessary interface, we will continue by writing the code for exporting an issue as a PDF. Since the behavior on the desktop will be different compared to that on the web, we will cover the desktop version first.
Since we've already written the user interface to allow users to export issues, the only thing left is to update ExportIssueViewModel to generate the PDF and provide users with a way to access it. On the desktop, we will write the PDF file to the local filesystem and open it. Since the app is also a UWP app, we will write the file to the app's local folder. Now, let's update ExportIssueViewModel:
public byte[] GeneratePDF()
{
byte[] bytes;
using (var memoryStream = new MemoryStream())
{
bytes = memoryStream.ToArray();
}
return bytes;
}
var pdfWriter = new PdfWriter(memoryStream);
var pdfDocument = new PdfDocument(pdfWriter);
var document = new Document(pdfDocument);
document.Close();
This creates a new iText PdfWriter and iText PdfDocument that will be written to the byte array using the MemoryStream object.
var header = new Paragraph("Issue export: " +
Issue.Title)
.SetTextAlignment(
iText.Layout.Properties.TextAlignment.CENTER)
.SetFontSize(20);
document.Add(header);
This creates a new paragraph with the text "Issue export:" and the issue's title. It also sets the text alignment and font size to make it easier to distinguish as the header of the document.
var issueType = new Paragraph("Type: " + Issue.IssueType);
document.Add(issueType);
switch (Issue.IssueType)
{
case IssueType.Train:
var trainNumber = new Paragraph("Train number: "
+ Issue.TrainNumber);
document.Add(trainNumber);
break;
case IssueType.Station:
var stationName = new Paragraph("Station name: "
+ Issue.StationName);
document.Add(stationName);
break;
case IssueType.Other:
var location = new Paragraph("Location: " +
Issue.Location);
document.Add(issueType);
break;
}
var description = new Paragraph("Description: " + Issue.Description);
document.Add(description);
This will add the necessary paragraph to the PDF document based on the issue's type. In addition to that, we will add the issue's description to the PDF document.
Note
Due to a bug in iText, running this code may create a NullReferenceException when adding the first element to the document. Unfortunately, at the time of writing this book, there is no known workaround. This will only occur when the debugger is attached and will not cause any issues when the app is running in production. When running the app with the debugger attached, you can click Continue via the toolbar to continue debugging the app.
SavePDFClickedCommand = new RelayCommand(async () =>
{
#if !__WASM__
var bytes = GeneratePDF();
var tempFileName =
$"{Path.GetFileNameWithoutExtension
(Path.GetTempFileName())}.pdf";
var folder = Windows.Storage.ApplicationData.
Current.TemporaryFolder;
await folder.CreateFileAsync(tempFileName,
Windows.Storage.CreationCollisionOption.
ReplaceExisting);
var file = await
folder.GetFileAsync(tempFileName);
await Windows.Storage.FileIO.WriteBufferAsync
(file, bytes.AsBuffer());
await Windows.System.Launcher.LaunchFileAsync
(file);
#endif
});
This will create a PDF, save it to the apps temporary folder, and open it with the default PDF handler.
Note
In this chapter, we are writing the file to a temporary folder and opening it using the default PDF viewer. Depending on your application and use case, FileSavePicker and other file pickers can be a very good fit. You can learn more about FileSavePicker and the other file pickers that are available here: https://platform.uno/docs/articles/features/windows-storage-pickers.html.
To try the issue export out, start the app and create a new issue. After that, select the issue from the issue list and click Export to PDF from the Issues dropdown at the top. Now, if you click on Create PDF, the PDF will be created. Shortly after that, the PDF will be opened in your default PDF viewer. The PDF should look something like this:
Since we cannot write a file to the user's local filesystem when the app is running on the web using WASM, in the next section, we will update our app to provide a download link on WASM instead of the Create PDF button by writing a custom HTML-element control.
While the key feature of Uno Platform is to run code that runs on all platforms, it also allows developers to write custom controls that are platform-specific. You can use this to take advantage of platform-specific controls. In our case, we will use this to create an HTML a-tag to provide a download link for the WASM version of our app. We will do this using the Uno.UI.Runtime.WebAssembly.HtmlElement attribute:
using System;
using System.Collections.Generic;
using System.Text;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
namespace ResourcePlanner.Views
{
#if __WASM__
[Uno.UI.Runtime.WebAssembly.HtmlElement("a")]
public class WasmDownloadElement : ContentControl
{
}
#endif
}
This will be our a -tag, which we will use to allow users to download the issue-export PDF. Since we only want this control on WASM, we have placed it inside the #if __WASM__ preprocessor directive.
public static readonly DependencyProperty MimeTypeProperty = DependencyProperty.Register(
"MimeType", typeof(string),
typeof(WasmDownloadElement), new
PropertyMetadata("application/octet-stream",
OnChanged));
public string MimeType
{
get => (string)GetValue(MimeTypeProperty);
set => SetValue(MimeTypeProperty, value);
}
public static readonly DependencyProperty FileNameProperty = DependencyProperty.Register(
"FileName", typeof(string),
typeof(WasmDownloadElement), new
PropertyMetadata("filename.bin", OnChanged));
public string FileName
{
get => (string)GetValue(FileNameProperty);
set => SetValue(FileNameProperty, value);}
private string _base64Content;
public void SetBase64Content(string content)
{
_base64Content = content;
Update();
}
private static void OnChanged(DependencyObject dependencyobject, DependencyPropertyChangedEventArgs args)
{
if (dependencyobject is WasmDownloadElement wd)
{
wd.Update();
}
}
private void Update()
{
if (_base64Content?.Length == 0)
{
this.ClearHtmlAttribute("href");
}
else
{
var dataUrl =
$"data:{MimeType};base64,{_base64Content}";
this.SetHtmlAttribute("href", dataUrl);
this.SetHtmlAttribute("download", FileName);
}
}
While this is a lot of code, we are only creating two DependencyProperty fields on the WasmDownloadElement class, namely MimeType and FileName, and allowing them to set the content that will be downloaded. The rest of the code handles setting the correct attributes on the underlying control.
#if __WASM__
this.WASMDownloadLink.MimeType =
"application/pdf";
var bytes = exportIssueVM.GeneratePDF();
var b64 = Convert.ToBase64String(bytes);
this.WASMDownloadLink.SetBase64Content(b64);
#endif
This will set the correct MIME type on the download link and set the correct content to download. Note that we defined the WASMDownloadLink element earlier in this chapter, inside the ExportIssueView.xaml file.
To test this, start the WASM head of your app. Once it has loaded, create an issue, then select it from the issue list and click Export to PDF via the Issues option. Instead of the Create PDF button, you should now see the Download PDF option, as shown in Figure 3.6:
Once you click the link, the PDF export will be downloaded.
In this chapter, we built a desktop app that works on Windows, macOS, and on the web using WASM. We covered how to write a data input form with input validation and how to use the Windows Community Toolkit. After that, we learned how to display data using the Windows Community Toolkit DataGrid control. Lastly, we covered how to export data in PDF format and provided a download link by writing a custom HTML control.
In the next chapter, we'll build a mobile app instead. While it will also be designed to be used by employees of UnoBookRail, the main focus will be running the app on a mobile device. Among other things, we'll use this app as an opportunity to look at working with unreliable connectivity and using device capabilities such as a camera.
18.219.236.62