-
Example of a VS Code launch.json file that sets up two debug configurations for a .NET 9 MAUI app—one for iOS using an “iPhone 16” device and one for Android using a “Pixel 9” device running API (or SDK) version 35.0.
/.vscode/launch.json
{ "version": "0.2.0", "configurations": [ { "name": "iOS", "type": "coreclr", "request": "launch", "program": "dotnet", "args": [ "build", "-t:Run", "-f:net9.0-ios", // iPhone 16 - Run bash cmd to find iOS device UDIDs: xcrun simctl list devices "-p:_DeviceName=:v2:udid:<device_id>" ], "cwd": "${workspaceFolder}", "stopAtEntry": false, "console": "integratedTerminal", "preLaunchTask": "build-ios" }, { "name": "Android", "type": "coreclr", "request": "launch", "program": "dotnet", "args": [ "build", "-t:Run", "-f:net9.0-android35.0", // Pixel 9 - Run bash cmd to find Android device UDIDs: adb devices // Open Android Studio > View > Tool Windows > Terminal OR just open the macOS Terminal. // If adb is not recognized, you may need to add the SDK platform-tools to your path: export PATH=$PATH:~/Library/Android/sdk/platform-tools // To ensure that it's a Pixel 9, run: adb -s <device_id> shell getprop ro.product.model "-p:_DeviceName=:v2:udid:<device_id>" // "-p:AndroidSdkVersion=35.0" ], "cwd": "${workspaceFolder}", "stopAtEntry": false, "console": "integratedTerminal", "preLaunchTask": "build-android" } ] }
/.vscode/tasks.json
{ "version": "2.0.0", "tasks": [ { "label": "build-ios", "type": "shell", "command": "dotnet clean && dotnet build -f net9.0-ios", "group": "build", "problemMatcher": "$msCompile" }, { "label": "build-android", "type": "shell", "command": "dotnet clean && dotnet build -f net9.0-android35.0", "group": "build", "problemMatcher": "$msCompile" } ] }
-
Getting Started with DevOps and .NET MAUI
-
- .NET Runtime Identifier Catalog
- Microsoft-hosted agents - Software
- Windows: %LOCALAPPDATA%\Xamarin\Logs
- Windows: %LOCALAPPDATA%\Xamarin\MonoTouch
- Mac: ~/Library/Logs/Xamarin
- Mac: ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
- Mac: ~/Library/MobileDevice/Provisioning\ Profiles
- Mac Show Hidden Files & Folders: Command + Shift + .(period)
- Macintosh HD > Applications
- Xcode path: /Applications/Xcode.app || /Applications/Xcode.app/Contents/Developer
- Macintosh HD > usr > bin
- codesign path: /usr/bin/codesign
- productbuild path: /usr/bin/productbuild
- Setting Xcode Development & Distribution Certificates to Use System Defaults (Trusted)
- Open Keychain Access and find your Apple Development and Apple Distribution certificates.
- Right-click each certificate → Get Info → Set Trust to "Use System Defaults".
- (Optional) Click each private key → Get Info → Access Control → Check "Allow all applications to access this item". Alternatively, you can leave it unchanged and grant access when prompted for a password by selecting "Always Allow."
- (Optional) Restart Xcode and your Mac to apply the changes.
.csproj Debug and Release Configurations
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup Condition="'$(TargetFramework)'=='net9.0-ios' AND '$(Configuration)'=='Debug'"> <CodesignKey>Apple Development: iPhone Developer</CodesignKey> <CodesignProvision>YourDevelopmentProfile</CodesignProvision> <Optimize>false</Optimize> </PropertyGroup> <PropertyGroup Condition="'$(TargetFramework)'=='net9.0-ios' AND '$(Configuration)'=='Release'"> <CodesignKey>Apple Distribution: iPhone Distribution</CodesignKey> <CodesignProvision>YourDistributionProfile</CodesignProvision> <Optimize>true</Optimize> </PropertyGroup> <PropertyGroup> <AppIcon>AppIcon</AppIcon> </PropertyGroup> <ItemGroup> <AlternativeAppIcon Include="AlternativeAppIcons" /> </ItemGroup> <ItemGroup> <MauiXaml Update="Views\MainPage.xaml"> <Generator>MSBuild:Compile</Generator> </MauiXaml> </ItemGroup> </Project>
-
.NET MAUI Deployment & testing
- Deploy & Debug .NET MAUI Apps to iOS Devices in VS Code - Complete Guide
- How to create a .p12 file | 3 Steps | IN DETAIL | for iOS Appstore and Adhoc Distribution
- How to Renew your IOS Distribution Or Development Certificate on Apple
- Developer Apple Creating API Keys for App Store Connect API
- .NET MAUI Apple account management
- .NET MAUI Deploy an iOS app using hot restart
-
fastlane CI Integrations - Best Practices - Azure DevOps
- App Store Connect API
- Mac: base64 -i ~/Downloads/AuthKey*{KeyID}.p8 -o ~/Downloads/AuthKey*{KeyID}.txt
-
.NET MAUI local databases
- https://www.nuget.org/packages/sqlite-net-pcl/
- https://www.nuget.org/packages/SQLiteNetExtensions/
- https://www.nuget.org/packages/SQLiteNetExtensions.Async
- https://www.nuget.org/packages/sqlite-net-sqlcipher/
- https://www.nuget.org/packages/SQLiteNetCipherExtensions
- https://www.nuget.org/packages/SQLiteNetCipherExtensions.Async
- https://www.nuget.org/packages/Microsoft.Data.Sqlite/
- https://www.nuget.org/packages/akavache/
- Clean the Build Cache – Run
dotnet clean
in your project directory to remove cached build artifacts. - Delete the bin and obj Folders – Manually delete these folders in your project directory to force a fresh build.
- Check Your Launch Configuration – Some users have reported issues with the launch.json file in VS Code, especially with the preLaunchTask setting.
- Verify Your .NET SDK and Workloads – Run dotnet --info to ensure you have the correct SDK version installed.
- Disable Hot Reload – Some developers have found that disabling XAML Hot Reload can resolve build issues.
Explanation of .NET MAUI
In .NET MAUI (Multi-platform App UI), the project structure and the way you define the initial UI of your application have evolved compared to older Xamarin.Forms projects. Below is a detailed explanation of the project layout, the differences between `MainPage` and `AppShell`, and their usage cases.
A typical .NET MAUI project has the following structure:
- Platforms: Contains platform-specific code and resources for Android, iOS, Windows, macOS, etc.
Android
,iOS
,MacCatalyst
,Windows
, etc.
- Resources: Contains app-wide resources like images, fonts, styles, and raw assets.
Images
,Fonts
,Styles
,Raw
, etc.
- App.xaml: Defines global resources and the entry point of the application.
- App.xaml.cs: The code-behind file for
App.xaml
. Contains theApp
class, which is the entry point of the application. - MainPage.xaml: The default page (UI) of the application.
- MainPage.xaml.cs: The code-behind file for
MainPage.xaml
. - AppShell.xaml: (Optional) Defines the shell-based navigation structure for the app.
- AppShell.xaml.cs: The code-behind file for
AppShell.xaml
.
- Definition:
MainPage
is the default page that is displayed when the application starts. It is a simple, single-page UI. - Usage: Suitable for simple apps that do not require complex navigation or multiple pages.
- Example:
public App() { InitializeComponent(); MainPage = new MainPage(); // Sets MainPage as the starting page }
- Definition:
AppShell
is a more advanced navigation structure that allows you to define a hierarchical navigation system, including flyout menus, tabs, and nested navigation. - Usage: Ideal for apps with multiple pages, complex navigation, or a need for a consistent UI structure across platforms.
- Example:
public App() { InitializeComponent(); MainPage = new AppShell(); // Sets AppShell as the starting point }
In .NET MAUI, the way the initial window and page are set up has changed to provide more flexibility and control.
In older versions, you would directly set the MainPage
property in the App
constructor:
public App()
{
InitializeComponent();
MainPage = new MainPage(); // Directly set MainPage
}
In .NET MAUI, the CreateWindow
method is overridden to provide more control over the creation of the application window:
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage()); // Create a new Window with MainPage
}
}
This new approach allows for more advanced scenarios, such as:
- Customizing the window (e.g., setting size, title, or other properties).
- Handling platform-specific initialization.
- Supporting multiple windows (e.g., on desktop platforms).
- Your app has a single page or very simple navigation.
- You do not need advanced navigation features like flyout menus or tabs.
- You want to keep the app structure simple and lightweight.
- Your app has multiple pages and requires a structured navigation system.
- You want to implement features like flyout menus, tabs, or nested navigation.
- You need a consistent UI structure across different platforms.
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:YourAppNamespace.Views"
x:Class="YourAppNamespace.AppShell">
<FlyoutItem Title="Home">
<ShellContent Route="MainPage" ContentTemplate="{DataTemplate views:MainPage}" />
</FlyoutItem>
<FlyoutItem Title="Settings">
<ShellContent Route="SettingsPage" ContentTemplate="{DataTemplate views:SettingsPage}" />
</FlyoutItem>
</Shell>
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
}
}
public partial class App : Application
{
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
}
MainPage
: Simple, single-page apps.AppShell
: Complex apps with structured navigation.CreateWindow
: Provides more control over window creation in .NET MAUI.
A concise reference guide that summarizes key concepts, syntax, and common practices for developing applications with .NET MAUI (Multi-platform App UI).
- Create a new .NET MAUI project:
# Create a new .NET MAUI project
dotnet new maui -n MyMauiApp
# Enter the project directory
cd MyMauiApp
# Restore NuGet packages
dotnet restore
#######################
# Android (Droid) Commands
#######################
# Clean the project for Android (if needed)
dotnet clean -f net6.0-android
# Build the project targeting Android
dotnet build -f net6.0-android
# Run/Deploy the app on an Android emulator or device
dotnet run -f net6.0-android
# Publish the project for Android in Release configuration
dotnet publish -f net6.0-android -c Release
#######################
# iOS Commands
#######################
# Clean the project for iOS (if needed)
dotnet clean -f net6.0-ios
# Build the project targeting iOS (requires macOS with iOS SDK)
dotnet build -f net6.0-ios
dotnet build -f net9.0-ios -c Release
# Run the app on an iOS simulator/device
dotnet run -f net6.0-ios
# Publish the project for iOS in Release configuration
dotnet publish -f net9.0-ios -c Release
- Run the app:
dotnet build -t:Run -f net7.0-android dotnet build -t:Run -f net7.0-ios
-
Label:
<Label Text="{x:Static local:Constants.WelcomeText}" /> <Label Text="Hello, MAUI!" FontSize="24" TextColor="Blue" HorizontalOptions="Center" VerticalOptions="Center" />
-
Button:
<Button Text="Click Me" Clicked="OnButtonClicked" HorizontalOptions="Center" VerticalOptions="Center" />
private void OnButtonClicked(object sender, EventArgs e) { DisplayAlert("Button Clicked", "You clicked the button!", "OK"); }
-
Entry (Text Input):
<Entry Placeholder="Enter text here" Text="{Binding UserInput}" HorizontalOptions="FillAndExpand" />
-
Image:
<Image Source="dotnet_bot.png" Aspect="AspectFill" HorizontalOptions="Center" VerticalOptions="Center" />
-
ListView:
<ListView ItemsSource="{Binding Items}"> <ListView.ItemTemplate> <DataTemplate> <ViewCell> <Label Text="{Binding Name}" /> </ViewCell> </DataTemplate> </ListView.ItemTemplate> </ListView>
-
StackLayout:
<StackLayout Orientation="Vertical" Spacing="10" Padding="20"> <Label Text="First Item" /> <Label Text="Second Item" /> <Button Text="Click Me" /> </StackLayout>
-
Grid:
<Grid RowDefinitions="Auto, Auto, *" ColumnDefinitions="*, *" RowSpacing="10" ColumnSpacing="10"> <Label Text="Row 0, Column 0" Grid.Row="0" Grid.Column="0" /> <Label Text="Row 0, Column 1" Grid.Row="0" Grid.Column="1" /> <Button Text="Click Me" Grid.Row="1" Grid.ColumnSpan="2" /> </Grid>
-
AbsoluteLayout:
<AbsoluteLayout> <BoxView Color="Red" AbsoluteLayout.LayoutFlags="PositionProportional" AbsoluteLayout.LayoutBounds="0.5, 0.5, 100, 100" /> </AbsoluteLayout>
-
Navigation Stack: PushAsync adds to the stack, allowing back navigation. PushModalAsync does not affect the stack and requires explicit closure.
-
User Experience: PushAsync is for linear navigation, while PushModalAsync is for modal, temporary interactions.
-
Navigate to a new page:
await Shell.Current.GoToAsync(nameof(NewPage)); await Shell.Current.Navigation.PushAsync(new NewPage()); await Navigation.PushAsync(new NewPage());
-
Navigate back:
await Shell.Current.Navigation.PopAsync(); await Navigation.PopAsync();
-
Modal Navigation:
await Shell.Current.Navigation.PushModalAsync(new ModalPage()); await Shell.Current.Navigation.PopModalAsync(); await Navigation.PushModalAsync(new ModalPage()); await Navigation.PopModalAsync();
-
BindingContext:
public partial class MainPage : ContentPage { public MainPage() { InitializeComponent(); BindingContext = new MainViewModel(); } }
-
Two-Way Binding:
<Entry Text="{Binding UserInput, Mode=TwoWay}" />
-
Command Binding:
<Button Text="Click Me" Command="{Binding ClickCommand}" /> // This binds the CommandParameter to the NoteId property of the current data context. <Button Text="Delete" Command="{Binding DeleteNoteAsync}" CommandParameter="{Binding NoteId}" /> // This binds the CommandParameter to the entire current data context note object. <Button Text="Delete" Command="{Binding DeleteNoteAsync}" CommandParameter="{Binding .}" />
public class MainViewModel : INotifyPropertyChanged { public ICommand ClickCommand { get; } public MainViewModel() { ClickCommand = new Command(OnClick); } private void OnClick() { // Handle click } }
-
Register Services:
public static class MauiProgram { public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder .UseMauiApp<App>() .ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); }); builder.Services.AddSingleton<IMyService, MyService>(); builder.Services.AddTransient<MainPage>(); return builder.Build(); } }
-
Inject Services:
public partial class MainPage : ContentPage { private readonly IMyService _myService; public MainPage(IMyService myService) { InitializeComponent(); _myService = myService; } }
-
Device Information:
var deviceType = DeviceInfo.DeviceType; // Physical or Virtual var platform = DeviceInfo.Platform; // Android, iOS, etc.
-
Platform-Specific XAML:
<ContentPage> <OnPlatform x:TypeArguments="Thickness"> <On Platform="iOS" Value="0,20,0,0" /> <On Platform="Android" Value="0,0,0,0" /> </OnPlatform> </ContentPage>
-
Robust File Handling in .NET MAUI: Capturing Photos or Picking Files with Fallback Options
- Enum Definition
[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] public enum MediaSelectionOptions : int { [EnumMember(Value = "Capture Picture"), Display(Name = "Capture Picture")] CapturePicture = 0, [EnumMember(Value = "Capture Video"), Display(Name = "Capture Video")] CaptureVideo = 1, [EnumMember(Value = "Select File"), Display(Name = "Select File")] SelectFile = 2 }
-
Enum:
MediaSelectionOptions
defines three options:CapturePicture
,CaptureVideo
, andSelectFile
. -
JsonConverter: The
StringEnumConverter
ensures that the enum values are serialized/deserialized as strings. -
EnumMember: Specifies the string value for each enum member.
-
Variables Initialization
FileResult? result = null; Stream? stream = null;
-
result: Holds the result of the file or photo capture operation.
-
stream: Holds the stream of the selected or captured file.
-
Display Action Sheet
string action = await Shell.Current.DisplayActionSheet( "Would you like to take a picture or select one?", "Cancel", null, nameof(Enums.MediaSelectionOptions.CapturePicture), nameof(Enums.MediaSelectionOptions.SelectFile) );
-
DisplayActionSheet: Displays a dialog with options to either capture a picture or select a file.
-
Enum Parsing and Switch Case
if (Enum.TryParse<Enums.MediaSelectionOptions>(action, out var selectedOption)) { switch (selectedOption) { case Enums.MediaSelectionOptions.CapturePicture: if (MediaPicker.Default.IsCaptureSupported) { result = await MediaPicker.Default.CapturePhotoAsync(); if (result != null) { stream = await result.OpenReadAsync(); } } else { await Shell.Current.DisplayAlert("Error", "Camera capture is not supported on this device.", "OK"); } break; case Enums.MediaSelectionOptions.SelectFile: result = await FilePicker.Default.PickAsync(); if (result != null) { stream = await result.OpenReadAsync(); } break; default: break; } } else { await Shell.Current.DisplayAlert("Info", "No selection was made.", "OK"); } if (result != null) { stream!.Position = 0; byte[] bytes = []; using (var memoryStream = new MemoryStream()) { stream.CopyTo(memoryStream); bytes = memoryStream.ToArray(); } }
- Enum.TryParse: Attempts to parse the selected action into the
MediaSelectionOptions
enum. - Switch Case: Executes different code based on the selected option:
- CapturePicture: Checks if capturing photos is supported, then captures a photo and opens a stream to the photo.
- SelectFile: Opens a file picker to select a file and opens a stream to the selected file.
- DisplayAlert: Shows alerts for errors or information.
-
Global Styles:
<Application.Resources> <ResourceDictionary> <Style TargetType="Label"> <Setter Property="FontSize" Value="18" /> <Setter Property="TextColor" Value="Black" /> </Style> </ResourceDictionary> </Application.Resources>
-
Local Styles:
<ContentPage.Resources> <ResourceDictionary> <Style x:Key="MyLabelStyle" TargetType="Label"> <Setter Property="FontSize" Value="24" /> <Setter Property="TextColor" Value="Blue" /> </Style> </ResourceDictionary> </ContentPage.Resources> <Label Text="Styled Label" Style="{StaticResource MyLabelStyle}" />
-
Simple Animation:
await myLabel.FadeTo(0, 1000); // Fade out over 1 second await myLabel.FadeTo(1, 1000); // Fade in over 1 second
-
Scale Animation:
await myButton.ScaleTo(2, 1000); // Scale up to 2x size over 1 second await myButton.ScaleTo(1, 1000); // Scale back to normal size over 1 second
-
Resource Files:
<Label Text="{x:Static resources:AppResources.HelloMessage}" />
// AppResources.resx <data name="HelloMessage" xml:space="preserve"> <value>Hello, World!</value> </data>
- Download the Fluent UI .ttf font file.
- Place the font file in the
Resources/Fonts
folder of your .NET MAUI project. - Ensure the build action for the font file is set to
MauiFont
.
-
Open your
MauiProgram.cs
file. -
Register the font using the
ConfigureFonts
method. Here's an example:public static class MauiProgram { public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder .UseMauiApp<App>() .ConfigureFonts(fonts => { fonts.AddFont("FluentSystemIcons-Regular.ttf", "FluentSystemIconsRegular"); }); return builder.Build(); } }
-
Use IconFont2Code to generate a class with the Unicode values for the icons.
-
Save the generated class in your project, for example, in
App.Resources.Fonts
.namespace App.Resources.Fonts { // Generated by IconFont2Code: https://andreinitescu.github.io/IconFont2Code // If you change the class to 'public' in order to use it outside its assembly // you should also change the fields from 'static readonly' to 'public static readonly' public static class FluentUI { public static readonly string FontFamily = "FluentUI"; public static readonly string access_time_20_regular = "\ue000"; public static readonly string timer_20_regular = "\ue001"; // Add other icons as needed } }
- Reference the font and use the Unicode value in your XAML files:
<xmlns:fonts="clr-namespace:App.Resources.Fonts" />
<Label
Text="{Binding Source={x:Static fonts:FluentUI.timer_20_regular}}"
FontFamily="FluentSystemIconsRegular"
FontSize="20" />
- You can also set the icon in your C# code:
var label = new Label { Text = App.Resources.Fonts.FluentUI.timer_20_regular, FontFamily = "FluentSystemIconsRegular", FontSize = 20 };
-
Read a file:
using var stream = await FileSystem.OpenAppPackageFileAsync("myfile.txt"); using var reader = new StreamReader(stream); var content = await reader.ReadToEndAsync();
-
Write to a file:
var filePath = Path.Combine(FileSystem.AppDataDirectory, "myfile.txt"); await File.WriteAllTextAsync(filePath, "Hello, MAUI!");
- HTTP Client:
var httpClient = new HttpClient(); var response = await httpClient.GetAsync("https://api.example.com/data"); var content = await response.Content.ReadAsStringAsync();
-
Save a preference:
Preferences.Set("username", "JohnDoe");
-
Retrieve a preference:
var username = Preferences.Get("username", "default_value");
-
Local Notifications:
var notification = new NotificationRequest { Title = "Test Notification", Description = "This is a test notification", ReturningData = "Dummy data" }; await LocalNotificationCenter.Current.Show(notification);
-
Debug.WriteLine:
Debug.WriteLine("This is a debug message");
-
Breakpoints:
- Set breakpoints in your code and run the app in debug mode to inspect variables and step through code.
- Unit Testing:
[Test] public void TestMethod() { var result = MyClass.MyMethod(); Assert.AreEqual(expectedValue, result); }
- Use
BindableObject
for custom controls to leverage data binding. - Avoid heavy computations on the UI thread; use
Task.Run
for background operations. - Use
ListView
withDataTemplate
for efficient list rendering.
- Memory Leaks: Ensure to unsubscribe from events and dispose of resources properly.
- UI Thread: Always update the UI on the main thread using
MainThread.BeginInvokeOnMainThread
.
- CommunityToolkit.Maui: Provides additional controls and helpers.
- Refit: Simplifies REST API consumption.
- Shiny: For background tasks, notifications, and more.
- Official Documentation: .NET MAUI Documentation
- GitHub Repository: .NET MAUI GitHub
- Community Forums: .NET MAUI Discussions
Certainly! Let's expand the .NET MAUI cheat sheet to include lifecycle events and reusable component loops examples.
.NET MAUI provides lifecycle events that allow you to hook into key moments in the lifecycle of pages, views, and the application itself.
- OnAppearing: Called when the page is about to appear.
- OnDisappearing: Called when the page is about to disappear.
protected override void OnAppearing()
{
base.OnAppearing();
Debug.WriteLine("Page is appearing");
// Perform actions like loading data or starting animations
}
protected override void OnDisappearing()
{
base.OnDisappearing();
Debug.WriteLine("Page is disappearing");
// Perform cleanup or stop animations
}
- OnStart: Called when the application starts.
- OnSleep: Called when the application goes to the background.
- OnResume: Called when the application resumes from the background.
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
protected override void OnStart()
{
Debug.WriteLine("App is starting");
// Perform actions like initializing services
}
protected override void OnSleep()
{
Debug.WriteLine("App is sleeping");
// Save state or pause ongoing tasks
}
protected override void OnResume()
{
Debug.WriteLine("App is resuming");
// Restore state or resume tasks
}
}
For custom views, you can override lifecycle methods like OnParentSet
, OnSizeAllocated
, etc.
public class CustomView : ContentView
{
protected override void OnParentSet()
{
base.OnParentSet();
Debug.WriteLine("CustomView parent has been set");
}
protected override void OnSizeAllocated(double width, double height)
{
base.OnSizeAllocated(width, height);
Debug.WriteLine($"CustomView size allocated: {width}x{height}");
}
}
Reusable components are a great way to avoid code duplication and maintain a clean architecture. Below are examples of creating reusable components and using them in loops.
- Custom Control (e.g., a reusable card):
- Create a custom control by subclassing
ContentView
.
- Create a custom control by subclassing
public class CardView : ContentView
{
public static readonly BindableProperty TitleProperty =
BindableProperty.Create(nameof(Title), typeof(string), typeof(CardView), string.Empty);
public string Title
{
get => (string)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public CardView()
{
var titleLabel = new Label
{
FontSize = 18,
TextColor = Colors.Black,
VerticalOptions = LayoutOptions.Center
};
titleLabel.SetBinding(Label.TextProperty, new Binding(nameof(Title), source: this));
Content = new StackLayout
{
Padding = 10,
Children = { titleLabel }
};
}
}
- Using the Reusable Component in XAML:
- Register the namespace and use the custom control.
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:MyMauiApp"
x:Class="MyMauiApp.MainPage">
<StackLayout>
<local:CardView Title="Card 1" />
<local:CardView Title="Card 2" />
</StackLayout>
</ContentPage>
- Binding a List of Items to a Reusable Component:
- Use a
CollectionView
orListView
to dynamically generate reusable components.
- Use a
<CollectionView ItemsSource="{Binding Cards}">
<CollectionView.ItemTemplate>
<DataTemplate>
<local:CardView Title="{Binding Title}" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
- ViewModel for the List:
- Define a
Card
model and aViewModel
to bind the data.
- Define a
public class Card
{
public string Title { get; set; }
}
public class MainViewModel
{
public ObservableCollection<Card> Cards { get; set; }
public MainViewModel()
{
Cards = new ObservableCollection<Card>
{
new Card { Title = "Card 1" },
new Card { Title = "Card 2" },
new Card { Title = "Card 3" }
};
}
}
- Binding the ViewModel:
- Set the
BindingContext
in the page.
- Set the
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
BindingContext = new MainViewModel();
}
}
If you need to create components dynamically in code (e.g., in a loop):
var stackLayout = new StackLayout();
foreach (var card in cards)
{
var cardView = new CardView { Title = card.Title };
stackLayout.Children.Add(cardView);
}
Content = stackLayout;
- Encapsulation: Keep the logic and UI of the reusable component self-contained.
- Bindable Properties: Use
BindableProperty
to make components flexible and data-bindable. - Performance: Avoid heavy computations in reusable components; use data binding and
ObservableCollection
for dynamic updates. - Reusability: Design components to be generic and reusable across different parts of the app.
Here’s an example of a reusable component that uses lifecycle events:
public class LifecycleAwareCard : ContentView
{
public LifecycleAwareCard()
{
var label = new Label { Text = "Lifecycle Card" };
Content = label;
// Simulate lifecycle awareness
this.Appearing += (sender, e) => label.Text = "Card Appeared";
this.Disappearing += (sender, e) => label.Text = "Card Disappeared";
}
}
Use it in XAML:
<local:LifecycleAwareCard />
- Lifecycle Events: Use
OnAppearing
,OnDisappearing
,OnStart
,OnSleep
, andOnResume
to manage app and page lifecycles. - Reusable Components: Create custom controls using
ContentView
and bindable properties for reusability. - Loops: Use
CollectionView
orListView
to dynamically generate reusable components from a list of data..
To check for an active internet connection, you can use the Connectivity
API provided by .NET MAUI.
- For Android, add the following permissions to the
AndroidManifest.xml
file:<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.INTERNET" />
- Use the
Connectivity
class to check the network access and type.
using Microsoft.Maui.Networking;
public bool IsInternetAvailable()
{
var current = Connectivity.NetworkAccess;
if (current == NetworkAccess.Internet)
{
Debug.WriteLine("Internet is available");
return true;
}
else
{
Debug.WriteLine("No internet access");
return false;
}
}
- Subscribe to connectivity changes to react to network changes in real-time.
public void MonitorConnectivity()
{
Connectivity.ConnectivityChanged += ConnectivityChangedHandler;
}
private void ConnectivityChangedHandler(object sender, ConnectivityChangedEventArgs e)
{
if (e.NetworkAccess == NetworkAccess.Internet)
{
Debug.WriteLine("Internet connection restored");
}
else
{
Debug.WriteLine("Internet connection lost");
}
}
if (IsInternetAvailable())
{
// Proceed with network operations
}
else
{
DisplayAlert("No Internet", "Please check your internet connection.", "OK");
}
.NET MAUI supports dark and light themes out of the box. You can dynamically switch between themes or apply them based on the system settings.
- Add theme-specific colors and styles in
App.xaml
or a separate resource dictionary.
<Application.Resources>
<ResourceDictionary>
<!-- Light Theme -->
<ResourceDictionary x:Key="LightTheme">
<Color x:Key="BackgroundColor">White</Color>
<Color x:Key="TextColor">Black</Color>
</ResourceDictionary>
<!-- Dark Theme -->
<ResourceDictionary x:Key="DarkTheme">
<Color x:Key="BackgroundColor">Black</Color>
<Color x:Key="TextColor">White</Color>
</ResourceDictionary>
</ResourceDictionary>
</Application.Resources>
- Use the
AppThemeBinding
to automatically switch between themes based on the system theme.
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MyMauiApp.MainPage"
BackgroundColor="{AppThemeBinding Light=White, Dark=Black}">
<Label Text="Hello, MAUI!"
TextColor="{AppThemeBinding Light=Black, Dark=White}"
HorizontalOptions="Center"
VerticalOptions="Center" />
</ContentPage>
- Programmatically switch themes using the
Application.Current.UserAppTheme
property.
// Set Light Theme
Application.Current.UserAppTheme = AppTheme.Light;
// Set Dark Theme
Application.Current.UserAppTheme = AppTheme.Dark;
// Follow System Theme
Application.Current.UserAppTheme = AppTheme.Unspecified;
- Use the
Application.Current.RequestedTheme
property to detect the current system theme.
var currentTheme = Application.Current.RequestedTheme;
if (currentTheme == AppTheme.Dark)
{
Debug.WriteLine("Dark theme is active");
}
else if (currentTheme == AppTheme.Light)
{
Debug.WriteLine("Light theme is active");
}
- Add a button to toggle between light and dark themes.
<Button Text="Toggle Theme"
Clicked="OnToggleThemeClicked"
HorizontalOptions="Center"
VerticalOptions="Center" />
private void OnToggleThemeClicked(object sender, EventArgs e)
{
if (Application.Current.UserAppTheme == AppTheme.Dark)
{
Application.Current.UserAppTheme = AppTheme.Light;
}
else
{
Application.Current.UserAppTheme = AppTheme.Dark;
}
}
Here’s an example of combining both features in a single page:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MyMauiApp.MainPage"
BackgroundColor="{AppThemeBinding Light=White, Dark=Black}">
<StackLayout>
<Label Text="Internet Status:"
TextColor="{AppThemeBinding Light=Black, Dark=White}"
HorizontalOptions="Center"
VerticalOptions="Center" />
<Label x:Name="InternetStatusLabel"
TextColor="{AppThemeBinding Light=Black, Dark=White}"
HorizontalOptions="Center"
VerticalOptions="Center" />
<Button Text="Check Internet"
Clicked="OnCheckInternetClicked"
HorizontalOptions="Center"
VerticalOptions="Center" />
<Button Text="Toggle Theme"
Clicked="OnToggleThemeClicked"
HorizontalOptions="Center"
VerticalOptions="Center" />
</StackLayout>
</ContentPage>
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
MonitorConnectivity();
}
private void OnCheckInternetClicked(object sender, EventArgs e)
{
InternetStatusLabel.Text = IsInternetAvailable() ? "Connected" : "Disconnected";
}
private void OnToggleThemeClicked(object sender, EventArgs e)
{
if (Application.Current.UserAppTheme == AppTheme.Dark)
{
Application.Current.UserAppTheme = AppTheme.Light;
}
else
{
Application.Current.UserAppTheme = AppTheme.Dark;
}
}
private bool IsInternetAvailable()
{
var current = Connectivity.NetworkAccess;
return current == NetworkAccess.Internet;
}
private void MonitorConnectivity()
{
Connectivity.ConnectivityChanged += ConnectivityChangedHandler;
}
private void ConnectivityChangedHandler(object sender, ConnectivityChangedEventArgs e)
{
MainThread.BeginInvokeOnMainThread(() =>
{
InternetStatusLabel.Text = e.NetworkAccess == NetworkAccess.Internet ? "Connected" : "Disconnected";
});
}
}
- Internet Connection: Use the
Connectivity
API to check and monitor network access. - Dark/Light Theming: Use
AppThemeBinding
andApplication.Current.UserAppTheme
to implement dynamic theming. - Combining Features: Integrate both features into your app for a seamless user experience.
28. The differences between ObservableObject
and INotifyPropertyChanged
in .NET 9, and comprehensive examples for each, adhering to nullable standards.
ObservableObject
is part of the CommunityToolkit.Mvvm package and provides a base implementation for INotifyPropertyChanged
and INotifyPropertyChanging
. It simplifies the process of creating observable properties.
Here's an example of using ObservableObject
in a .NET MAUI application:
-
Install the CommunityToolkit.Mvvm NuGet package:
dotnet add package CommunityToolkit.Mvvm
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-Regular.ttf", "OpenSansRegular"); }); #if DEBUG builder.Logging.AddDebug(); #endif return builder.Build(); } }
-
Create a ViewModel using
ObservableObject
:using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; public partial class MainPageViewModel : ObservableObject { [ObservableProperty] private string? _username; [ObservableProperty] private bool _isBusy; [RelayCommand] private void Submit() { IsBusy = true; // Simulate a long-running task Task.Delay(2000).ContinueWith(_ => IsBusy = false); } }
-
Bind the ViewModel to the View:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:vm="clr-namespace:YourNamespace" x:Class="YourNamespace.MainPage"> <ContentPage.BindingContext> <vm:MainPageViewModel /> </ContentPage.BindingContext> <StackLayout> <Entry Text="{Binding Username}" Placeholder="Enter your username" /> <Button Text="Submit" Command="{Binding SubmitCommand}" IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBooleanConverter}}" /> </StackLayout> </ContentPage>
INotifyPropertyChanged
is an interface that you implement to notify clients that a property value has changed. This is the traditional way to implement property change notifications.
Here's an example of using INotifyPropertyChanged
in a .NET MAUI application:
-
Create a ViewModel implementing
INotifyPropertyChanged
:using System.ComponentModel; using System.Runtime.CompilerServices; public class MainPageViewModel : INotifyPropertyChanged { private string? _username; public string? Username { get => _username; set { if (_username != value) { _username = value; OnPropertyChanged(); } } } private bool _isBusy; public bool IsBusy { get => _isBusy; set { if (_isBusy != value) { _isBusy = value; OnPropertyChanged(); } } } public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } public void Submit() { IsBusy = true; // Simulate a long-running task Task.Delay(2000).ContinueWith(_ => IsBusy = false); } }
-
Bind the ViewModel to the View:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:vm="clr-namespace:YourNamespace" x:Class="YourNamespace.MainPage"> <ContentPage.BindingContext> <vm:MainPageViewModel /> </ContentPage.BindingContext> <StackLayout> <Entry Text="{Binding Username}" Placeholder="Enter your username" /> <Button Text="Submit" Command="{Binding SubmitCommand}" IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBooleanConverter}}" /> </StackLayout> </ContentPage>
- ObservableObject: Simplifies the implementation by reducing boilerplate code. It uses attributes like
[ObservableProperty]
and[RelayCommand]
to generate properties and commands automatically. - INotifyPropertyChanged: Requires manual implementation of property change notifications, which can be more verbose and error-prone.
Both approaches are valid, but ObservableObject
can significantly reduce the amount of code you need to write and maintain.
Below is a comprehensive example of a .NET MAUI application that features a login screen using ObservableObject
, adheres to nullable standards, and includes command parameters. After a successful login, the user is navigated to a view notes screen.
Create a comprehensive example in .NET 9 MAUI that features an ObservableObject for a login screen, following nullable standards. Additionally, include an example of command parameters and a view notes screen that appears after logging in.
This class represents the user model.
public class User
{
public string? Username { get; set; }
public string? Password { get; set; }
}
This class implements ObservableObject
and handles the login logic.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
public partial class LoginViewModel : ObservableObject
{
public LoginViewModel()
{
PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(Username))
{
LoginCommand.NotifyCanExecuteChanged(); // Renamed from LoginAsyncCommand to LoginCommand
}
else if (e.PropertyName == nameof(Password))
{
LoginCommand.NotifyCanExecuteChanged(); // Renamed from LoginAsyncCommand to LoginCommand
}
};
}
[ObservableProperty]
[Required(ErrorMessage = "Username is required")]
private string? _username;
[ObservableProperty]
[Required(ErrorMessage = "Password is required")]
private string? _password;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginAsyncCommand))]
private bool _allowLogin = true;
// This method determines whether the LoginCommand can execute
private bool CanLoginExecute() => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password) && AllowLogin.Equals(true);
[RelayCommand(CanExecute = nameof(CanLoginExecute))]
private async Task LoginAsync()
{
// Simulate a login process
if (Username == "admin" && Password == "password")
{
ErrorMessage = null;
// await Shell.Current.GoToAsync("//notes");
await Shell.Current.Navigation.PushAsync(new Views.NotesPage());
}
else
{
ErrorMessage = "Invalid username or password.";
}
}
// This method is called whenever Username or Password changes
partial void OnUsernameChanged(string? value) => LoginCommand.NotifyCanExecuteChanged();
partial void OnPasswordChanged(string? value) => LoginCommand.NotifyCanExecuteChanged();
}
This is the XAML for the login screen.
<?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:vm="clr-namespace:YourNamespace.ViewModels"
x:Class="YourNamespace.Views.LoginPage"
Title="Login">
<ContentPage.BindingContext>
<vm:LoginViewModel />
</ContentPage.BindingContext>
<StackLayout Padding="20">
<Entry Placeholder="Username" Text="{Binding Username}" />
<Entry Placeholder="Password" Text="{Binding Password}" IsPassword="True" />
<Label Text="{Binding ErrorMessage}" TextColor="Red" />
<Button Text="Login" Command="{Binding LoginCommand}" />
</StackLayout>
</ContentPage>
This class handles the notes screen logic.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
public partial class NotesViewModel : ObservableObject
{
public NotesViewModel()
{
PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(CurrentNote))
{
AddNoteCommand.NotifyCanExecuteChanged();
}
};
}
// private List<int> list { get; set; } = new List<int> { 1, 2, 3, 4, 5 };
// [ObservableProperty]
// ObservableCollection<int> _observableCollection = new ObservableCollection<int>(list);
// ObservableCollection<int> _observableCollection = new(list);
[ObservableProperty]
private string? _currentNote;
[ObservableProperty]
private ObservableCollection<string> _notes = new ObservableCollection<string>
{
"Note 1",
"Note 2",
"Note 3"
};
// This method determines whether the AddNoteCommand can execute
private bool CanAddNoteExecute() => !string.IsNullOrEmpty(CurrentNote);
[RelayCommand(CanExecute = nameof(CanAddNoteExecute))]
private void AddNote(string? note)
{
if (!string.IsNullOrWhiteSpace(note))
{
Notes.Add(note);
}
}
}
This is the XAML for the notes screen.
<?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:vm="clr-namespace:YourNamespace.ViewModels"
x:Class="YourNamespace.Views.NotesPage"
Title="Notes">
<ContentPage.BindingContext>
<vm:NotesViewModel />
</ContentPage.BindingContext>
<StackLayout Padding="20">
<ListView ItemsSource="{Binding Notes}">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<Label Text="{Binding .}" />
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Entry Placeholder="Add a new note" x:Name="NewNoteEntry" Text="{Binding CurrentNote}" />
<Button Text="Add Note" Command="{Binding AddNoteCommand}" CommandParameter="{Binding Source={x:Reference NewNoteEntry}, Path=Text}" />
</StackLayout>
</ContentPage>
-
CanExecute
inLoginViewModel
:- The
CanLoginExecute
method checks if bothUsername
andPassword
are not null or empty. - The
LoginCommand
is updated to includeCanExecute = nameof(CanLoginExecute)
. - The
OnUsernameChanged
andOnPasswordChanged
methods notify theLoginCommand
to re-evaluate itsCanExecute
state whenever theUsername
orPassword
properties change.
- The
-
CanExecute
inNotesViewModel
:- The
CanAddNoteExecute
method checks if thenote
parameter is not null or empty. - The
AddNoteCommand
is updated to includeCanExecute = nameof(CanAddNoteExecute)
.
- The
-
Automatic Button State:
- The
Login
button will be enabled only when bothUsername
andPassword
are filled. - The
Add Note
button will be enabled only when theEntry
field is not empty.
- The
To adhere to nullable standards, ensure that your .csproj
file includes the following property:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
- When the application starts, the user is presented with the login screen.
- The
Login
button will be disabled until bothUsername
andPassword
fields are filled. - After entering the correct credentials (
admin
/password
), the user is navigated to the notes screen. - On the notes screen, the user can view existing notes and add new ones.
- The
Add Note
button will be disabled until theEntry
field has some text.
Make sure you have the following NuGet packages installed:
CommunityToolkit.Mvvm
(forObservableObject
,RelayCommand
, etc.)
Find the relative source ancestor type by binding context:
private T? FindAncestorBindingContext<T>(Element element) where T : class
{
Element currentElement = element;
while (currentElement is not null)
{
if (currentElement.BindingContext is T viewModel)
{
return viewModel;
}
currentElement = currentElement.Parent;
}
return default;
}
private Task TestFindAncestorBindingContext()
{
try
{
var todoDetailViewModel = FindAncestorBindingContext<TodoDetailViewModel>(this);
if (todoDetailViewModel is not null)
{
todoDetailViewModel.Item = todoDetailViewModel.GetItem();
return Task.CompletedTask;
}
else
{
return Task.CompletedTask;
}
}
catch (Exception)
{
return Task.CompletedTask;
}
}
Building a .NET MAUI App with MVVM: Using Partial Views and Community Toolkit
Creating a .NET MAUI application using the MVVM (Model-View-ViewModel) pattern with a list that uses a partial view for each row and a button that sends the row object as a parameter can be achieved by following these steps. We'll use the Community Toolkit for MVVM to simplify the process.
-
Create a new .NET MAUI project:
dotnet new maui -n MauiListApp cd MauiListApp
-
Install the Community Toolkit MVVM package:
dotnet add package CommunityToolkit.Maui dotnet add package CommunityToolkit.Mvvm dotnet add package Newtonsoft.Json
Create a simple model class that represents the data for each row.
// Models/Item.cs
using Newtonsoft.Json;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MauiListApp.Models
{
public class Item
{
[JsonIgnore]
private string? _name;
[JsonIgnore]
public string? Name
{
get => _name;
set
{
if (_name != value)
{
_name = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
private string? _description;
[JsonIgnore]
public string? Description
{
get => _description;
set
{
if (_description != value)
{
_description = value;
OnPropertyChanged();
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
Create a ViewModel that will handle the logic for the list and the button click.
// ViewModels/ItemViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MauiListApp.Models;
using System.Collections.ObjectModel;
namespace MauiListApp.ViewModels
{
public partial class ItemViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<Item> _items;
[ObservableProperty]
private bool _isRefresh;
public ItemViewModel()
{
Items = new ObservableCollection<Item>
{
new Item { Name = "Item 1", Description = "Description 1" },
new Item { Name = "Item 2", Description = "Description 2" },
new Item { Name = "Item 3", Description = "Description 3" }
};
}
[RelayCommand]
private async Task OnItemClicked(Item item)
{
// Handle the button click event
// For example, display an alert with the item's name
await Task.Delay(1000);
await Shell.Current.DisplayAlert("Item Clicked", $"You clicked {item.Name}", "OK");
}
[RelayCommand]
private async Task Refresh()
{
IsRefresh = true;
// Handle the refresh event
await Task.Delay(1000);
IsRefresh = false;
}
}
}
Create a partial view (DataTemplate) for each row in the list.
<!-- Views/ItemView.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewModels="clr-namespace:MauiListApp.ViewModels"
x:Class="MauiListApp.Views.ItemView">
<Grid Padding="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<VerticalStackLayout>
<Label Text="{Binding Name}" FontSize="16" FontAttributes="Bold" />
<Label Text="{Binding Description}" FontSize="14" />
</VerticalStackLayout>
<Button Grid.Column="1"
Text="Click Me"
Command="{Binding Source={RelativeSource AncestorType={x:Type viewModels:ItemViewModel}}, Path=OnItemClickedCommand}"
CommandParameter="{Binding .}" />
</Grid>
</ContentView>
Create the main view that will display the list of items using the partial view.
<!-- Views/MainPage.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"
xmlns:viewModels="clr-namespace:MauiListApp.ViewModels"
xmlns:views="clr-namespace:MauiListApp.Views"
x:Class="MauiListApp.MainPage"
Title="MAUI List App">
<ContentPage.BindingContext>
<viewModels:ItemViewModel />
</ContentPage.BindingContext>
<RefreshView IsRefreshing="{Binding IsRefresh}" Command="{Binding RefreshCommand}">
<CollectionView ItemsSource="{Binding Items}">
<CollectionView.ItemTemplate>
<DataTemplate>
<views:ItemView />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
</ContentPage>
// Views/MainPage.xaml.cs
using MauiListApp.ViewModels;
namespace MauiListApp.Views
{
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
ItemViewModel ItemViewModel = IPlatformApplication.Current!.Services.GetService<ItemViewModel>();
BindingContext = ItemViewModel;
}
}
}
In the MauiProgram.cs
, register the views and viewModels if necessary.
// MauiProgram.cs
using CommunityToolkit.Maui;
using MauiListApp.ViewModels;
using MauiListApp.Views;
namespace MauiListApp;
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");
});
// Register ViewModels and Views
builder.Services.AddSingleton<ItemViewModel>();
builder.Services.AddSingleton<MainPage>();
return builder.Build();
}
}
Now, you can run the application. The main page will display a list of items, and each item will have a button that, when clicked, will display an alert with the item's name.
- Model: Represents the data (
Item
). - ViewModel: Manages the list of items and handles the button click (
ItemViewModel
). - View: The main view (
MainPage.xaml
) and the partial view for each row (ItemView.xaml
). - Community Toolkit MVVM: Used for simplifying the MVVM pattern with
ObservableObject
,ObservableProperty
, andRelayCommand
.
This setup provides a clean separation of concerns and leverages the power of the Community Toolkit to simplify the MVVM implementation in .NET MAUI.
Comprehensive Guide to developing a .NET MAUI MVVM Todo Application with REST API CRUD Operations
Creating a .NET MAUI MVVM Todo Application with REST API CRUD operations involves several steps. Below is a comprehensive guide to help you build the application, including folder structure, login page, todo detail page, and HTTP requests.
- .NET 9 SDK installed
- Visual Studio 2022 or later with MAUI workload installed
- Basic understanding of C#, MVVM pattern, and REST APIs
- Open Visual Studio.
- Create a new project.
- Select
.NET MAUI App (Preview)
. - Name your project (e.g.,
MauiTodoApp
). - Click
Create
.
Organize your project with the following folder structure:
MauiTodoApp/
│
├── Converters/
│ └── InverseBooleanConverter.cs
│
├── Helpers/
│ └── Settings.cs
|
├── Models/
│ └── TodoItem.cs
│ └── TokenResponse.cs
│ ├── User.cs
│ └── UserResponse.cs
│
├── ViewModels/
│ ├── LoginViewModel.cs
│ ├── TodoListViewModel.cs
│ └── TodoDetailViewModel.cs
│
├── Views/
│ ├── LoginPage.xaml
│ ├── TodoListPage.xaml
│ └── TodoDetailPage.xaml
│
├── Services/
│ └── TodoService.cs
│
├── App.xaml
├── App.xaml.cs
├── AppShell.xaml
├── AppShell.xaml.cs
└── MauiProgram.cs
Create a InverseBooleanConverter
, Settings
, CommandExtensions
, TokenResponse
, TodoItem
, User
, and UserResponse
, model in the Models
folder.
// Converters/InverseBooleanConverter.cs
using System.Globalization;
namespace MauiTodoApp.Converters
{
public class InverseBooleanConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
return !boolValue;
}
return false;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
return !boolValue;
}
return false;
}
}
}
// Helpers/Settings.cs
namespace MauiTodoApp.Helpers
{
public static class Settings
{
public static string Username
{
get
{
return Preferences.Default.Get("Username", "");
}
set
{
Preferences.Default.Set("Username", value);
}
}
public static string Password
{
get
{
return Preferences.Default.Get("Password", "");
}
set
{
Preferences.Default.Set("Password", value);
}
}
public static string AccessToken
{
get
{
return Preferences.Default.Get("AccessToken", "");
}
set
{
Preferences.Default.Set("AccessToken", value);
}
}
public static DateTime AccessTokenExpiration
{
get
{
return Preferences.Default.Get("AccessTokenExpiration", DateTime.UtcNow);
}
set
{
Preferences.Default.Set("AccessTokenExpiration", value);
}
}
}
}
// Helpers/CommandExtensions.cs
using System.Windows.Input;
namespace MauiTodoApp.Helpers
{
public static class CommandExtensions
{
public static ICommand ToSafeCommand(this ICommand command)
{
return command;
}
}
}
// Models/TokenResponse.cs
using Newtonsoft.Json;
namespace MauiTodoApp.Models
{
public class TokenResponse
{
[JsonProperty("token_type")]
public string? TokenType { get; set; }
[JsonProperty("expires_in")]
public int ExpiresIn { get; set; }
[JsonProperty("ext_expires_in")]
public int ExtExpiresIn { get; set; }
[JsonProperty("access_token")]
public string? AccessToken { get; set; }
}
}
// Models/TodoItem.cs
namespace MauiTodoApp.Models
{
public class TodoItem
{
public int Id { get; set; }
public int UserId { get; set; }
public string? Title { get; set; }
public bool Completed { get; set; }
}
}
// Models/User.cs
namespace MauiTodoApp.Models
{
public class User
{
public int Id { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? Email { get; set; }
}
}
// Models/UserResponse.cs
using System.Collections.Generic;
namespace MauiTodoApp.Models
{
public class UserResponse
{
public required List<User> Users { get; set; }
}
}
Create a TodoService
class in the Services
folder to handle HTTP requests.
// Services/TodoService.cs
using MauiTodoApp.Models;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace MauiTodoApp.Services
{
public class TodoService
{
private readonly HttpClient _httpClient;
public TodoService(HttpClient httpClient)
{
_httpClient = httpClient;
}
// public static async Task<bool> LoginAsync(string username, string password)
// {
// var userModel = new User { Username = username, Password = password, Email = username };
// var json = JsonConvert.SerializeObject(userModel);
// HttpContent content = new StringContent(json);
// content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
// var response = await new HttpClient().PostAsync("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", content);
// var tokenResponse = JsonConvert.DeserializeObject<TokenResponse>(await response.Content.ReadAsStringAsync());
// Settings.AccessToken = tokenResponse!.AccessToken!;
// Settings.AccessTokenExpiration = DateTime.Now.AddSeconds(tokenResponse!.ExtExpiresIn!);
// return string.IsNullOrEmpty(Settings.AccessToken).Equals(false);
// }
// public async Task<bool> LoginAsync(string username, string password)
// {
// var loginRequest = new { username, password };
// var response = await _httpClient.PostAsJsonAsync("https://jsonplaceholder.typicode.com/users", loginRequest);
// return response.IsSuccessStatusCode;
// }
public async Task<bool> LoginAsync(string username, string password)
{
try
{
var response = await _httpClient.GetFromJsonAsync<UserResponse>("https://dummyjson.com/users");
if (response?.Users != null)
{
var user = response.Users.FirstOrDefault(u =>
u.Username!.Equals(username, StringComparison.OrdinalIgnoreCase) &&
u.Password == password);
return user != null;
}
}
catch (Exception ex)
{
Console.WriteLine($"Login failed: {ex.Message}");
}
return false;
}
public async Task<List<TodoItem>> GetTodosAsync()
{
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "token");
return await _httpClient.GetFromJsonAsync<List<TodoItem>>("https://jsonplaceholder.typicode.com/todos") ?? new();
}
public async Task<TodoItem> GetTodoAsync(int id)
{
return await _httpClient.GetFromJsonAsync<TodoItem>($"https://jsonplaceholder.typicode.com/todos/{id}") ?? new();
}
public async Task<TodoItem> CreateTodoAsync(TodoItem todo)
{
var response = await _httpClient.PostAsJsonAsync("https://jsonplaceholder.typicode.com/posts", todo);
return await response.Content.ReadFromJsonAsync<TodoItem>() ?? new();
}
public async Task UpdateTodoAsync(int id, TodoItem todo)
{
await _httpClient.PutAsJsonAsync($"https://jsonplaceholder.typicode.com/todos/{id}", todo);
}
public async Task DeleteTodoAsync(int id)
{
await _httpClient.DeleteAsync($"https://jsonplaceholder.typicode.com/todos/{id}");
}
}
}
Create ViewModels for Login, TodoList, and TodoDetail.
// ViewModels/LoginViewModel.cs
using MauiTodoApp.Helpers;
using MauiTodoApp.Services;
using MauiTodoApp.Views;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace MauiTodoApp.ViewModels
{
public partial class LoginViewModel : INotifyPropertyChanged
{
public string? Username { get; set; }
public string? Password { get; set; }
private bool _isLoading;
private readonly TodoService _todoService = new(new HttpClient());
public bool IsLoading
{
get => _isLoading;
set
{
_isLoading = value;
OnPropertyChanged();
}
}
public ICommand LoginCommand
{
get
{
return new Command(async () =>
{
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
{
await Shell.Current.DisplayAlert("Login failed", "Username and password are required.", "OK");
return;
}
IsLoading = true;
var isAuthenticated = await _todoService.LoginAsync(Username!, Password!);
if (isAuthenticated)
{
await Shell.Current.GoToAsync(nameof(TodoListPage));
IsLoading = false;
}
else
{
IsLoading = false;
await Shell.Current.DisplayAlert("Login failed", "Invalid username or password.", "OK");
}
}).ToSafeCommand();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
// ViewModels/TodoListViewModel.cs
using MauiTodoApp.Models;
using MauiTodoApp.Services;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MauiTodoApp.ViewModels
{
public partial class TodoListViewModel : INotifyPropertyChanged
{
private readonly TodoService _todoService = new(new HttpClient());
private List<TodoItem>? _todos;
private bool _isLoading;
public List<TodoItem> Todos
{
get => _todos!;
set
{
_todos = value;
OnPropertyChanged();
}
}
public bool IsLoading
{
get => _isLoading;
set
{
_isLoading = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public async Task LoadTodosAsync()
{
IsLoading = true;
Todos = await _todoService.GetTodosAsync();
IsLoading = false;
}
}
}
// ViewModels/TodoDetailViewModel.cs
using MauiTodoApp.Helpers;
using MauiTodoApp.Models;
using MauiTodoApp.Views;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace MauiTodoApp.ViewModels
{
public partial class TodoDetailViewModel : INotifyPropertyChanged
{
private TodoItem? _todo;
public TodoItem Todo
{
get { return _todo!; }
set
{
_todo = value;
OnPropertyChanged();
}
}
public string GetItem()
{
return "Test";
}
public ICommand BackCommand
{
get
{
return new Command(async () =>
{
await Shell.Current.GoToAsync(nameof(TodoListPage));
}).ToSafeCommand();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
Create XAML pages for Login, TodoList, and TodoDetail.
<!-- Views/LoginPage.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"
xmlns:local="clr-namespace:MauiTodoApp"
x:Class="MauiTodoApp.Views.LoginPage"
xmlns:viewModels="clr-namespace:MauiTodoApp.ViewModels"
x:DataType="viewModels:LoginViewModel"
Shell.NavBarIsVisible="false"
Title="Login">
<ContentPage.BindingContext>
<viewModels:LoginViewModel />
</ContentPage.BindingContext>
<StackLayout Orientation="Vertical">
<ActivityIndicator IsRunning="{Binding IsLoading}" IsVisible="{Binding IsLoading}" VerticalOptions="Center" HorizontalOptions="Center" />
<Entry Text="{Binding Username}" Placeholder="username" IsVisible="{Binding IsLoading, Converter={StaticResource InverseBooleanConverter}}" />
<Entry Text="{Binding Password}" Placeholder="password" IsPassword="True" IsVisible="{Binding IsLoading, Converter={StaticResource InverseBooleanConverter}}" />
<Button Command="{Binding LoginCommand}" Text="Login" IsVisible="{Binding IsLoading, Converter={StaticResource InverseBooleanConverter}}" />
</StackLayout>
</ContentPage>
// Views/LoginPage.xaml.cs -->
using MauiTodoApp.Models;
namespace MauiTodoApp.Views
{
public partial class LoginPage : ContentPage
{
public LoginPage()
{
InitializeComponent();
}
}
}
<!-- Views/TodoListPage.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"
xmlns:local="clr-namespace:MauiTodoApp"
x:Class="MauiTodoApp.Views.TodoListPage"
xmlns:models="clr-namespace:MauiTodoApp.Models"
xmlns:viewModels="clr-namespace:MauiTodoApp.ViewModels"
x:DataType="viewModels:TodoListViewModel"
Title="Todo List">
<ContentPage.BindingContext>
<viewModels:TodoListViewModel />
</ContentPage.BindingContext>
<Shell.BackButtonBehavior>
<BackButtonBehavior IsVisible="False" IsEnabled="False"/>
</Shell.BackButtonBehavior>
<StackLayout Orientation="Vertical">
<ActivityIndicator IsRunning="{Binding IsLoading}" IsVisible="{Binding IsLoading}" VerticalOptions="Center" HorizontalOptions="Center" />
<ListView ItemsSource="{Binding Todos}" ItemTapped="GoToTodoDetailPage_ItemTapped" IsVisible="{Binding IsLoading, Converter={StaticResource InverseBooleanConverter}}">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:TodoItem">
<ViewCell>
<StackLayout>
<Label Text="{Binding Title}"/>
<Label Text="{Binding Completed, StringFormat='Completed: {0}'}" />
</StackLayout>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackLayout>
</ContentPage>
// Views/TodoListPage.xaml.cs -->
using MauiTodoApp.Models;
using MauiTodoApp.ViewModels;
namespace MauiTodoApp.Views
{
public partial class TodoListPage : ContentPage
{
public TodoListPage()
{
InitializeComponent();
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (BindingContext is TodoListViewModel viewModel)
{
await viewModel.LoadTodosAsync();
}
}
private async void GoToTodoDetailPage_ItemTapped(object sender, ItemTappedEventArgs e)
{
var todo = e.Item as TodoItem;
//var navigationParameter = new ShellNavigationQueryParameters
//{
// { "Todo", todo! }
//};
//await Shell.Current.GoToAsync(nameof(TodoDetailPage), navigationParameter);
await Navigation.PushAsync(new TodoDetailPage(todo));
}
}
}
<!-- Views/TodoDetailPage.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="MauiTodoApp.Views.TodoDetailPage"
xmlns:viewModels="clr-namespace:MauiTodoApp.ViewModels"
x:DataType="viewModels:TodoDetailViewModel"
Title="Todo Details">
<ContentPage.BindingContext>
<viewModels:TodoDetailViewModel />
</ContentPage.BindingContext>
<Shell.BackButtonBehavior>
<BackButtonBehavior Command="{Binding BackCommand}" />
</Shell.BackButtonBehavior>
<StackLayout Orientation="Vertical">
<Entry Text="{Binding Todo.Title}" />
</StackLayout>
</ContentPage>
// Views/TodoDetailPage.xaml.cs -->
using MauiTodoApp.Models;
using MauiTodoApp.ViewModels;
namespace MauiTodoApp.Views
{
public partial class TodoDetailPage : ContentPage, IQueryAttributable
{
public TodoDetailPage(TodoItem? todoItem = null)
{
InitializeComponent();
if (todoItem != null)
{
var todoDetailViewModel = new TodoDetailViewModel
{
Todo = todoItem!
};
BindingContext = todoDetailViewModel;
}
}
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("Todo", out object? value))
{
var todoItem = value as TodoItem;
var todoDetailViewModel = new TodoDetailViewModel
{
Todo = todoItem!
};
BindingContext = todoDetailViewModel;
}
}
}
}
In MauiProgram.cs
, register your Services, ViewModels, and Views.
// MauiProgram.cs
using MauiTodoApp.Services;
using MauiTodoApp.ViewModels;
using MauiTodoApp.Views;
using Microsoft.Extensions.Logging;
namespace MauiTodoApp
{
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");
});
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<TodoListViewModel>();
builder.Services.AddTransient<TodoDetailViewModel>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<TodoListPage>();
builder.Services.AddTransient<TodoDetailPage>();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
}
<!-- App.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:MauiTodoApp"
x:Class="MauiTodoApp.App"
xmlns:viewModels="clr-namespace:MauiTodoApp.ViewModels"
xmlns:converters="clr-namespace:MauiTodoApp.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<converters:InverseBooleanConverter x:Key="InverseBooleanConverter" />
</ResourceDictionary>
</Application.Resources>
</Application>
Create an AppShell
to handle navigation.
<!-- AppShell.xaml -->
<?xml version="1.0" encoding="UTF-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:MauiTodoApp"
xmlns:views="clr-namespace:MauiTodoApp.Views"
FlyoutHeaderBehavior="CollapseOnScroll"
x:Class="MauiTodoApp.AppShell"
x:DataType="local:AppShell"
x:Name="shell">
<ShellContent Title="Login" ContentTemplate="{DataTemplate views:LoginPage}" Route="LoginPage" Shell.FlyoutBehavior="Disabled" Shell.FlyoutItemIsVisible="False" />
<ShellContent Title="Todo List" ContentTemplate="{DataTemplate views:TodoListPage}" Route="TodoListPage" />
<!-- <ShellContent Title="Todo Detail" ContentTemplate="{DataTemplate views:TodoDetailPage}" Route="TodoDetailPage" /> -->
<!-- <MenuItem Text="Todo List"
Command="{Binding GoToPageCommand}"
CommandParameter="{x:Type views:TodoListPage}">
</MenuItem> -->
<!-- <MenuItem Text="Todo Detail"
Command="{Binding GoToPageCommand}"
CommandParameter="{x:Type views:TodoDetailPage}">
</MenuItem> -->
<MenuItem Text="Logout"
Command="{Binding LogoutCommand}">
</MenuItem>
</Shell>
// AppShell.xaml.cs
using System.Windows.Input;
using MauiTodoApp.Views;
namespace MauiTodoApp
{
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
BindingContext = this;
//Routing.RegisterRoute(nameof(LoginPage), typeof(LoginPage));
//Routing.RegisterRoute(nameof(TodoListPage), typeof(TodoListPage));
Routing.RegisterRoute(nameof(TodoDetailPage), typeof(TodoDetailPage));
}
private Task<NavigationPage> GetNavigationPage(Type type)
{
try
{
if (IPlatformApplication.Current is not null
&& IPlatformApplication.Current.Services is IServiceProvider serviceProvider)
{
if (serviceProvider.GetService(type) is not null
&& serviceProvider.GetService(type) is ContentPage contentPage)
{
return Task.FromResult(new NavigationPage(contentPage));
}
else if (Activator.CreateInstance(type) is Page page)
{
return Task.FromResult(new NavigationPage(page));
}
else
{
return Task.FromResult(new NavigationPage(new LoginPage()));
}
}
else
{
return Task.FromResult(new NavigationPage(new LoginPage()));
}
}
catch (Exception)
{
return Task.FromResult(new NavigationPage(new LoginPage()));
}
}
public ICommand GoToPageCommand
{
get
{
return new Command<Type>(async (targetType) =>
{
try
{
if (Current.FlyoutIsPresented is true)
{
Current.FlyoutIsPresented = false;
}
switch (targetType)
{
case Type t when t == typeof(TodoListPage):
await GoToAsync(nameof(TodoListPage));
break;
case Type t when t == typeof(TodoDetailPage):
await GoToAsync(nameof(TodoDetailPage));
break;
}
}
catch (Exception ex)
{
await DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
}
}).ToSafeCommand();
}
}
public ICommand LogoutCommand
{
get
{
return new Command(async () =>
{
try
{
await GoToAsync("//LoginPage");
}
catch (Exception ex)
{
await DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
}
}).ToSafeCommand();
}
}
}
}
Comprehensive Guide to implement service interface injection in Xamarin and .NET MAUI for Android and iOS platforms
To implement service interface injection in Xamarin and .NET MAUI for Android and iOS platforms, you can use DependencyService in Xamarin or Dependency Injection in .NET MAUI. Below is an example of how to achieve this:
public interface IFileHelper
{
Task<string> GetDocumentsPath();
Task WriteToFile(string filename, string content);
Task ShareFile(string filename); // Added for iOS file sharing
}
-
Android:
[assembly: Dependency(typeof(FileHelperAndroid))] namespace YourNamespace.Droid { public class FileHelperAndroid : IFileHelper { public Task<string> GetDocumentsPath() { // Save to external storage (Downloads folder) var path = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDownloads).AbsolutePath; return Task.FromResult(path); } public async Task WriteToFile(string filename, string content) { try { string path = await GetDocumentsPath(); string filePath = Path.Combine(path, filename); Debug.WriteLine($"Writing to file: {filePath}"); if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } using (StreamWriter writer = new StreamWriter(filePath, append: true)) { await writer.WriteLineAsync(content); } Debug.WriteLine($"File written successfully: {filePath}"); } catch (UnauthorizedAccessException ex) { Debug.WriteLine($"Permission error: {ex.Message}"); } catch (IOException ex) { Debug.WriteLine($"IO error: {ex.Message}"); } catch (Exception ex) { Debug.WriteLine($"Unexpected error: {ex.Message}"); } } public Task ShareFile(string filename) { // Not needed for Android since files are saved to external storage return Task.CompletedTask; } } }
-
iOS:
[assembly: Dependency(typeof(FileHelperIOS))] namespace YourNamespace.iOS { public class FileHelperIOS : IFileHelper { public Task<string> GetDocumentsPath() { // Save to app's Documents directory var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); return Task.FromResult(documents); } public async Task WriteToFile(string filename, string content) { try { string path = await GetDocumentsPath(); string filePath = Path.Combine(path, filename); Debug.WriteLine($"Writing to file: {filePath}"); if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } using (StreamWriter writer = new StreamWriter(filePath, append: true)) { await writer.WriteLineAsync(content); } Debug.WriteLine($"File written successfully: {filePath}"); } catch (UnauthorizedAccessException ex) { Debug.WriteLine($"Permission error: {ex.Message}"); } catch (IOException ex) { Debug.WriteLine($"IO error: {ex.Message}"); } catch (Exception ex) { Debug.WriteLine($"Unexpected error: {ex.Message}"); } } public async Task ShareFile(string filename) { try { string path = await GetDocumentsPath(); string filePath = Path.Combine(path, filename); if (!File.Exists(filePath)) { Debug.WriteLine("File does not exist."); return; } // Use Xamarin.Essentials to share the file await Share.RequestAsync(new ShareFileRequest { Title = "Share File", File = new ShareFile(filePath) }); } catch (Exception ex) { Debug.WriteLine($"Error sharing file: {ex.Message}"); } } } }
public class SomeService
{
private readonly IFileHelper _fileHelper;
public SomeService()
{
_fileHelper = DependencyService.Get<IFileHelper>();
}
public async Task DoSomething()
{
string path = await _fileHelper.GetDocumentsPath();
await _fileHelper.WriteToFile("test.txt", "Hello, World!");
#if IOS
await _fileHelper.ShareFile("test.txt"); // Share the file on iOS
#endif
}
}
public interface IFileHelper
{
Task<string> GetDocumentsPath();
Task WriteToFile(string filename, string content);
Task ShareFile(string filename); // Added for iOS file sharing
}
-
Android:
namespace YourNamespace.Platforms.Android { public class FileHelperAndroid : IFileHelper { public Task<string> GetDocumentsPath() { // Save to external storage (Downloads folder) var path = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDownloads).AbsolutePath; return Task.FromResult(path); } public async Task WriteToFile(string filename, string content) { try { string path = await GetDocumentsPath(); string filePath = Path.Combine(path, filename); Debug.WriteLine($"Writing to file: {filePath}"); if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } using (StreamWriter writer = new StreamWriter(filePath, append: true)) { await writer.WriteLineAsync(content); } Debug.WriteLine($"File written successfully: {filePath}"); } catch (UnauthorizedAccessException ex) { Debug.WriteLine($"Permission error: {ex.Message}"); } catch (IOException ex) { Debug.WriteLine($"IO error: {ex.Message}"); } catch (Exception ex) { Debug.WriteLine($"Unexpected error: {ex.Message}"); } } public Task ShareFile(string filename) { // Not needed for Android since files are saved to external storage return Task.CompletedTask; } } }
-
iOS:
namespace YourNamespace.Platforms.iOS { public class FileHelperIOS : IFileHelper { public Task<string> GetDocumentsPath() { // Save to app's Documents directory var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); return Task.FromResult(documents); } public async Task WriteToFile(string filename, string content) { try { string path = await GetDocumentsPath(); string filePath = Path.Combine(path, filename); Debug.WriteLine($"Writing to file: {filePath}"); if (!Directory.Exists(path)) { Directory.CreateDirectory(path); } using (StreamWriter writer = new StreamWriter(filePath, append: true)) { await writer.WriteLineAsync(content); } Debug.WriteLine($"File written successfully: {filePath}"); } catch (UnauthorizedAccessException ex) { Debug.WriteLine($"Permission error: {ex.Message}"); } catch (IOException ex) { Debug.WriteLine($"IO error: {ex.Message}"); } catch (Exception ex) { Debug.WriteLine($"Unexpected error: {ex.Message}"); } } public async Task ShareFile(string filename) { try { string path = await GetDocumentsPath(); string filePath = Path.Combine(path, filename); if (!File.Exists(filePath)) { Debug.WriteLine("File does not exist."); return; } // Use Xamarin.Essentials to share the file await Share.RequestAsync(new ShareFileRequest { Title = "Share File", File = new ShareFile(filePath) }); } catch (Exception ex) { Debug.WriteLine($"Error sharing file: {ex.Message}"); } } } }
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");
});
#if ANDROID
builder.Services.AddSingleton<IFileHelper, FileHelperAndroid>();
#elif IOS
builder.Services.AddSingleton<IFileHelper, FileHelperIOS>();
#endif
return builder.Build();
}
}
public class SomeService
{
private readonly IFileHelper _fileHelper;
public SomeService(IFileHelper fileHelper)
{
_fileHelper = fileHelper;
}
public async Task DoSomething()
{
string path = await _fileHelper.GetDocumentsPath();
// Dictionary<string, object> model = new Dictionary<string, object>();
// model.Add("Url", "https://gist.github.com/");
// model.Add("DateTime", DateTime.Now);
// model.Add("Request", "<csharp-request-object>");
// model.Add("Response", "<csharp-response-object>");
// // model.Add("Response", JsonConvert.DeserializeObject<T>("<http-response-string>"));
// // // model.Add("Response", JsonConvert.DeserializeObject<object>("<http-response-string>"));
// System.Diagnostics.Debug.WriteLine(JsonConvert.SerializeObject(model));
// await _fileHelper.WriteToFile("test.json", JsonConvert.SerializeObject(model));
await _fileHelper.WriteToFile("test.txt", "Hello, World!");
#if IOS
await _fileHelper.ShareFile("test.txt"); // Share the file on iOS
#endif
}
}
In both Xamarin and .NET MAUI, you can now inject the IFileHelper
service into your classes and use it to perform file operations. The key difference is that Xamarin uses DependencyService
for service location, while .NET MAUI uses dependency injection through MauiProgram.cs
.
Comprehensive Guide to implement .NET MAUI service for handling notifications (toasts, push notifications, alerts, etc.)
To create a .NET MAUI service for handling notifications (toasts, push notifications, alerts, etc.), you can use dependency injection to make the service available throughout your application. Below is an example of how to implement such a service and inject it into your MAUI application.
First, define an interface for the notification service. This will allow you to easily mock or replace the service if needed.
public interface INotificationService
{
Task ShowToast(string message, ToastDuration toastDuration);
Task ShowAlertAsync(string title, string message, string cancel = "OK");
Task<bool> ShowConfirmationAsync(string title, string message, string accept = "Yes", string cancel = "No");
Task SendPushNotification(string title, string message);
Task SendLocalNotification(string title, string message);
}
Next, implement the interface. This implementation will use MAUI's built-in capabilities for toasts, alerts, and push notifications.
using Microsoft.Maui.Controls;
public class NotificationService : INotificationService
{
public async Task ShowToast(string message, ToastDuration toastDuration)
{
await CommunityToolkit.Maui.Alerts.Toast.Make(message, toastDuration).Show();
}
public async Task ShowAlertAsync(string title, string message, string cancel)
{
await Application.Current!.Windows[0].Page!.DisplayAlert(title, message, cancel);
}
public async Task<bool> ShowConfirmationAsync(string title, string message, string accept, string cancel)
{
return await Application.Current!.Windows[0].Page!.DisplayAlert(title, message, accept, cancel);
}
public async Task SendPushNotification(string title, string message)
{
// Implement push notification logic here
// This could involve using a third-party service like Firebase Cloud Messaging (FCM)
// or a platform-specific notification API.
await Task.Delay(1000);
}
public async Task SendLocalNotification(string title, string message)
{
// Implement push notification logic here
// This could involve using a third-party service like Firebase Cloud Messaging (FCM)
// or a platform-specific notification API.
await Task.Delay(1000);
}
}
In your MauiProgram.cs
, register the NotificationService
as a singleton so that it can be injected wherever needed.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls.Hosting;
using Microsoft.Maui.Hosting;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
+ builder.Services.AddTransient<MainViewModel>();
+ builder.Services.AddTransient<MainPage>();
+ builder.Services.AddSingleton<INotificationService, NotificationService>();
return builder.Build();
}
}
Now you can inject the INotificationService
into your ViewModels, Pages, or other services.
public class MainViewModel
{
private readonly INotificationService _notificationService;
public MainViewModel(INotificationService notificationService)
{
_notificationService = notificationService;
}
public async Task ShowNotification()
{
_notificationService.ShowToast("This is a toast message!");
await _notificationService.ShowAlertAsync("Alert", "This is an alert message.");
bool result = await _notificationService.ShowConfirmationAsync("Confirm", "Are you sure?");
if (result)
{
await _notificationService.SendPushNotification("Notification", "You confirmed!");
await _notificationService.SendLocalNotification("Notification", "You confirmed!");
}
}
}
Finally, bind the ViewModel to your page and use the notification service.
public partial class MainPage : ContentPage
{
public MainPage(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
private async void OnButtonClicked(object sender, EventArgs e)
{
var viewModel = BindingContext as MainViewModel;
await viewModel.ShowNotification();
}
}
If you want to use the CommunityToolkit.Maui.Alerts
for toasts, you need to add the CommunityToolkit.Maui
package to your project.
dotnet add package CommunityToolkit.Maui
Then, configure it in your MauiProgram.cs
:
using CommunityToolkit.Maui;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
+ .UseMauiCommunityToolkit() // Add this line
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<MainPage>();
builder.Services.AddSingleton<INotificationService, NotificationService>();
return builder.Build();
}
}
Accessing the IsBusy property directly within the Microsoft.Maui.Controls.Xaml.ReferenceExtension
Accessing the IsBusy property directly within the Microsoft.Maui.Controls.Xaml.ReferenceExtension or the VerticalStackLayout context is functioning as intended. This typically happens when attempting to bind to a property on the page itself with the use of {x:Reference Page}
.
To address this issue, ensure that:
- The
IsBusy
property is correctly defined in theContentPage
code-behind. - The reference to the page (
x:Reference Page
) is valid.
Ensure the x:Name
is set on your ContentPage
so it can be referenced in XAML:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="YourNamespace.YourPage"
+ x:Name="Page">
<ContentPage.Resources>
<ResourceDictionary>
<local:InverseBooleanConverter x:Key="InverseBooleanConverter" />
</ResourceDictionary>
</ContentPage.Resources>
<VerticalStackLayout IsVisible="{Binding Source={x:Reference Page}, Path=IsBusy, Converter={StaticResource InverseBooleanConverter}}">
<ActivityIndicator IsRunning="{Binding Source={x:Reference Page}, Path=IsBusy}" />
<Switch x:Name="SettingSwitch" />
</VerticalStackLayout>
</ContentPage>
In the ContentPage
code-behind, ensure the IsBusy
property is defined as follows:
public partial class YourPage : ContentPage
{
public YourPage()
{
InitializeComponent()
SettingSwitch.IsToggled = false;
SettingSwitch.Toggled += async (sender, e) => await OnSettingSwitchToggled(sender!, e);
}
private async Task OnSettingSwitchToggled(object sender, ToggledEventArgs e)
{
+ this.IsBusy = true;
await Task.Delay(1000);
Console.WriteLine($"Setting switched to: {e.Value}");
+ this.IsBusy = false;
}
- The
x:Name="Page"
on theContentPage
allows thex:Reference
binding in XAML to access theIsBusy
property. - The
IsBusy
property is implemented in the code-behind so that changes are reflected in the UI.
- Ensure that the
InverseBooleanConverter
is implemented and added to the resource dictionary. - If you still encounter binding errors, double-check that
x:Reference Page
matches thex:Name
specified on theContentPage
.
Various Documentation
Common Source Code
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
// Example 1: Combining collections using Concat (simulate spread operator)
class Example1
{
public static void Run()
{
List<int> listA = new List<int> { 1, 2 };
List<int> listB = new List<int> { 3, 4 };
var combinedList = listA.Concat(listB).ToList();
Console.WriteLine("Combined List using Concat (simulate spread operator): " + string.Join(", ", combinedList));
// Output: 1, 2, 3, 4
}
}
// Example 2: Updating a list using .Select with a return statement in a lambda block.
class Example2
{
public static void Run()
{
List<int> numbers = new List<int> { 10, 20, 30 };
var mappedList = numbers.Select(num =>
// var mappedList = numbers.Select((num, index) =>
{
// Using block syntax with an explicit return statement to create an anonymous object.
return new { Original = num, Double = num * 2 };
}).ToList();
Console.WriteLine("\nMapped List using Select with a return key:");
foreach (var item in mappedList)
{
Console.WriteLine($"Original: {item.Original}, Double: {item.Double}");
}
// Output: Anonymous objects printed for each number.
}
}
// Example 3: Chained syntax "Where().All().ToList()"
class Example3
{
public static void Run()
{
// Wrap the list into our chainable type to allow a chainable All.
List<int> numberList = new List<int> { 2, 4, 6, 8 };
// Note: The original example's `.ToList().All()` would result in a boolean for `filteredList`.
// If you intended to keep the list and then check 'All' as a separate step,
// you would assign the ToList() result to a variable first.
// For demonstration purposes, I'm showing the boolean result as implied by original chain.
bool allEven = numberList
.Where(n => n > 0) // Filter: keep numbers > 0
.All(n => n % 2 == 0); // Check: if all numbers are even
Console.WriteLine("\nResult of Where().All() chain: " + allEven);
// Output: Result of Where().All() chain: True
}
}
// Example 4: Using a lambda with fallback logic
class Example4
{
public static void Run(object mappedList)
{
string result = new Func<string>(() =>
{
try
{
// This line was commented out in the original, but uncommented for demonstration
// throw new Exception();
// Assuming mappedList is accessible and not null, otherwise "Alternate value" will be used.
// In a real scenario, you'd pass mappedList or define it within this scope.
return (mappedList as List<object>)?.FirstOrDefault()?.ToString() ?? "Alternate value";
}
catch
{
return "Alternate value";
}
})();
Console.WriteLine($"\n{result}");
// Output: Either "{ Original = 10, Double = 20 }" or "Alternate value" based on the state and exception.
}
}
// Example 5: Manually Creating the Logger
public class MyClassService
{
private readonly ILogger<MyClassService> _logger;
public MyClassService(ILogger<MyClassService> logger)
{
_logger = logger;
_logger.LogInformation("MyClassService initialized.");
}
public void DoSomething()
{
_logger.LogInformation("Doing something important.");
}
}
class Example5
{
public static void Run()
{
var loggerFactory = LoggerFactory.Create(builder =>
{
// Configure logging providers
builder.AddConsole();
// Optionally add more providers here (e.g., Debug, EventSource)
});
ILogger<MyClassService> logger = loggerFactory.CreateLogger<MyClassService>();
// ILogger<MyClassService> logger = new LoggerFactory().CreateLogger<MyClassService>(); // This would create a new factory every time
// Now you can pass this logger to your service
var myClassService = new MyClassService(logger);
myClassService.DoSomething();
}
}
class Program
{
static void Main(string[] args)
{
try
{
Example1.Run();
Example2.Run();
// For Example 4, we need to pass a list (or simulate it)
// Let's create a dummy list for Example 4 based on Example 2's output structure
List<object> dummyMappedList = new List<object>
{
new { Original = 10, Double = 20 } // Simulating an item from mappedList
};
Example4.Run(dummyMappedList);
Example3.Run(); // Changed order to make Example 3's output clearer after previous runs
Example5.Run();
}
catch (Exception ex)
{
// 1. Catch the error
Console.Error.WriteLine($"[LOG] An error occurred in PerformOperationWithLogging: {ex.Message}");
// You might log more details, like ex.StackTrace, to a file or monitoring system here.
// 2. Re-throw the original exception
// IMPORTANT: Just 'throw;' preserves the original stack trace.
// 'throw ex;' (BAD) would reset the stack trace to this point, losing origin info.
throw;
}
Console.WriteLine("--- Demonstrating Alternatives and Why .Wait() Is Bad ---");
var demo = new AsyncAlternatives();
Console.WriteLine("\n--- 1. Fire and Forget (Ignoring the Task) ---");
// Good for truly background, non-critical tasks where exceptions are handled internally.
// Bad for: No completion notification, unobserved exceptions if not handled internally.
demo.FireAndForgetCall();
Console.WriteLine("Main thread continues immediately after FireAndForgetCall.");
Task.Delay(500).Wait(); // Give async task a moment to start
Console.WriteLine("--------------------------------------------\n");
Console.WriteLine("--- 2. Task.Run() (Offloading to Thread Pool) ---");
// Good for: Moving CPU-bound work off the main thread, fire-and-forget.
// Bad for: Same unobserved exception issues as pure fire-and-forget, slight overhead.
demo.TaskRunCall();
Console.WriteLine("Main thread continues immediately after TaskRunCall.");
Task.Delay(500).Wait(); // Give async task a moment to start
Console.WriteLine("----------------------------------------------\n");
Console.WriteLine("--- 3. ContinueWith (For Completion/Error Handling) ---");
// Good for: Observing completion or handling exceptions from a fire-and-forget task without blocking.
// Bad for: Can add complexity, less readable than async/await.
demo.ContinueWithCall();
Console.WriteLine("Main thread continues immediately after ContinueWithCall.");
Task.Delay(3000).Wait(); // Give async task and continuation a moment to complete
Console.WriteLine("-----------------------------------------------\n");
Console.WriteLine("--- Why .Wait() Is Bad (and .Result too) ---");
// .Wait() blocks the calling thread, leading to unresponsiveness and deadlocks.
// It also wraps exceptions in AggregateException, making error handling clunky.
try
{
Console.WriteLine("Attempting to use .Wait() - This will block the current thread.");
demo.DoSomethingAsync().Wait(); // BAD: Blocks the Main thread
Console.WriteLine("If this were a UI app, the UI would freeze here.");
Console.WriteLine("If this were ASP.NET, a request thread would be tied up.");
}
catch (AggregateException ex)
{
Console.WriteLine($"Caught an AggregateException from .Wait(): {ex.InnerExceptions[0].Message}");
// Better error handling: foreach (var inner in ex.InnerExceptions) { Console.WriteLine(inner.Message); }
}
// DEADLOCK POTENTIAL (common in UI/ASP.NET):
// If DoSomethingAsync internally used await on a context that requires the current thread,
// and that thread is blocked by .Wait(), it would deadlock.
Console.WriteLine("\n--- Conclusion ---");
Console.WriteLine("Prefer 'await' whenever possible to enable true asynchronous, non-blocking execution.");
Console.WriteLine("Only use 'async void' for event handlers, with internal exception handling.");
Console.WriteLine("For other 'void' methods, consider fire-and-forget with internal error handling,");
Console.WriteLine("or 'ContinueWith' for observing completion/errors if not awaiting is an absolute must.");
}
class AsyncAlternatives
{
// A simulated async task for demonstrations
public async Task DoSomethingAsync()
{
Console.WriteLine(" [Async] DoSomethingAsync started.");
await Task.Delay(1000); // Simulate network call or I/O
// Uncomment the line below to see exception handling in action for different scenarios
// throw new InvalidOperationException("Simulated error in async task!");
Console.WriteLine(" [Async] DoSomethingAsync finished.");
}
// 1. Fire and Forget
public void FireAndForgetCall()
{
Console.WriteLine(" Calling DoSomethingAsync() and discarding the Task.");
_ = DoSomethingAsync(); // Use '_' discard operator for clarity
}
// 2. Task.Run()
public void TaskRunCall()
{
Console.WriteLine(" Wrapping DoSomethingAsync() in Task.Run().");
_ = Task.Run(async () => await DoSomethingAsync());
}
// 3. ContinueWith
public void ContinueWithCall()
{
Console.WriteLine(" Calling DoSomethingAsync() and attaching a ContinueWith.");
DoSomethingAsync().ContinueWith(task =>
{
if (task.IsFaulted)
{
foreach (var ex in task.Exception.InnerExceptions)
{
Console.Error.WriteLine($" [ContinueWith Error] Async task failed: {ex.Message}");
}
}
else if (task.IsCompletedSuccessfully)
{
Console.WriteLine(" [ContinueWith Success] Async task completed successfully.");
}
// Add else if (task.IsCanceled) for cancellation handling
}, TaskScheduler.Default); // Ensures continuation runs on a thread pool thread
}
}
}
Case Details Framework
To develop the .NET MAUI 9 Case Details framework, we will focus on creating a robust structure using the MVVM (Model-View-ViewModel) pattern and the MS Community Toolkit. This framework will allow users to view different case categories, their corresponding steps, and navigate to the appropriate views based on the step's status and visibility.
AI Prompt
Please create a detailed guide on developing the .NET MAUI 9 Case Details framework, which will facilitate the viewing of various case categories and their corresponding steps, utilizing MVVM and the MS Community Toolkit. Concentrate solely on the XAML views and the framework itself. I currently have a page that lists different case categories, but I require a framework that will present all the steps and details associated with each category. Each step will have its own view, so when a case category is selected, the case details view should include a header section displaying the available steps for that category. Upon selecting a step, its respective view should be displayed. For instance, if the first step is "Select Supplier," the application should navigate to the "Select Supplier" view when this step is chosen on the case details page.
Please note that each step has a status indicating whether it is not started, in progress, finished, or canceled. Only steps marked as in progress can be accessed, while those with other statuses should be locked. Additionally, each case step will have an indicator to determine its visibility and another indicator of string type to specify which view should be displayed for that particular step within the application.
Avoid using: await Shell.Current.GoToAsync(nameof(NewPage));
Instead, use: await Shell.Current.Navigation.PushAsync(new NewPage());
Also, refrain from using: public App() { InitializeComponent(); MainPage = new MainPage(); }
Instead, implement: public partial class App : Application { public App() { InitializeComponent(); }
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage());
}
}
First, let's define the models that will represent the case categories and steps.
public class CaseCategory
{
public string Name { get; set; }
public List<CaseStep> Steps { get; set; }
}
public class CaseStep
{
public string Name { get; set; }
public StepStatus Status { get; set; }
public bool IsVisible { get; set; }
public string ViewName { get; set; }
}
public enum StepStatus
{
NotStarted,
InProgress,
Finished,
Canceled
}
Next, we'll create the ViewModels that will handle the logic for displaying the case categories and steps.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
public partial class CaseDetailsViewModel : ObservableObject
{
[ObservableProperty]
private CaseCategory selectedCategory;
[ObservableProperty]
private CaseStep selectedStep;
public ObservableCollection<CaseCategory> Categories { get; set; } = new ObservableCollection<CaseCategory>();
public CaseDetailsViewModel()
{
// Initialize with sample data
Categories.Add(new CaseCategory
{
Name = "Category 1",
Steps = new List<CaseStep>
{
new CaseStep { Name = "Select Supplier", Status = StepStatus.InProgress, IsVisible = true, ViewName = "SelectSupplierView" },
new CaseStep { Name = "Review Contract", Status = StepStatus.NotStarted, IsVisible = true, ViewName = "ReviewContractView" }
}
});
// Add more categories and steps as needed
}
[RelayCommand]
private async Task NavigateToStepAsync()
{
if (SelectedStep != null && SelectedStep.Status == StepStatus.InProgress && SelectedStep.IsVisible)
{
var viewType = Type.GetType($"YourNamespace.{SelectedStep.ViewName}");
if (viewType != null)
{
var page = Activator.CreateInstance(viewType) as Page;
if (page != null)
{
await Shell.Current.Navigation.PushAsync(page);
}
}
}
}
}
Now, let's create the XAML views for displaying the case categories and steps.
This view will list all the case categories.
<?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="YourNamespace.CaseCategoriesView"
Title="Case Categories">
<CollectionView ItemsSource="{Binding Categories}"
SelectionMode="Single"
SelectionChanged="OnCategorySelected">
<CollectionView.ItemTemplate>
<DataTemplate>
<Label Text="{Binding Name}" FontSize="Large" Padding="10" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</ContentPage>
In the CaseCategoriesView.xaml.cs
, we will handle the SelectionChanged
event to navigate to the CaseDetailsView
and pass the selected CaseCategory
as a parameter.
using Microsoft.Maui.Controls;
namespace YourNamespace
{
public partial class CaseCategoriesView : ContentPage
{
public CaseCategoriesView()
{
InitializeComponent();
BindingContext = new CaseDetailsViewModel(); // Set the ViewModel
}
private async Task OnCategorySelected(object sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is CaseCategory selectedCategory)
{
// Navigate to the CaseDetailsView and pass the selected category
var caseDetailsView = new CaseDetailsView
{
BindingContext = new CaseDetailsViewModel { SelectedCategory = selectedCategory }
};
await Shell.Current.Navigation.PushAsync(caseDetailsView);
}
// Deselect the item
((CollectionView)sender).SelectedItem = null;
}
}
}
This view will display the steps for the selected case category.
<?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="YourNamespace.CaseDetailsView"
Title="Case Details">
<StackLayout>
<Label Text="{Binding SelectedCategory.Name}" FontSize="Large" Padding="10" />
<CollectionView ItemsSource="{Binding SelectedCategory.Steps}" SelectionMode="Single" SelectedItem="{Binding SelectedStep}">
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout>
<Label Text="{Binding Name}" FontSize="Medium" Padding="10" />
<Label Text="{Binding Status}" FontSize="Small" Padding="10" />
<Button Text="Go to Step"
Command="{Binding Source={RelativeSource AncestorType={x:Type local:CaseDetailsViewModel}}, Path=NavigateToStepCommand}"
IsEnabled="{Binding Status, Converter={StaticResource StepStatusToBoolConverter}}" />
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</StackLayout>
</ContentPage>
For each step, create a corresponding view. For example, SelectSupplierView.xaml
and ReviewContractView.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="YourNamespace.SelectSupplierView"
Title="Select Supplier">
<StackLayout>
<Label Text="Select Supplier View" FontSize="Large" Padding="10" />
<!-- Add more UI elements as needed -->
</StackLayout>
</ContentPage>
<?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="YourNamespace.ReviewContractView"
Title="Review Contract">
<StackLayout>
<Label Text="Review Contract View" FontSize="Large" Padding="10" />
<!-- Add more UI elements as needed -->
</StackLayout>
</ContentPage>
Create a converter to determine if a step is accessible based on its status.
using System.Globalization;
public class StepStatusToBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is StepStatus status)
{
return status == StepStatus.InProgress;
}
return false;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Register the converter in your App.xaml
or the specific page where it's needed.
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="YourNamespace.App">
<Application.Resources>
<ResourceDictionary>
<local:StepStatusToBoolConverter x:Key="StepStatusToBoolConverter" />
</ResourceDictionary>
</Application.Resources>
</Application>
Ensure that your App.xaml.cs
is set up correctly for navigation.
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new NavigationPage(new CaseCategoriesView()));
}
}
Finally, set the binding context for your views in the code-behind.
public partial class CaseCategoriesView : ContentPage
{
public CaseCategoriesView()
{
InitializeComponent();
BindingContext = new CaseDetailsViewModel();
}
}
public partial class CaseDetailsView : ContentPage
{
public CaseDetailsView()
{
InitializeComponent();
BindingContext = new CaseDetailsViewModel();
}
}
This framework provides a structured approach to managing case categories and their steps in a .NET MAUI application. By leveraging the MVVM pattern and the MS Community Toolkit, you can ensure a clean separation of concerns and maintainable code. The XAML views are designed to be intuitive and user-friendly, allowing users to navigate through case steps seamlessly.
Wizard (Step-wise Forms / MultiStep Form) Framework Implementation
Alex - Maui Wizard Demo Redth - Building a Step-by-Step / Wizard Control in .NET MAUI
This guide provides a detailed explanation of the MAUI Wizard implementation, which demonstrates how to create a multi-step wizard interface in .NET MAUI. The wizard allows users to navigate through different content pages with smooth animations and maintains state between steps.
The implementation consists of several key components:
- MauiProgram.cs - Sets up the MAUI application and dependency injection
- WizardStepsControl - The core control that manages wizard navigation and animation
- WizardView - A base class for wizard step content views
- MainViewModel - Provides data and state management for the wizard
- MainPage - The host page that contains the wizard and navigation buttons
- Content Pages - Sample wizard steps (NewContent1, NewContent2, NewContent3)
The entry point for the MAUI application configuration:
using Microsoft.Extensions.Logging;
namespace MauiWizard
{
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");
});
#if DEBUG
builder.Logging.AddDebug();
#endif
builder.Services.AddSingleton<IPageService, PageService>();
builder.Services.AddSingleton<IShellNavigationService, ShellNavigationService>();
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<MainPage>();
builder.Services.AddTransient<NewPage2>();
return builder.Build();
}
}
}
This class:
- Creates the MAUI application builder
- Configures default fonts
- Enables debug logging in DEBUG mode
- Registers the MainViewModel and MainPage with the dependency injection container
The core control that manages wizard navigation:
namespace MauiWizard
{
public class StepChangedEventArgs(int previousStepIndex, int stepIndex) : EventArgs()
{
public int PreviousStepIndex { get; } = previousStepIndex;
public int StepIndex { get; } = stepIndex;
}
public partial class WizardStepsControl : Grid
{
public static readonly BindableProperty TitleProperty =
BindableProperty.Create(nameof(Title), typeof(string), typeof(WizardStepsControl), string.Empty);
public string Title
{
get { return (string)GetValue(TitleProperty); }
set { SetValue(TitleProperty, value); }
}
public event EventHandler<StepChangedEventArgs>? StepChanged;
public int GetCurrentIndex()
{
for (var i = 0; i < Children.Count; i++)
{
if (Children[i] is VisualElement ve && ve.IsVisible)
return i;
}
return -1;
}
public bool CanGoBack()
{
var currentIndex = GetCurrentIndex();
return currentIndex > 0;
}
public bool CanGoForward()
{
var currentIndex = GetCurrentIndex();
var nextIndex = currentIndex + 1;
return nextIndex < Children.Count;
}
private void SetCurrentTitle(IView view)
{
if (view is WizardView wizardView)
Title = wizardView.Title!;
}
protected override void OnChildAdded(Element child)
{
SetCurrentTitle(Children.FirstOrDefault()!);
if (child is VisualElement ve)
{
ve.IsVisible = false;
if (GetCurrentIndex() < 0)
{
ve.IsVisible = true;
}
}
base.OnChildAdded(child);
}
public async Task Back()
{
var c = GetCurrentIndex();
var currentIndex = c;
var nextIndex = c - 1;
if (nextIndex < 0)
return;
if (currentIndex == nextIndex)
return;
var currentView = Children[currentIndex] as VisualElement;
var nextView = Children[nextIndex] as VisualElement;
if (currentView is WizardView currentWizardView)
{
await currentWizardView.OnStepDisappearingAsync();
}
nextView!.TranslationX = -1 * this.Width;
nextView!.IsVisible = true;
await Task.WhenAll(
nextView!.TranslateTo(0, 0, 500, Easing.CubicInOut),
currentView!.TranslateTo(this.Width, 0, 500, Easing.CubicInOut));
currentView!.IsVisible = false;
currentView!.TranslationX = 0;
if (nextView is WizardView nextWizardView)
{
await nextWizardView.OnStepAppearingAsync();
}
SetCurrentTitle(nextView);
StepChanged?.Invoke(this, new StepChangedEventArgs(currentIndex, nextIndex));
}
public async Task Forward()
{
var c = GetCurrentIndex();
var currentIndex = c;
var nextIndex = c + 1;
if (nextIndex >= Children.Count)
nextIndex = 0;
if (currentIndex == nextIndex)
return;
var currentView = Children[currentIndex] as VisualElement;
var nextView = Children[nextIndex] as VisualElement;
if (currentView is WizardView currentWizardView)
{
await currentWizardView.OnStepDisappearingAsync();
}
nextView!.TranslationX = this.Width;
nextView!.IsVisible = true;
await Task.WhenAll(
nextView!.TranslateTo(0, 0, 500, Easing.CubicInOut),
currentView!.TranslateTo(-1 * this.Width, 0, 500, Easing.CubicInOut));
currentView!.IsVisible = false;
currentView!.TranslationX = 0;
if (nextView is WizardView nextWizardView)
{
await nextWizardView.OnStepAppearingAsync();
}
SetCurrentTitle(nextView);
StepChanged?.Invoke(this, new StepChangedEventArgs(currentIndex, nextIndex));
}
public async Task GoToFirstPage()
{
var currentIndex = GetCurrentIndex();
if (currentIndex <= 0) return;
var firstPageIndex = 0;
await GoToPage(firstPageIndex);
}
public async Task GoToPage(int pageIndex)
{
if (pageIndex < 0 || pageIndex >= Children.Count)
throw new ArgumentOutOfRangeException(nameof(pageIndex), "Page index is out of range");
var currentIndex = GetCurrentIndex();
if (currentIndex == pageIndex) return;
var currentView = Children[currentIndex] as VisualElement;
var nextView = Children[pageIndex] as VisualElement;
if (currentView is WizardView currentWizardView)
{
await currentWizardView.OnStepDisappearingAsync();
}
nextView!.TranslationX = pageIndex > currentIndex ? this.Width : -1 * this.Width;
nextView!.IsVisible = true;
await Task.WhenAll(
nextView!.TranslateTo(0, 0, 500, Easing.CubicInOut),
currentView!.TranslateTo(
pageIndex > currentIndex ? -1 * this.Width : this.Width,
0,
500,
Easing.CubicInOut));
currentView!.IsVisible = false;
currentView!.TranslationX = 0;
if (nextView is WizardView nextWizardView)
{
await nextWizardView.OnStepAppearingAsync();
}
SetCurrentTitle(nextView);
StepChanged?.Invoke(this, new StepChangedEventArgs(currentIndex, pageIndex));
}
}
}
Key features:
- Inherits from
Grid
to host multiple wizard steps - Manages navigation between steps with
Back()
andForward()
methods - Provides smooth slide animations when transitioning between steps
- Tracks current step index and validates navigation
- Exposes
StepChanged
event to notify when steps change - Manages the wizard title based on current step
- Handles visibility of child views automatically
The base class for wizard step content:
namespace MauiWizard
{
public partial class WizardView : ContentView
{
public string? Title { get; set; }
public virtual Task OnStepAppearingAsync()
{
return Task.CompletedTask;
}
public virtual Task OnStepDisappearingAsync()
{
return Task.CompletedTask;
}
}
}
This simple class:
- Inherits from
ContentView
- Adds a
Title
property to identify each wizard step
The view model for the main page:
using System.Windows.Input;
namespace MauiWizard
{
public static class CommandExtensions
{
public static ICommand ToSafeCommand(this ICommand command)
{
return command;
}
}
}
namespace MauiWizard
{
public interface IPageService
{
Task<List<IView>> GetPagesAsync();
}
}
namespace MauiWizard
{
public class PageService : IPageService
{
Task<List<IView>> IPageService.GetPagesAsync()
{
List<IView> views = [new NewContent1(), new NewContent2(), new NewContent3()];
return Task.FromResult(views);
}
}
}
namespace MauiWizard
{
public interface IShellNavigationService
{
Task NavigateToPage(string route);
Task GoBack();
}
}
namespace MauiWizard
{
public class ShellNavigationService : IShellNavigationService
{
public async Task NavigateToPage(string route)
{
await Shell.Current.GoToAsync(route);
}
public async Task GoBack()
{
await Shell.Current.GoToAsync("..");
}
}
}
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace MauiWizard
{
public partial class MainViewModel : INotifyPropertyChanged
{
private readonly IPageService _pageService;
private readonly IShellNavigationService _shellNavigationService;
public MainViewModel(IPageService pageService, IShellNavigationService shellNavigationService)
{
_pageService = pageService;
_shellNavigationService = shellNavigationService;
BackCommand = new Command(async () =>
{
if (OnBack is not null)
await OnBack.Invoke();
}).ToSafeCommand();
ForwardCommand = new Command(async () =>
{
if (OnForward is not null)
await OnForward.Invoke();
}).ToSafeCommand();
GoToFirstPageCommand = new Command(async () =>
{
if (OnGoToFirstPage is not null)
await OnGoToFirstPage.Invoke();
}).ToSafeCommand();
GoToPageCommand = new Command<int>(async (pageIndex) =>
{
if (OnGoToPage is not null)
await OnGoToPage.Invoke(pageIndex);
}).ToSafeCommand();
ShellNavigateToPage = new Command<string>(async (route) =>
{
await _shellNavigationService.NavigateToPage(route);
}).ToSafeCommand();
}
public Func<Task>? OnBack { get; set; }
public Func<Task>? OnForward { get; set; }
public Func<Task>? OnGoToFirstPage { get; set; }
public Func<int, Task>? OnGoToPage { get; set; }
public ICommand BackCommand { get; }
public ICommand ForwardCommand { get; }
public ICommand GoToFirstPageCommand { get; }
public ICommand GoToPageCommand { get; }
public ICommand ShellNavigateToPage { get; }
private List<IView>? _views;
public List<IView>? Views
{
get => _views;
set
{
if (_views != value)
{
_views = value;
OnPropertyChanged();
}
}
}
public async Task Initialize()
{
Views = await _pageService.GetPagesAsync();
}
public async Task ShellNavigateToPageAsync(string route)
{
await _shellNavigationService.NavigateToPage(route);
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
This view model:
- Implements
INotifyPropertyChanged
for data binding - Maintains a collection of wizard steps (
Views
) - Initializes with three sample wizard steps
- Provides property change notifications
The host page for the wizard:
namespace MauiWizard
{
public partial class MainPage : ContentPage
{
public MainPage(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (BindingContext is MainViewModel viewModel)
{
await viewModel.Initialize();
StepControl.Children.Clear();
foreach (IView view in viewModel.Views!)
{
StepControl.Add(view);
}
viewModel.OnBack = async () => await StepControl.Back();
viewModel.OnForward = async () => await StepControl.Forward();
viewModel.OnGoToFirstPage = async () => await StepControl.GoToFirstPage();
viewModel.OnGoToPage = async (pageIndex) => await StepControl.GoToPage(pageIndex);
Title = StepControl.Title;
BackButton.IsEnabled = StepControl.CanGoBack();
ForwardButton.IsEnabled = StepControl.CanGoForward();
StepControl.StepChanged += StepControl_StepChanged!;
//await Dispatcher.DispatchAsync(async () =>
//{
if (StepControl.Children.FirstOrDefault() is WizardView initialView)
{
await initialView.OnStepAppearingAsync();
}
//});
}
}
protected override void OnDisappearing()
{
base.OnDisappearing();
StepControl.StepChanged -= StepControl_StepChanged!;
}
private void StepControl_StepChanged(object sender, StepChangedEventArgs e)
{
Title = StepControl.Title;
BackButton.IsEnabled = StepControl.CanGoBack();
ForwardButton.IsEnabled = StepControl.CanGoForward();
}
}
}
The MainPage:
- Hosts the
WizardStepsControl
- Adds all wizard steps from the view model
- Manages navigation button states
- Handles navigation button clicks
- Updates the page title based on current step
The UI definition for the main page:
<?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:local="clr-namespace:MauiWizard"
x:DataType="local:MainViewModel"
x:Class="MauiWizard.MainPage">
<Grid Padding="20">
<ScrollView>
<local:WizardStepsControl x:Name="StepControl" x:FieldModifier="public">
<!--<local:NewContent1 />
<local:NewContent2 />
<local:NewContent3 />-->
</local:WizardStepsControl>
</ScrollView>
<Grid VerticalOptions="End">
<Button x:Name="BackButton"
Text="Back"
HeightRequest="20"
FontAttributes="Bold"
HorizontalOptions="Start"
Command="{Binding BackCommand}" />
<Button x:Name="ForwardButton"
Text="Next"
HeightRequest="20"
FontAttributes="Bold"
HorizontalOptions="End"
Command="{Binding ForwardCommand}" />
</Grid>
</Grid>
</ContentPage>
This XAML:
- Contains a
WizardStepsControl
in aScrollView
- Includes navigation buttons at the bottom
- Uses a
Grid
layout for proper positioning - Comments show alternative static content approach
Three sample wizard step implementations:
using System.Diagnostics;
namespace MauiWizard;
public partial class NewContent1 : WizardView
{
public NewContent1()
{
InitializeComponent();
btnGoTo3rdView.Clicked += async (sender, e) => await GotTo3rdView(sender!, e);
}
public override async Task OnStepAppearingAsync()
{
await base.OnStepAppearingAsync();
// Your async initialization code here
await Task.Delay(500); // Simulate async work
Debug.WriteLine("NewContent1 is now appearing!");
// You can update UI here too
await MainThread.InvokeOnMainThreadAsync(async () =>
{
await Task.Delay(0);
// Update UI elements
//await Dispatcher.DispatchAsync(async () =>
//{
// await Shell.Current.CurrentPage.DisplayAlert("Test", "From 1", "Okay");
//});
});
}
public override async Task OnStepDisappearingAsync()
{
await base.OnStepDisappearingAsync();
// Cleanup code here
Debug.WriteLine("NewContent1 is disappearing!");
}
private async Task GotTo3rdView(object sender, EventArgs e)
{
if (BindingContext is MainViewModel viewModel)
{
if (viewModel.OnGoToPage != null)
await viewModel.OnGoToPage.Invoke(2);
}
}
}
<?xml version="1.0" encoding="utf-8" ?>
<local:WizardView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:MauiWizard"
x:Class="MauiWizard.NewContent1"
Title="NewContent1">
<VerticalStackLayout>
<Label
Text="Welcome to .NET MAUI 1!"
VerticalOptions="Center"
HorizontalOptions="Center" />
<Button
x:Name="btnGoTo3rdView"
Text="Go to 3rd View" />
<Button
Text="Go to Content Page 2"
Command="{Binding ShellNavigateToPage}"
CommandParameter="//Page2" />
</VerticalStackLayout>
</local:WizardView>
using System.Diagnostics;
namespace MauiWizard;
public partial class NewContent2 : WizardView
{
public NewContent2()
{
InitializeComponent();
}
public override async Task OnStepAppearingAsync()
{
await base.OnStepAppearingAsync();
// Your async initialization code here
await Task.Delay(500); // Simulate async work
Debug.WriteLine("NewContent2 is now appearing!");
// You can update UI here too
await MainThread.InvokeOnMainThreadAsync(async () =>
{
// Update UI elements
await Shell.Current.CurrentPage.DisplayAlert("Test", "From 2", "Okay");
});
}
public override async Task OnStepDisappearingAsync()
{
await base.OnStepDisappearingAsync();
// Cleanup code here
Debug.WriteLine("NewContent2 is disappearing!");
}
}
<?xml version="1.0" encoding="utf-8" ?>
<local:WizardView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:MauiWizard"
x:Class="MauiWizard.NewContent2"
Title="NewContent2">
<VerticalStackLayout>
<Label
Text="Welcome to .NET MAUI 2!"
VerticalOptions="Center"
HorizontalOptions="Center" />
</VerticalStackLayout>
</local:WizardView>
using System.Diagnostics;
namespace MauiWizard;
public partial class NewContent3 : WizardView
{
public NewContent3()
{
InitializeComponent();
btnGoTo1stView.Clicked += async (sender, e) => await GotTo1stView(sender!, e);
}
public override async Task OnStepAppearingAsync()
{
await base.OnStepAppearingAsync();
// Your async initialization code here
await Task.Delay(500); // Simulate async work
Debug.WriteLine("NewContent3 is now appearing!");
// You can update UI here too
await MainThread.InvokeOnMainThreadAsync(async () =>
{
// Update UI elements
await Shell.Current.CurrentPage.DisplayAlert("Test", "From 3", "Okay");
});
}
public override async Task OnStepDisappearingAsync()
{
await base.OnStepDisappearingAsync();
// Cleanup code here
Debug.WriteLine("NewContent3 is disappearing!");
}
private async Task GotTo1stView(object sender, EventArgs e)
{
if (BindingContext is MainViewModel viewModel)
{
if (viewModel.OnGoToFirstPage != null)
await viewModel.OnGoToFirstPage.Invoke();
}
}
}
<?xml version="1.0" encoding="utf-8" ?>
<local:WizardView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:MauiWizard"
x:Class="MauiWizard.NewContent3"
Title="NewConten3">
<VerticalStackLayout>
<Label
Text="Welcome to .NET MAUI 3!"
VerticalOptions="Center"
HorizontalOptions="Center" />
<Button
x:Name="btnGoTo1stView"
Text="Go to 1st View" />
</VerticalStackLayout>
</local:WizardView>
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="MauiWizard.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:MauiWizard"
Shell.FlyoutBehavior="Flyout"
Title="MauiWizard">
<Shell.FlyoutHeaderTemplate>
<DataTemplate>
<Grid HeightRequest="200">
<Label Text="Maui Wizard App"
FontAttributes="Bold"
HorizontalTextAlignment="Center"
VerticalTextAlignment="Center" />
</Grid>
</DataTemplate>
</Shell.FlyoutHeaderTemplate>
<Shell.FlyoutFooterTemplate>
<DataTemplate>
<StackLayout>
<Label Text="Maui Wizard"
FontAttributes="Bold"
HorizontalOptions="Center" />
</StackLayout>
</DataTemplate>
</Shell.FlyoutFooterTemplate>
<FlyoutItem Route="App"
FlyoutDisplayOptions="AsMultipleItems">
<Tab Title="Home"
Route="Home">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="Page0" />
</Tab>
<ShellContent Route="Page1"
Title="New Page 1"
ContentTemplate="{DataTemplate local:NewPage1}" />
<ShellContent Route="Page2"
Title="New Page 2"
ContentTemplate="{DataTemplate local:NewPage2}" />
</FlyoutItem>
</Shell>
namespace MauiWizard
{
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
//Navigated += OnShellNavigated!;
}
/*private void OnShellNavigated(object sender, ShellNavigatedEventArgs e)
{
if (e.Current?.Location?.OriginalString == "//App/Home/Page0")
{
Current.CurrentItem = Current.Items[0];
}
}*/
}
}
<?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="MauiWizard.NewPage1"
Title="NewPage1">
<VerticalStackLayout>
<Label
Text="Welcome to .NET MAUI Page 1!"
VerticalOptions="Center"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ContentPage>
namespace MauiWizard;
public partial class NewPage2 : ContentPage
{
public NewPage2(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
<?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:local="clr-namespace:MauiWizard"
x:DataType="local:MainViewModel"
x:Class="MauiWizard.NewPage2"
Title="NewPage2">
<VerticalStackLayout>
<Label
Text="Welcome to .NET MAUI Page 2!"
VerticalOptions="Center"
HorizontalOptions="Center" />
<Button
Text="Go to Content Main Page"
Command="{Binding ShellNavigateToPage}"
CommandParameter="//App/Home/Page0" />
</VerticalStackLayout>
</ContentPage>
These content pages:
- Inherit from
WizardView
- Define simple content for each wizard step
- Each has a unique title
- Use
VerticalStackLayout
for simple content arrangement
-
Navigation Animation:
- The
WizardStepsControl
implements smooth slide animations when moving between steps - Uses
TranslateTo
for animating views horizontally - Manages view visibility during transitions
- The
-
State Management:
- Tracks current step index
- Validates navigation (can go back/forward)
- Updates UI state (button enabled states) accordingly
-
Event Handling:
- Provides
StepChanged
event to notify consumers of navigation - Includes previous and current step indices in event args
- Provides
-
Flexible Content:
- Supports adding wizard steps either in XAML or code-behind
- Content pages are simple to create by inheriting from
WizardView
-
Responsive Design:
- Uses MAUI layout controls for proper sizing and positioning
- Includes a
ScrollView
for content that might exceed screen size
-
Adding Wizard Steps:
- Steps can be added in XAML (commented out in MainPage.xaml)
- Or programmatically via view model (current implementation)
-
Navigation Control:
- Call
Forward()
orBack()
to navigate - Check
CanGoForward()
orCanGoBack()
to validate navigation
- Call
-
Customizing Content:
- Create new classes inheriting from
WizardView
- Add any MAUI controls as content
- Set the
Title
property to identify the step
- Create new classes inheriting from
-
Responding to Changes:
- Subscribe to
StepChanged
event to react to navigation - Update UI based on current step
- Subscribe to
-
Custom Animations:
- Modify the
Forward()
andBack()
methods to implement different animations - Adjust duration or easing functions
- Modify the
-
Additional Navigation Controls:
- Add a step progress indicator
- Implement direct navigation to specific steps
-
Validation:
- Add step validation before allowing navigation
- Show validation errors
-
Data Persistence:
- Maintain state across wizard steps
- Collect data from all steps upon completion
This implementation provides a solid foundation for building wizard-style interfaces in .NET MAUI with smooth animations and clean separation of concerns. The component-based design makes it easy to extend and customize for specific application requirements.
Scanning and Decoding South African License Disc Barcodes with ZXing.Net.Maui
This guide provides a complete implementation for scanning and decoding South African vehicle license disc barcodes (PDF-417 format) in .NET MAUI applications using ZXing.Net.Maui.
dotnet add package ZXing.Net.Maui
dotnet add package ZXing.Net.Maui.Controls
using ZXing.Net.Maui.Controls;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseBarcodeReader(); // Initialize ZXing.Net.Maui
return builder.Build();
}
}
public class RSALicenseDisc
{
public string? DocumentType { get; set; }
public string? UnknownField1 { get; set; }
public string? LicenseNumber { get; set; }
public string? UnknownField2 { get; set; }
public string? ControlNumber { get; set; }
public string? LicenceNumber { get; set; }
public string? VehicleRegistrationNumber { get; set; }
public string? VehicleDescription { get; set; }
public string? Make { get; set; }
public string? Model { get; set; }
public string? Color { get; set; }
public string? VIN { get; set; }
public string? EngineNumber { get; set; }
public DateTime? ExpiryDate { get; set; }
}
public partial class BarCodeScanPage : ContentPage
{
private TaskCompletionSource<RSALicenseDisc>? _taskCompletionSource;
public BarCodeScanPage(TaskCompletionSource<RSALicenseDisc> taskCompletionSource)
{
InitializeComponent();
_taskCompletionSource = taskCompletionSource;
CameraBarcodeReaderView.BarcodesDetected += async (sender, e) => await OnBarcodesDetected(sender!, e);
CameraBarcodeReaderView.Options = new BarcodeReaderOptions
{
Formats = BarcodeFormats.TwoDimensional, // Focus on PDF-417 format
AutoRotate = true,
Multiple = true,
TryHarder = true
};
}
protected override void OnAppearing()
{
base.OnAppearing();
CameraBarcodeReaderView.IsDetecting = true;
}
protected override void OnDisappearing()
{
CameraBarcodeReaderView.IsDetecting = false;
CameraBarcodeReaderView.BarcodesDetected -= async (sender, e) => await OnBarcodesDetected(sender!, e);
base.OnDisappearing();
}
private async Task OnBarcodesDetected(object sender, BarcodeDetectionEventArgs e)
{
try
{
RSALicenseDisc rsaLicenseDisc = new();
foreach (var barcode in e.Results)
{
if (barcode.Format == BarcodeFormat.Pdf417)
{
rsaLicenseDisc = await ParseRSALicenseDisc(barcode.Value);
if (IsValidLicenseDisc(rsaLicenseDisc))
{
break;
}
}
}
_taskCompletionSource?.TrySetResult(rsaLicenseDisc);
await Navigation.PopAsync();
}
catch (Exception ex)
{
await DisplayAlert("Error", ex.Message, "OK");
}
}
private bool IsValidLicenseDisc(RSALicenseDisc disc)
{
return !string.IsNullOrEmpty(disc.VehicleRegistrationNumber) &&
!string.IsNullOrEmpty(disc.Make) &&
!string.IsNullOrEmpty(disc.Color) &&
disc.ExpiryDate > DateTime.MinValue;
}
private async Task<RSALicenseDisc> ParseRSALicenseDisc(string barcodeData)
{
try
{
var result = new RSALicenseDisc();
var segments = barcodeData.Split('%')
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();
if (segments.Length >= 14)
{
result.DocumentType = segments[0];
result.UnknownField1 = segments[1];
result.LicenseNumber = segments[2];
result.UnknownField2 = segments[3];
result.ControlNumber = segments[4];
result.LicenceNumber = segments[5];
result.VehicleRegistrationNumber = segments[6];
result.VehicleDescription = segments[7];
result.Make = segments[8];
result.Model = segments[9];
result.Color = segments[10];
result.VIN = segments[11];
result.EngineNumber = segments[12];
if (DateTime.TryParseExact(segments[13], "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var expiryDate))
{
result.ExpiryDate = expiryDate;
}
}
return result;
}
catch (Exception ex)
{
await DisplayAlert("Error", ex.Message, "OK");
return new RSALicenseDisc();
}
}
}
<?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:zxing="clr-namespace:ZXing.Net.Maui.Controls;assembly=ZXing.Net.Maui"
x:Class="YourNamespace.BarCodeScanPage"
Title="Scan License Disc">
<Grid>
<zxing:CameraBarcodeReaderView x:Name="CameraBarcodeReaderView"
IsDetecting="True" />
<Grid HorizontalOptions="Center"
VerticalOptions="Center"
WidthRequest="300"
HeightRequest="150">
<BoxView Color="Red"
HeightRequest="2"
VerticalOptions="Start"
HorizontalOptions="Fill"/>
<BoxView Color="Red"
WidthRequest="2"
VerticalOptions="Fill"
HorizontalOptions="Start"/>
<BoxView Color="Red"
WidthRequest="2"
VerticalOptions="Fill"
HorizontalOptions="End"/>
<BoxView Color="Red"
HeightRequest="2"
VerticalOptions="End"
HorizontalOptions="Fill"/>
</Grid>
</Grid>
</ContentPage>
[RelayCommand]
public async Task ScanBarCode()
{
try
{
LicenseDiscDetails = await GetBarCodeValues();
// Process the scanned data
if (!string.IsNullOrEmpty(LicenseDiscDetails?.VehicleRegistrationNumber))
{
// Update UI or process the data further
}
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert("Error", ex.Message, "OK");
}
}
public async Task<RSALicenseDisc> GetBarCodeValues()
{
try
{
var taskCompletionSource = new TaskCompletionSource<RSALicenseDisc>();
await Shell.Current.Navigation.PushAsync(new BarCodeScanPage(taskCompletionSource));
return await taskCompletionSource.Task;
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert("Error", ex.Message, "OK");
return new RSALicenseDisc();
}
}
Add to AndroidManifest.xml
:
<uses-permission android:name="android.permission.CAMERA" />
Add to Info.plist
:
<key>NSCameraUsageDescription</key>
<string>Camera access is required to scan license disc barcodes</string>
- Targeted Scanning: Focuses specifically on PDF-417 barcode format used by SA license discs
- Structured Data: Parses barcode data into a strongly-typed
RSALicenseDisc
object - Validation: Includes validation to ensure essential fields are present
- Async Pattern: Uses
TaskCompletionSource
for clean async integration - Error Handling: Comprehensive error handling throughout the scanning process
South African license disc barcodes typically contain:
- Vehicle registration number
- License disc number
- Vehicle make and model
- Color
- VIN and engine numbers
- Expiry date
- Additional control numbers and document identifiers
The data is separated by percentage signs (%) with the following structure:
%DocumentType%Unknown1%LicenseNum%Unknown2%ControlNum%LicenceNum%RegNum%Description%Make%Model%Color%VIN%EngineNum%ExpiryDate%
This implementation provides a complete solution for scanning and processing South African vehicle license discs in .NET MAUI applications.
.NET MAUI File Helper for reducing file sizes and converting PNG images to JPEG format.
using SkiaSharp;
using Microsoft.Maui.Graphics.Platform;
namespace Maui.Helpers
{
public static class FileHelper
{
public static async Task<byte[]?> DownSizeFileStream(FileResult? file)
{
try
{
if (file == null)
return null;
byte[]? result = null;
using (Stream stream = await file.OpenReadAsync())
{
if (stream.Length > Settings.FileMaxSizeBytes)
{
Microsoft.Maui.Graphics.IImage image = PlatformImage.FromStream(stream);
if (image != null && image.Width > Settings.FileMaxWidthResolution && image.Height > Settings.FileMaxHeightResolution)
{
Microsoft.Maui.Graphics.IImage newImage = image.Downsize(Settings.FileMaxWidthResolution, Settings.FileMaxHeightResolution, false);
result = await newImage.AsBytesAsync();
}
else
{
result = await image.AsBytesAsync();
}
}
else
{
result = await PlatformImage.FromStream(stream).AsBytesAsync();
}
}
return result;
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
return null;
}
}
public static async Task<byte[]?> DownSizeFileStream(Stream? stream)
{
try
{
if (stream == null)
return null;
byte[]? result = null;
using (stream)
{
if (stream.Length > Settings.FileMaxSizeBytes)
{
Microsoft.Maui.Graphics.IImage image = PlatformImage.FromStream(stream);
if (image != null && image.Width > Settings.FileMaxWidthResolution && image.Height > Settings.FileMaxHeightResolution)
{
Microsoft.Maui.Graphics.IImage newImage = image.Downsize(Settings.FileMaxWidthResolution, Settings.FileMaxHeightResolution, false);
result = await newImage.AsBytesAsync();
}
else
{
result = await image.AsBytesAsync();
}
}
else
{
result = await PlatformImage.FromStream(stream).AsBytesAsync();
}
}
return result;
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
return null;
}
}
public static async Task<byte[]?> ConvertPngToJpegByteArray(Stream pngStream, float quality = 1f)
{
try
{
if (pngStream == null || pngStream.Length == 0)
return null;
Microsoft.Maui.Graphics.IImage image = PlatformImage.FromStream(pngStream);
if (image == null)
return null;
MemoryStream jpegStream = new MemoryStream();
await image.SaveAsync(jpegStream, ImageFormat.Jpeg, quality: quality);
jpegStream.Position = 0;
return jpegStream.ToArray();
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
return null;
}
}
public static async Task<MemoryStream?> ConvertPngToJpegStream(Stream pngStream, float quality = 1f)
{
try
{
if (pngStream == null || pngStream.Length == 0)
return null;
Microsoft.Maui.Graphics.IImage image = PlatformImage.FromStream(pngStream);
if (image == null)
return null;
MemoryStream jpegStream = new MemoryStream();
await image.SaveAsync(jpegStream, ImageFormat.Jpeg, quality: quality);
return jpegStream;
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
return null;
}
}
public static async Task<byte[]?> ConvertPngToJpeg(FileResult? pngFile, float quality = 1f)
{
try
{
if (pngFile == null)
return null;
Stream stream = await pngFile.OpenReadAsync();
return await ConvertPngToJpegByteArray(stream, quality);
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
return null;
}
}
public static async Task<MemoryStream?> DrawTextOnImageAsync(FileResult? imageFile, string[] textLines)
{
try
{
if (imageFile == null)
return null;
using var stream = await imageFile.OpenReadAsync();
using var originalBitmap = SKBitmap.Decode(stream);
if (originalBitmap == null)
return null;
int width = originalBitmap.Width;
int height = originalBitmap.Height;
var imageInfo = new SKImageInfo(width, height);
using var surface = SKSurface.Create(imageInfo);
SKCanvas canvas = surface.Canvas;
canvas.Clear(SKColors.White);
canvas.DrawBitmap(originalBitmap, 0, 0);
float fontSize = 14f;
float margin = 20f;
float lineSpacing = 4f;
using var font = new SKFont(SKTypeface.Default, fontSize)
{
Edging = SKFontEdging.Antialias
};
using var fillPaint = new SKPaint
{
Color = SKColors.White,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
using var strokePaint = new SKPaint
{
Color = SKColors.Black,
IsAntialias = true,
Style = SKPaintStyle.Stroke,
StrokeWidth = 2
};
float textY = height - margin;
for (int i = textLines.Length - 1; i >= 0; i--)
{
string line = textLines[i];
using var blob = SKTextBlob.Create(line, font);
var bounds = blob!.Bounds;
float textX = width - margin;
textY -= bounds.Height + lineSpacing;
canvas.DrawText(line, textX, textY, SKTextAlign.Right, font, strokePaint);
canvas.DrawText(line, textX, textY, SKTextAlign.Right, font, fillPaint);
}
canvas.Flush();
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
if (Settings.MediaSetting.Equals(true))
{
var fileName = imageFile.FileName;
var filePath = Path.Combine(FileSystem.Current.AppDataDirectory, fileName);
using (var fileStream = File.OpenWrite(filePath))
{
data.SaveTo(fileStream);
}
}
var ms = new MemoryStream();
data.SaveTo(ms);
ms.Seek(0, SeekOrigin.Begin);
return ms;
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
return null;
}
}
public static async Task<MemoryStream?> DrawTextOnStreamAsync(Stream? stream, string[] textLines, string fileName = "")
{
try
{
if (stream == null)
return null;
using var originalBitmap = SKBitmap.Decode(stream);
if (originalBitmap == null)
return null;
int width = originalBitmap.Width;
int height = originalBitmap.Height;
var imageInfo = new SKImageInfo(width, height);
using var surface = SKSurface.Create(imageInfo);
SKCanvas canvas = surface.Canvas;
canvas.Clear(SKColors.White);
canvas.DrawBitmap(originalBitmap, 0, 0);
float fontSize = 14f;
float margin = 20f;
float lineSpacing = 4f;
using var font = new SKFont(SKTypeface.Default, fontSize)
{
Edging = SKFontEdging.Antialias
};
using var fillPaint = new SKPaint
{
Color = SKColors.White,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
using var strokePaint = new SKPaint
{
Color = SKColors.Black,
IsAntialias = true,
Style = SKPaintStyle.Stroke,
StrokeWidth = 2
};
float textY = height - margin;
for (int i = textLines.Length - 1; i >= 0; i--)
{
string line = textLines[i];
using var blob = SKTextBlob.Create(line, font);
var bounds = blob!.Bounds;
float textX = width - margin;
textY -= bounds.Height + lineSpacing;
canvas.DrawText(line, textX, textY, SKTextAlign.Right, font, strokePaint);
canvas.DrawText(line, textX, textY, SKTextAlign.Right, font, fillPaint);
}
canvas.Flush();
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
if (Settings.MediaSetting.Equals(true) && string.IsNullOrEmpty(fileName).Equals(false))
{
var filePath = Path.Combine(FileSystem.Current.AppDataDirectory, fileName);
using (var fileStream = File.OpenWrite(filePath))
{
data.SaveTo(fileStream);
}
}
var ms = new MemoryStream();
data.SaveTo(ms);
ms.Seek(0, SeekOrigin.Begin);
return ms;
}
catch (Exception ex)
{
await Shell.Current.CurrentPage.DisplayAlert(Constants.Error, ex.Message, Constants.Okay);
return null;
}
}
}
}
.NET MAUI Image Save Helper for saving image files.
using System.Diagnostics;
using System.Net.Mime;
namespace Maui.Helpers
{
public class ImageSaveHelper
{
private static string GetMimeTypeFromName(string fileName)
{
string extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".txt" => MediaTypeNames.Text.Plain,
".html" or ".htm" => MediaTypeNames.Text.Html,
".css" => "text/css",
".js" => "application/javascript",
".json" => "application/json",
".xml" => MediaTypeNames.Text.Xml,
".png" => "image/png",
".jpg" or ".jpeg" => MediaTypeNames.Image.Jpeg,
".gif" => "image/gif",
".bmp" => "image/bmp",
".tiff" or ".tif" => "image/tiff",
".svg" => "image/svg+xml",
".mp3" => "audio/mpeg",
".wav" => "audio/wav",
".mp4" => "video/mp4",
".mpeg" or ".mpg" => "video/mpeg",
".avi" => "video/x-msvideo",
".pdf" => "application/pdf",
".zip" => "application/zip",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt" => "application/vnd.ms-powerpoint",
".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
_ => "application/octet-stream",
};
}
private static string GetMimeTypeFromPath(string filePath)
{
return GetMimeTypeFromName(Path.GetFileName(filePath));
}
public static Task SaveImage(byte[] bytes, string fileName)
{
try
{
#if WINDOWS
System.IO.File.WriteAllBytes(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), fileName), bytes);
#elif ANDROID
var context = Platform.CurrentActivity;
if (OperatingSystem.IsAndroidVersionAtLeast(29))
{
Android.Content.ContentResolver resolver = context.ContentResolver;
Android.Content.ContentValues contentValues = new();
contentValues.Put(Android.Provider.MediaStore.IMediaColumns.DisplayName, fileName);
contentValues.Put(Android.Provider.MediaStore.IMediaColumns.MimeType, GetMimeTypeFromPath(fileName));
contentValues.Put(Android.Provider.MediaStore.IMediaColumns.RelativePath, string.Format("DCIM/{0}", AppInfo.Current.Name));
Android.Net.Uri imageUri = resolver.Insert(Android.Provider.MediaStore.Images.Media.ExternalContentUri, contentValues);
var os = resolver.OpenOutputStream(imageUri);
Android.Graphics.BitmapFactory.Options options = new();
options.InJustDecodeBounds = true;
MemoryStream memoryStream = new MemoryStream(bytes);
memoryStream.Position = 0;
var bitmap = Android.Graphics.BitmapFactory.DecodeStream(new MemoryStream(bytes));
bitmap.Compress(Android.Graphics.Bitmap.CompressFormat.Png, 100, os);
os.Flush();
os.Close();
}
else
{
Java.IO.File storagePath = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryPictures);
string path = System.IO.Path.Combine(storagePath.ToString(), fileName);
System.IO.File.WriteAllBytes(path, bytes);
var mediaScanIntent = new Android.Content.Intent(Android.Content.Intent.ActionMediaScannerScanFile);
mediaScanIntent.SetData(Android.Net.Uri.FromFile(new Java.IO.File(path)));
context.SendBroadcast(mediaScanIntent);
}
#elif IOS || MACCATALYST
var image = new UIKit.UIImage(Foundation.NSData.FromArray(bytes));
image.SaveToPhotosAlbum((image, error) =>
{
});
#endif
return Task.CompletedTask;
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return Task.CompletedTask;
}
}
}
}
.NET MAUI Community Toolkit Drawing View
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:mct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:viewModels="clr-namespace:.."
x:Class="clr-namespace:..">
<StackLayout Orientation="Vertical">
<Border Stroke="Black"
Background="White"
StrokeThickness="2"
HeightRequest="105"
WidthRequest="105">
<mct:DrawingView x:Name="MctDrawing"
IsMultiLineModeEnabled="True"
ShouldClearOnFinish="False"
Background="Transparent"
BackgroundColor="Transparent"
LineColor="Black"
LineWidth="3"
HeightRequest="100"
WidthRequest="100"
HorizontalOptions="Fill"
VerticalOptions="Fill" />
</Border>
<Button Text="Clear" />
<Button Text="Save"
Command="{Binding Source={RelativeSource AncestorType={x:Type viewModels:..}}, Path=SaveDrawingCommand}"
CommandParameter="{Binding Source={x:Reference MctDrawing}}">
</Button>
</StackLayout>
</ContentView>
.NET MAUI Displaying Enum Display Values.
To display the [Display(Name = "...")]
attribute values of an enum in your .NET MAUI XAML page, you'll need to create a value converter. Here's how to implement this:
First, create a converter that will transform the enum value to its display name:
using System.ComponentModel.DataAnnotations;
using System.Globalization;
public sealed class EnumDisplayConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is null)
{
return string.Empty;
}
if (!value.GetType().IsEnum)
{
return value.ToString() ?? string.Empty;
}
var fieldInfo = value.GetType().GetField(value.ToString() ?? string.Empty);
if (fieldInfo is null)
{
return value.ToString() ?? string.Empty;
}
var displayAttribute = fieldInfo
.GetCustomAttributes(typeof(DisplayAttribute), false)
.FirstOrDefault() as DisplayAttribute;
return displayAttribute?.Name ?? value.ToString() ?? string.Empty;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return Activator.CreateInstance(targetType) ?? throw new InvalidOperationException("Cannot create default enum value");
}
}
In your XAML page or App.xaml, add the converter as a resource:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:YourNamespace"
x:Class="YourNamespace.YourPage">
<ContentPage.Resources>
<ResourceDictionary>
<local:EnumDisplayConverter x:Key="EnumDisplayConverter" />
</ResourceDictionary>
</ContentPage.Resources>
<!-- Your content here -->
</ContentPage>
Now you can bind to enum values and display their display names:
<Label Text="{Binding YourStatusProperty, Converter={StaticResource EnumDisplayConverter}}" />
.NET MAUI SQLite for local data storage
- The code is structured for a .NET MAUI application, with models representing a To-Do List, its items, and modification details. It also includes interfaces for database services and their implementations.
<Project Sdk="Microsoft.NET.Sdk">
<!-- <PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net9.0-ios|AnyCPU'">
<ApplicationId>eqstra.mobility.maui</ApplicationId>
<RuntimeIdentifiers Condition="'$(TargetFramework)' == 'net9.0-ios'">iossimulator-arm64</RuntimeIdentifiers>
<SQLitePlatform Condition="'$(TargetFramework)' == 'net9.0-ios'">iOS</SQLitePlatform>
</PropertyGroup> -->
<ItemGroup>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
<!-- <PackageReference Include="SQLitePCLRaw.provider.dynamic_cdecl" Version="2.1.11" /> -->
<PackageReference Include="sqlite-net-sqlcipher" Version="1.9.172" />
<PackageReference Include="SQLiteNetCipherExtensions" Version="2.1.0" />
<PackageReference Include="SQLiteNetCipherExtensions.Async" Version="2.1.0" />
</ItemGroup>
</Project>
#nullable enable
using Newtonsoft.Json; // From NuGet: Newtonsoft.Json
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using SQLite; // From NuGet: sqlite-net-sqlcipher (SQLitePCLRaw.bundle_e_sqlcipher)
using SQLiteNetExtensions.Attributes; // From NuGet: SQLiteNetCipherExtensions and SQLiteNetCipherExtensions.Async
using SQLiteNetExtensions.Extensions; // For relationship operations
using SQLiteNetExtensionsAsync.Extensions; // For async relationship operations
using System.Linq; // For .Any() and .ToList() after Where
// using System.Reflection; // For PropertyInfo in BaseService
// To enable DEBUG logs from your services within dotnetfiddle.net.
// In a production MAUI app, this would be set via project configuration.
// /Utils/
// This class contains constants and utility definitions. In a real project, this might be in a 'Utils' or 'Common' folder.
public static class Constants
{
#region Logging Messages - Information
public static string LogInfo_EncryptionKeyGenerated = "Generated and stored new encryption key for app records.";
public static string LogInfo_EncryptionKeyRetrieved = "Retrieved existing encryption key for records.";
public static string LogInfo_Databaseinitialised = "App records database initialised successfully at {0}.";
public static string LogInfo_RecordUpdated = "Updated record: {0}";
public static string LogInfo_RecordInserted = "Inserted new record: {0}";
public static string LogInfo_RecordRetrieved = "Retrieved record: {0}";
public static string LogInfo_RecordNotFound = "Record '{0}' not found. Returning default value.";
public static string LogInfo_RecordDeleted = "Deleted record: {0}";
public static string LogInfo_RecordsRetrievedCount = "Retrieved {0} Records.";
public static string LogInfo_CreatedTable = "Created/Ensured table for type: {0}";
public static string LogInfo_ItemSaved = "Saved item of type {0}.";
public static string LogInfo_ItemSaveFailed = "Failed to save item of type {0}.";
public static string LogInfo_ItemRetrievedByKey = "Retrieved item of type {0} with key {1}.";
public static string LogInfo_ItemNotFoundByKey = "Item of type {0} with key {1} not found.";
public static string LogInfo_ItemsRetrievedCount = "Retrieved {0} items of type {1}.";
public static string LogInfo_ItemDeleted = "Deleted item of type {0}.";
public static string LogInfo_ItemDeleteFailed = "Failed to delete item of type {0}.";
public static string LogInfo_ItemDeletedByKey = "Deleted item of type {0} with key {1}.";
public static string LogInfo_ItemDeleteFailedByKey = "Failed to delete item of type {0} with key {1}.";
public static string LogInfo_ItemsRetrieved = "Retrieved {1} items of type {0}.";
public static string LogInfo_NoItemsFound = "No items found for type {0}.";
#endregion
#region Logging Messages - Warnings
public static string LogWarn_KeyNotAvailableInit = "Encryption key not available during Init(). Waiting for key initialisation.";
public static string LogWarn_DbNotAvailableSave = "Attempted to save record '{0}' but database was not available.";
public static string LogWarn_DbNotAvailableGet = "Attempted to get record '{0}' but database was not available. Returning default value.";
public static string LogWarn_DbNotAvailableDelete = "Attempted to delete record '{0}' but database was not available.";
public static string LogWarn_DbNotAvailableGetAll = "Attempted to get all settings but database was not available. Returning empty list.";
public static string LogWarn_DbNotInitialisedTableType = "DatabaseService initialised with no initial table type. Ensure table is created manually or via specific methods.";
public static string LogWarn_DbNotInitialisedTableTypes = "DatabaseService initialised with no initial table types. Ensure tables are created manually or via specific methods.";
#endregion
#region Logging Messages - Errors
public static string LogError_KeyGenerationFailed = "Failed to generate a valid encryption key.";
public static string LogError_KeyInitInvalidOperation = "Invalid operation during encryption key initialisation.";
public static string LogError_KeyInitUnexpected = "An unexpected error occurred during encryption key initialisation.";
public static string LogError_KeyInitFailedDbConnect = "Failed to initialise encryption key. Database connection cannot be established.";
public static string LogError_KeyNullOrEmptyAfterInit = "Cannot initialise database: Encryption key is null or empty after initialisation task completed.";
public static string LogError_DbInitSQLite = "SQLite error occurred while initialising AppSettings database at {0}.";
public static string LogError_DbInitUnexpected = "An unexpected error occurred during AppSettings database initialisation at {0}.";
public static string LogError_SerializationFailed = "Failed to serialize value for record '{0}'.";
public static string LogError_SaveSQLite = "SQLite error saving record '{0}'.";
public static string LogError_SaveUnexpected = "An unexpected error occurred while saving record '{0}'.";
public static string LogError_DeserializationFailed = "Failed to deserialize record '{0}'. Data might be corrupted. Returning default value.";
public static string LogError_GetSQLite = "SQLite error retrieving record '{0}'. Returning default value.";
public static string LogError_GetUnexpected = "An unexpected error occurred while retrieving record '{0}'. Returning default value.";
public static string LogError_DeleteSQLite = "SQLite error deleting record '{0}'.";
public static string LogError_DeleteUnexpected = "An unexpected error occurred while deleting record '{0}'.";
public static string LogError_GetAllSQLite = "SQLite error retrieving all settings. Returning empty list.";
public static string LogError_GetAllUnexpected = "An unexpected error occurred while retrieving all settings. Returning empty list.";
public static string LogError_SaveSQLiteGeneric = "SQLite error saving item of type {0}.";
public static string LogError_SaveUnexpectedGeneric = "An unexpected error occurred while saving item of type {0}.";
public static string LogError_GetSQLiteGeneric = "SQLite error retrieving item of type {0} with key {1}.";
public static string LogError_GetUnexpectedGeneric = "An unexpected error occurred while retrieving item of type {0} with key {1}.";
public static string LogError_GetAllSQLiteGeneric = "SQLite error retrieving all items of type {0}.";
public static string LogError_GetAllUnexpectedGeneric = "An unexpected error occurred while retrieving all items of type {0}.";
public static string LogError_DeleteSQLiteGeneric = "SQLite error deleting item of type {0}.";
public static string LogError_DeleteUnexpectedGeneric = "An unexpected error occurred while deleting item of type {0}.";
public static string LogError_DeleteSQLiteByKeyGeneric = "SQLite error deleting item of type {0} with key {1}.";
public static string LogError_DeleteUnexpectedByKeyGeneric = "An unexpected error occurred while deleting item of type {0} with key {1}.";
#endregion
public const string DatabaseFilename = "testlocaldatabase.db3";
public const SQLite.SQLiteOpenFlags Flags =
SQLite.SQLiteOpenFlags.ReadWrite |
SQLite.SQLiteOpenFlags.Create |
SQLite.SQLiteOpenFlags.FullMutex |
SQLite.SQLiteOpenFlags.SharedCache;
public static string DatabasePath => Path.Combine(FileSystem.AppDataDirectory, DatabaseFilename);
}
// /Enums/
// This enum would typically reside in an 'Enums' or 'Common' folder.
public enum SettingKeys : int
{
SettingsDatabaseKey = 0
}
// Mock SecureStorage for console app environment.
// In a MAUI application, this would use Xamarin.Essentials.SecureStorage.
public static class SecureStorage
{
private static readonly Dictionary<string, string> _storage = new Dictionary<string, string>();
public static Task<string> GetAsync(string key)
{
_storage.TryGetValue(key, out string? value);
return Task.FromResult(value!); // Use null-forgiving operator as TryGetValue sets value to null if key not found
}
public static Task SetAsync(string key, string value)
{
_storage[key] = value;
return Task.CompletedTask;
}
}
// Mock FileSystem for console app environment.
// In a MAUI application, this would use Microsoft.Maui.Storage.FileSystem.
public static class FileSystem
{
public static string AppDataDirectory
{
get
{
// Use a temporary path for dotnetfiddle.net as it's a sandboxed environment.
// Ensure the directory exists.
string path = Path.Combine(Path.GetTempPath(), "MyAppData");
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
return path;
}
}
}
// Mock ILogger interface.
// In a real application, this would be from Microsoft.Extensions.Logging.
public interface ILogger
{
void LogInformation(string message, params object[] args);
void LogWarning(string message, params object[] args);
void LogError(Exception exception, string message, params object[] args);
void LogError(string message, params object[] args);
}
// Mock ILogger<T> interface.
// In a real application, this would be from Microsoft.Extensions.Logging.
public interface ILogger<T> : ILogger { }
// Simple ConsoleLogger implementation for testing purposes.
// Logs messages to the console with the type name.
public class ConsoleLogger<T> : ILogger<T>
{
public void LogInformation(string message, params object[] args)
{
Console.WriteLine($"INFO [{typeof(T).Name}]: {string.Format(message, args)}");
}
public void LogWarning(string message, params object[] args)
{
Console.WriteLine($"WARN [{typeof(T).Name}]: {string.Format(message, args)}");
}
public void LogError(Exception? exception, string message, params object[] args)
{
Console.Error.WriteLine($"ERROR [{typeof(T).Name}]: {string.Format(message, args)}\n{(exception != null ? $"Exception: {exception.Message}\nStackTrace: {exception.StackTrace}" : "")}");
}
public void LogError(string message, params object[] args)
{
Console.Error.WriteLine($"ERROR [{typeof(T).Name}]: {string.Format(message, args)}");
}
}
// /Models/
// These classes represent the data models and would typically be found in a 'Models' folder.
// public class ToDoList
// {
// [PrimaryKey, AutoIncrement, JsonIgnore]
// public int Id { get; set; } // Primary key for this junction entry
// public string Title { get; set; } = string.Empty; // initialise to prevent null warnings
// [Ignore] // This property is ignored by SQLite.Net as it's a navigation property
// public List<ToDoItem> Items { get; set; } = new List<ToDoItem>();
// public ToDoList() { } // Parameterless constructor required by SQLite.Net
// public ToDoList(string title)
// {
// Title = title;
// Items = new List<ToDoItem>();
// }
// }
public class ToDoList
{
[PrimaryKey, AutoIncrement, JsonIgnore]
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
[OneToMany(CascadeOperations = CascadeOperation.All)]
public List<ToDoItem> Items { get; set; } = new List<ToDoItem>();
public ToDoList() { }
public ToDoList(string title)
{
Title = title;
Items = new List<ToDoItem>();
}
}
// public class ToDoItem
// {
// [PrimaryKey, AutoIncrement, JsonIgnore]
// public int Id { get; set; } // Primary key for this junction entry
// [Indexed, JsonIgnore] // Indexed for faster lookups based on ToDoListId
// public int ToDoListId { get; set; } // Foreign key to ToDoList table
// public string Description { get; set; } = string.Empty; // initialise to prevent null warnings
// public bool IsCompleted { get; set; }
// public DateTime DueDate { get; set; }
// [Ignore] // This property is ignored by SQLite.Net as it's a navigation property
// public ModifiedDetail? ModifiedDetails { get; set; }
// public ToDoItem() { } // Parameterless constructor required by SQLite.Net
// public ToDoItem(string description, DateTime dueDate, bool isCompleted, ModifiedDetail modifiedDetails)
// {
// Description = description;
// DueDate = dueDate;
// IsCompleted = isCompleted;
// ModifiedDetails = modifiedDetails;
// }
// }
public class ToDoItem
{
[PrimaryKey, AutoIncrement, JsonIgnore]
public int Id { get; set; }
[ForeignKey(typeof(ToDoList))]
public int ToDoListId { get; set; }
public string Description { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
public DateTime DueDate { get; set; }
[OneToOne(CascadeOperations = CascadeOperation.All)]
public ModifiedDetail ModifiedDetails { get; set; } = new ModifiedDetail();
public ToDoItem() { }
public ToDoItem(string description, DateTime dueDate, bool isCompleted, ModifiedDetail modifiedDetails)
{
Description = description;
DueDate = dueDate;
IsCompleted = isCompleted;
ModifiedDetails = modifiedDetails;
}
}
// public class ModifiedDetail
// {
// [PrimaryKey, AutoIncrement, JsonIgnore]
// public int Id { get; set; } // Primary key for this junction entry
// [Indexed, JsonIgnore] // Indexed for faster lookups based on ToDoItemId
// public int ToDoItemId { get; set; } // Foreign key to ToDoItem table
// public string ModifiedBy { get; set; } = string.Empty; // initialise to prevent null warnings
// public DateTime ModifiedDate { get; set; }
// public ModifiedDetail() { } // Parameterless constructor required by SQLite.Net
// public ModifiedDetail(string modifiedBy, DateTime modifiedDate)
// {
// ModifiedBy = modifiedBy;
// ModifiedDate = modifiedDate;
// }
// }
public class ModifiedDetail
{
[PrimaryKey, AutoIncrement, JsonIgnore]
public int Id { get; set; }
[ForeignKey(typeof(ToDoItem))]
public int ToDoItemId { get; set; }
public string ModifiedBy { get; set; } = string.Empty;
public DateTime ModifiedDate { get; set; }
public ModifiedDetail() { }
public ModifiedDetail(string modifiedBy, DateTime modifiedDate)
{
ModifiedBy = modifiedBy;
ModifiedDate = modifiedDate;
}
}
// /Interfaces/Database/
// These interfaces define the contract for database services and would typically be in an 'Interfaces/Database' folder.
public interface IDatabaseService
{
Task<SQLiteAsyncConnection?> GetInitialisedDatabaseAsync();
Task CloseInitialisedDatabaseAsync();
Task ClearAllTables();
}
public interface IBaseService<T>
{
Task CreateTable();
Task<int> SaveAsync(T item, int primaryKey = 0);
Task<T?> GetAsync(int primaryKey);
Task<List<T>> GetAllAsync();
Task<int> DeleteAsync(T item);
}
public interface IToDoListService : IBaseService<ToDoList> { }
public interface IToDoItemService : IBaseService<ToDoItem> { }
public interface IModifiedDetailService : IBaseService<ModifiedDetail> { }
// /Services/Database/
// These abstract and concrete service implementations would typically be in a 'Services/Database' folder.
public class DatabaseService : IDatabaseService
{
private readonly ILogger? _logger;
private SQLiteAsyncConnection? _database { get; set; }
private string? _encryptionKey { get; set; }
private readonly TaskCompletionSource<bool> _encryptionKeyInitialisedTcs = new();
public DatabaseService(ILogger<DatabaseService>? logger)
{
_logger = logger;
// Start encryption key initialisation as a background task.
// The _encryptionKeyInitialisedTcs will signal completion or failure.
// _ = InitialiseEncryptionKey();
}
/// <summary>
/// Generates a new random 32-character GUID string for use as an encryption key.
/// </summary>
/// <returns>A new encryption key or an empty string if generation fails.</returns>
private string GenerateRandomKey()
{
try
{
return Guid.NewGuid().ToString("N").Substring(0, 32);
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_KeyGenerationFailed);
return string.Empty;
}
}
/// <summary>
/// initialises the encryption key for the database. It attempts to retrieve an existing key
/// from SecureStorage; if not found, it generates a new one and stores it.
/// </summary>
private async Task InitialiseEncryptionKey()
{
try
{
_encryptionKey = await SecureStorage.GetAsync(nameof(SettingKeys.SettingsDatabaseKey));
if (string.IsNullOrEmpty(_encryptionKey))
{
_encryptionKey = GenerateRandomKey();
if (string.IsNullOrEmpty(_encryptionKey))
{
_logger?.LogError(Constants.LogError_KeyGenerationFailed);
// Signal an exception if key generation itself failed
_encryptionKeyInitialisedTcs.TrySetException(new InvalidOperationException(Constants.LogError_KeyGenerationFailed));
return;
}
await SecureStorage.SetAsync(nameof(SettingKeys.SettingsDatabaseKey), _encryptionKey);
#if DEBUG
_logger?.LogInformation(Constants.LogInfo_EncryptionKeyGenerated);
#endif
}
#if DEBUG
else
{
_logger?.LogInformation(Constants.LogInfo_EncryptionKeyRetrieved);
}
#endif
// Signal that the encryption key has been successfully initialised
_encryptionKeyInitialisedTcs.TrySetResult(true);
}
catch (InvalidOperationException ex)
{
_logger?.LogError(ex, Constants.LogError_KeyInitInvalidOperation);
_encryptionKeyInitialisedTcs.TrySetException(ex);
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_KeyInitUnexpected);
_encryptionKeyInitialisedTcs.TrySetException(ex);
}
}
/// <summary>
/// initialises the SQLite database connection and creates the table for the generic type T.
/// This method ensures the encryption key is ready before attempting database connection.
/// </summary>
private async Task Initialise()
{
// If the database connection is already established, return.
if (_database is not null)
{
return;
}
try
{
// Wait for the encryption key to be initialised.
// If an exception occurred during key initialisation, it will be re-thrown here.
// await _encryptionKeyInitialisedTcs.Task;
await InitialiseEncryptionKey();
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_KeyInitFailedDbConnect);
_database = null; // Ensure database is null if key initialisation failed
return;
}
// Check if the encryption key is still null or empty after initialisation.
if (string.IsNullOrEmpty(_encryptionKey))
{
_logger?.LogError(Constants.LogError_KeyNullOrEmptyAfterInit);
_database = null;
return;
}
try
{
// Create a SQLite connection string with the database path, flags, and encryption key.
SQLiteConnectionString connectionString = new SQLiteConnectionString(
Constants.DatabasePath,
Constants.Flags,
true, // StoreDateTimeAsTicks
_encryptionKey
);
// initialise the SQLiteAsyncConnection.
_database = new SQLiteAsyncConnection(connectionString);
#if DEBUG
_logger?.LogInformation(Constants.LogInfo_Databaseinitialised, Constants.DatabasePath);
#endif
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitSQLite, Constants.DatabasePath);
_database = null;
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitUnexpected, Constants.DatabasePath);
_database = null;
}
}
/// <summary>
/// Gets the initialised SQLiteAsyncConnection instance.
/// Ensures the database is initialised before returning the connection.
/// </summary>
/// <returns>The SQLiteAsyncConnection instance, or null if initialisation failed.</returns>
public async Task<SQLiteAsyncConnection?> GetInitialisedDatabaseAsync()
{
try
{
await Initialise(); // Ensure database is initialised
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitSQLite, Constants.DatabasePath);
_database = null;
}
catch (System.Exception ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitUnexpected, Constants.DatabasePath);
_database = null;
}
return _database;
}
/// <summary>
/// Closes the initialised SQLiteAsyncConnection instance.
/// </summary>
public async Task CloseInitialisedDatabaseAsync()
{
if (_database is null)
{
return;
}
try
{
await _database.CloseAsync();
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitSQLite, Constants.DatabasePath);
}
catch (System.Exception ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitUnexpected, Constants.DatabasePath);
}
finally
{
_database = null;
// _encryptionKey = null; // Optionally clear the encryption key after closing the database
}
}
/// <summary>
/// This method iterates through all table mappings and deletes all records from each table.
/// </summary>
public async Task ClearAllTables()
{
if (_database is null)
{
#if DEBUG
_logger?.LogWarning(Constants.LogWarn_DbNotAvailableGet);
#endif
return;
}
try
{
List<TableMapping> tableMappings = _database.TableMappings.Where(t => t.MappedType.Equals(typeof(SQLiteConnection.ColumnInfo)).Equals(false)).ToList();
foreach (TableMapping tableMapping in tableMappings)
{
await _database.DeleteAllAsync(tableMapping);
}
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitSQLite, Constants.DatabasePath);
_database = null;
}
catch (System.Exception ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitUnexpected, Constants.DatabasePath);
_database = null;
}
}
}
public abstract class BaseService<T> where T : new()
{
protected readonly ILogger _logger;
protected readonly IDatabaseService _databaseService;
public BaseService(ILogger logger, IDatabaseService databaseService)
{
_logger = logger;
_databaseService = databaseService;
}
/// <summary>
/// Initialises the SQLite database connection and creates the table for the generic type T.
/// </summary>
public async Task InitialiseTableAsync()
{
SQLiteAsyncConnection? db = await _databaseService.GetInitialisedDatabaseAsync();
if (db is null)
{
#if DEBUG
_logger?.LogWarning(Constants.LogWarn_DbNotAvailableGet, typeof(T).Name);
#endif
return;
}
try
{
// Create the table for type T if it doesn't already exist.
await db.CreateTableAsync(typeof(T));
#if DEBUG
_logger?.LogInformation(Constants.LogInfo_CreatedTable, typeof(T).Name);
#endif
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitSQLite, Constants.DatabasePath);
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_DbInitUnexpected, Constants.DatabasePath);
}
}
/// <summary>
/// Inserts a new item or updates an existing item in the database.
/// This method leverages reflection to handle the 'Id' property for generic types.
/// </summary>
/// <param name="item">The item to save.</param>
/// <param name="primaryKey">An optional primary key to use for updates, if item.Id is 0. Primarily used for explicit update scenarios.</param>
/// <returns>The number of rows affected (1 for success, 0 for failure).</returns>
protected async Task<int> InsertOrUpdateAsync(T item, int primaryKey = 0)
{
SQLiteAsyncConnection? db = await _databaseService.GetInitialisedDatabaseAsync();
if (db is null)
{
#if DEBUG
_logger?.LogWarning(Constants.LogWarn_DbNotAvailableSave, typeof(T).Name);
#endif
return 0;
}
try
{
int result = 0;
T? existingItem = default(T);
if (primaryKey is not 0)
{
// existingItem = await db.FindAsync<T>(primaryKey);
existingItem = await db.GetWithChildrenAsync<T>(primaryKey, recursive: true);
}
if (existingItem is not null)
{
// result = await db.UpdateAsync(item);
await db.UpdateWithChildrenAsync(item);
result = 1;
#if DEBUG
_logger?.LogInformation(
result > 0 ? Constants.LogInfo_ItemSaved : Constants.LogInfo_ItemSaveFailed,
typeof(T).Name
);
#endif
}
else
{
// result = await db.InsertAsync(item);
await db.InsertWithChildrenAsync(item, recursive: true);
result = 1;
#if DEBUG
_logger?.LogInformation(
result > 0 ? Constants.LogInfo_ItemSaved : Constants.LogInfo_ItemSaveFailed,
typeof(T).Name
);
#endif
}
return result;
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_SaveSQLiteGeneric, typeof(T).Name);
return 0;
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_SaveUnexpectedGeneric, typeof(T).Name);
return 0;
}
}
/// <summary>
/// Finds and retrieves an item from the database by its primary key.
/// </summary>
/// <param name="primaryKey">The primary key of the item to retrieve.</param>
/// <returns>The retrieved item, or default(T) if not found or an error occurs.</returns>
protected async Task<T?> FindAsync(int primaryKey)
{
SQLiteAsyncConnection? db = await _databaseService.GetInitialisedDatabaseAsync();
if (db is null)
{
#if DEBUG
_logger?.LogWarning(Constants.LogWarn_DbNotAvailableGet, typeof(T).Name);
#endif
return default(T);
}
try
{
// T? item = await db.FindAsync<T>(primaryKey);
T? item = await db.GetWithChildrenAsync<T>(primaryKey, recursive: true);
if (item != null)
{
#if DEBUG
_logger?.LogInformation(Constants.LogInfo_ItemRetrievedByKey, typeof(T).Name, primaryKey);
#endif
}
else
{
#if DEBUG
_logger?.LogInformation(Constants.LogInfo_ItemNotFoundByKey, typeof(T).Name, primaryKey);
#endif
}
return item;
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_GetSQLiteGeneric, typeof(T).Name, primaryKey);
return default(T);
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_GetUnexpectedGeneric, typeof(T).Name, primaryKey);
return default(T);
}
}
/// <summary>
/// Retrieves all items of type T from the database.
/// </summary>
/// <returns>A list of all items of type T.</returns>
protected async Task<List<T>> FindAllAsync()
{
SQLiteAsyncConnection? db = await _databaseService.GetInitialisedDatabaseAsync();
if (db is null)
{
#if DEBUG
_logger?.LogWarning(Constants.LogWarn_DbNotAvailableGetAll, typeof(T).Name);
#endif
return new List<T>();
}
try
{
// List<T> items = await db.Table<T>().ToListAsync();
List<T> items = await db.GetAllWithChildrenAsync<T>(recursive: true);
#if DEBUG
_logger?.LogInformation(
items.Count > 0 ? Constants.LogInfo_ItemsRetrieved : Constants.LogInfo_NoItemsFound,
typeof(T).Name,
items.Count
);
#endif
return items;
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_GetAllSQLiteGeneric, typeof(T).Name);
return new List<T>();
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_GetAllUnexpectedGeneric, typeof(T).Name);
return new List<T>();
}
}
/// <summary>
/// Deletes an item from the database.
/// </summary>
/// <param name="item">The item to delete.</param>
/// <returns>The number of rows affected (1 for success, 0 for failure).</returns>
protected async Task<int> RemoveAsync(T item)
{
SQLiteAsyncConnection? db = await _databaseService.GetInitialisedDatabaseAsync();
if (db is null)
{
#if DEBUG
_logger?.LogWarning(Constants.LogWarn_DbNotAvailableDelete, typeof(T).Name);
#endif
return 0;
}
try
{
// int result = await db.DeleteAsync(item);
await db.DeleteAsync(item, recursive: true);
#if DEBUG
// _logger?.LogInformation(
// result > 0 ? Constants.LogInfo_ItemDeleted : Constants.LogInfo_ItemDeleteFailed,
// typeof(T).Name
// );
_logger?.LogInformation(Constants.LogInfo_ItemDeleted, typeof(T).Name);
#endif
// return result;
return 1;
}
catch (SQLiteException ex)
{
_logger?.LogError(ex, Constants.LogError_DeleteSQLiteGeneric, typeof(T).Name);
return 0;
}
catch (Exception ex)
{
_logger?.LogError(ex, Constants.LogError_DeleteUnexpectedGeneric, typeof(T).Name);
return 0;
}
}
}
public class ModifiedDetailService : BaseService<ModifiedDetail>, IModifiedDetailService
{
public ModifiedDetailService(ILogger<ModifiedDetailService> logger, IDatabaseService databaseService) : base(logger, databaseService)
{
// The base constructor calls InitialiseEncryptionKey, which then allows Initialise to run.
// Explicitly calling GetInitialisedDatabaseAsync here is not strictly necessary if the first
// call to SaveAsync/GetAsync etc. will trigger it anyway. However, it ensures table creation
// happens early.
// _ = GetInitialisedDatabaseAsync();
}
public async Task CreateTable() => await InitialiseTableAsync();
public async Task<int> SaveAsync(ModifiedDetail item, int primaryKey = 0) => await InsertOrUpdateAsync(item, primaryKey);
public async Task<ModifiedDetail?> GetAsync(int primaryKey) => await FindAsync(primaryKey);
public async Task<List<ModifiedDetail>> GetAllAsync() => await FindAllAsync();
public async Task<int> DeleteAsync(ModifiedDetail item) => await RemoveAsync(item);
}
public class ToDoItemService : BaseService<ToDoItem>, IToDoItemService
{
public ToDoItemService(ILogger<ToDoItemService> logger, IDatabaseService databaseService) : base(logger, databaseService)
{
// The base constructor calls InitialiseEncryptionKey, which then allows Initialise to run.
// Explicitly calling GetInitialisedDatabaseAsync here is not strictly necessary if the first
// call to SaveAsync/GetAsync etc. will trigger it anyway. However, it ensures table creation
// happens early.
// _ = GetInitialisedDatabaseAsync();
}
public async Task CreateTable() => await InitialiseTableAsync();
public async Task<int> SaveAsync(ToDoItem item, int primaryKey = 0) => await InsertOrUpdateAsync(item, primaryKey);
public async Task<ToDoItem?> GetAsync(int primaryKey) => await FindAsync(primaryKey);
public async Task<List<ToDoItem>> GetAllAsync() => await FindAllAsync();
public async Task<int> DeleteAsync(ToDoItem item) => await RemoveAsync(item);
}
public class ToDoListService : BaseService<ToDoList>, IToDoListService
{
private readonly IModifiedDetailService _modifiedDetailService;
private readonly IToDoItemService _toDoItemService;
public ToDoListService(
ILogger<ToDoListService> logger,
IDatabaseService databaseService,
IModifiedDetailService modifiedDetailService,
IToDoItemService toDoItemService) : base(logger, databaseService)
{
_modifiedDetailService = modifiedDetailService;
_toDoItemService = toDoItemService;
// The base constructor calls InitialiseEncryptionKey, which then allows Initialise to run.
// Explicitly calling GetInitialisedDatabaseAsync here is not strictly necessary if the first
// call to SaveAsync/GetAsync etc. will trigger it anyway. However, it ensures table creation
// happens early.
// _ = GetInitialisedDatabaseAsync();
}
public async Task CreateTable() => await InitialiseTableAsync();
/// <summary>
/// Saves a ToDoList and its associated ToDoItems and ModifiedDetails.
/// Handles both insertion and updating of records.
/// </summary>
/// <param name="item">The ToDoList to save.</param>
/// <param name="primaryKey">Optional primary key for explicit updates of the ToDoList.</param>
/// <returns>The number of rows affected for the ToDoList (1 for success, 0 for failure).</returns>
// public async Task<int> SaveAsync(ToDoList item, int primaryKey = 0)
// {
// try
// {
// int result = await InsertOrUpdateAsync(item, primaryKey);
// if (result > 0 && item is not null)
// {
// if (item.Items is not null && item.Items.Count > 0)
// {
// foreach (ToDoItem todoItem in item.Items)
// {
// todoItem.ToDoListId = item.Id;
// await _toDoItemService.SaveAsync(todoItem, todoItem.Id);
// if (todoItem.ModifiedDetails is not null)
// {
// todoItem.ModifiedDetails.ToDoItemId = todoItem.Id;
// await _modifiedDetailService.SaveAsync(todoItem.ModifiedDetails, todoItem.ModifiedDetails.Id);
// }
// }
// }
// }
// return result;
// }
// catch (Exception ex)
// {
// _logger?.LogError(ex, Constants.LogError_SaveUnexpectedGeneric, nameof(ToDoList));
// return 0;
// }
// }
public async Task<int> SaveAsync(ToDoList item, int primaryKey = 0) => await InsertOrUpdateAsync(item, primaryKey);
/// <summary>
/// Retrieves a ToDoList by its primary key, including its associated ToDoItems and ModifiedDetails.
/// </summary>
/// <param name="primaryKey">The primary key of the ToDoList to retrieve.</param>
/// <returns>The retrieved ToDoList, or null if not found or an error occurs.</returns>
// public async Task<ToDoList?> GetAsync(int primaryKey)
// {
// try
// {
// ToDoList? toDoList = await FindAsync(primaryKey);
// if (toDoList is not null)
// {
// List<ToDoItem> todoItems = await _toDoItemService.GetAllAsync();
// if (todoItems.Count is not 0)
// {
// todoItems = todoItems.Where(m => m.ToDoListId == toDoList.Id).ToList();
// if (todoItems.Count is not 0)
// {
// foreach (var (todoItem, index) in todoItems.Select((todoItem, index) => (todoItem, index)))
// {
// List<ModifiedDetail> modifiedDetailList = await _modifiedDetailService.GetAllAsync();
// if (modifiedDetailList.Count is not 0)
// {
// modifiedDetailList = modifiedDetailList.Where(m => m.ToDoItemId == todoItem.Id).ToList();
// if (modifiedDetailList.Count is not 0)
// {
// todoItem.ModifiedDetails = modifiedDetailList.FirstOrDefault();
// }
// }
// }
// toDoList.Items = todoItems;
// }
// }
// }
// return toDoList;
// }
// catch (Exception ex)
// {
// _logger?.LogError(ex, Constants.LogError_GetUnexpectedGeneric, nameof(ToDoList));
// return null;
// }
// }
public async Task<ToDoList?> GetAsync(int primaryKey) => await FindAsync(primaryKey);
/// <summary>
/// Retrieves all ToDoLists, including their associated ToDoItems and ModifiedDetails.
/// </summary>
/// <returns>A list of all ToDoLists.</returns>
// public async Task<List<ToDoList>> GetAllAsync()
// {
// try
// {
// List<ToDoList> toDoLists = await FindAllAsync();
// if (toDoLists.Count is not 0)
// {
// foreach (var (toDoList, index) in toDoLists.Select((toDoList, index) => (toDoList, index)))
// {
// List<ToDoItem> todoItems = await _toDoItemService.GetAllAsync();
// if (todoItems.Count is not 0)
// {
// todoItems = todoItems.Where(m => m.ToDoListId == toDoList.Id).ToList();
// if (todoItems.Count is not 0)
// {
// foreach (var (todoItem, todoItemIndex) in todoItems.Select((todoItem, todoItemIndex) => (todoItem, todoItemIndex)))
// {
// List<ModifiedDetail> modifiedDetailList = await _modifiedDetailService.GetAllAsync();
// if (modifiedDetailList.Count is not 0)
// {
// modifiedDetailList = modifiedDetailList.Where(m => m.ToDoItemId == todoItem.Id).ToList();
// if (modifiedDetailList.Count is not 0)
// {
// todoItem.ModifiedDetails = modifiedDetailList.FirstOrDefault();
// }
// }
// }
// toDoList.Items = todoItems;
// }
// }
// }
// }
// return toDoLists;
// }
// catch (Exception ex)
// {
// _logger?.LogError(ex, Constants.LogError_GetAllUnexpectedGeneric, nameof(List<ToDoList>));
// return new List<ToDoList>();
// }
// }
public async Task<List<ToDoList>> GetAllAsync() => await FindAllAsync();
/// <summary>
/// Deletes a ToDoList, along with all its associated ToDoItems and their ModifiedDetails.
/// </summary>
/// <param name="item">The ToDoList to delete.</param>
/// <returns>The number of rows affected for the ToDoList (1 for success, 0 for failure).</returns>
// public async Task<int> DeleteAsync(ToDoList item)
// {
// try
// {
// List<ToDoItem> todoItems = await _toDoItemService.GetAllAsync();
// if (todoItems.Count is not 0)
// {
// todoItems = todoItems.Where(m => m.ToDoListId == item.Id).ToList();
// if (todoItems.Count is not 0)
// {
// foreach (ToDoItem todoItem in todoItems)
// {
// List<ModifiedDetail> modifiedDetailList = await _modifiedDetailService.GetAllAsync();
// if (modifiedDetailList.Count is not 0)
// {
// ModifiedDetail? detailToDelete = modifiedDetailList.FirstOrDefault(m => m.ToDoItemId == todoItem.Id);
// if (detailToDelete != null)
// {
// await _modifiedDetailService.DeleteAsync(detailToDelete);
// }
// }
// await _toDoItemService.DeleteAsync(todoItem);
// }
// }
// }
// return await RemoveAsync(item);
// }
// catch (Exception ex)
// {
// _logger?.LogError(ex, Constants.LogError_DeleteUnexpectedGeneric, nameof(ToDoList));
// return 0;
// }
// }
public async Task<int> DeleteAsync(ToDoList item) => await RemoveAsync(item);
}
// Program.cs
// This is the entry point for the console application.
public class Program
{
public static async Task Main(string[] args)
{
Console.WriteLine("Starting Database Service Test...");
// Ensure the database file doesn't exist from previous runs for a clean test.
// This is important for consistent testing on platforms like dotnetfiddle.net.
if (File.Exists(Constants.DatabasePath))
{
File.Delete(Constants.DatabasePath);
Console.WriteLine($"Cleaned up existing database file: {Constants.DatabasePath}");
}
// Setup mock loggers for each service.
// In a real application, a dependency injection framework (like Microsoft.Extensions.DependencyInjection)
// would handle the instantiation and injection of these services and loggers.
ILogger<DatabaseService> databaseLogger = new ConsoleLogger<DatabaseService>();
ILogger<ModifiedDetailService> modifiedDetailLogger = new ConsoleLogger<ModifiedDetailService>();
ILogger<ToDoItemService> toDoItemLogger = new ConsoleLogger<ToDoItemService>();
ILogger<ToDoListService> toDoListLogger = new ConsoleLogger<ToDoListService>();
// Setup service instances, passing their respective loggers and any dependencies.
IDatabaseService databaseService = new DatabaseService(databaseLogger);
IModifiedDetailService modifiedDetailService = new ModifiedDetailService(modifiedDetailLogger, databaseService);
await modifiedDetailService.CreateTable();
IToDoItemService toDoItemService = new ToDoItemService(toDoItemLogger, databaseService);
await toDoItemService.CreateTable();
IToDoListService toDoListService = new ToDoListService(toDoListLogger, databaseService, modifiedDetailService, toDoItemService);
await toDoListService.CreateTable();
Console.WriteLine("\n--- Test Case 1: Create and save a ToDoList with items ---");
// Create ModifiedDetail instances for the ToDoItems
var modifiedDetail1 = new ModifiedDetail("User A", DateTime.Now.AddDays(-1));
var modifiedDetail2 = new ModifiedDetail("User B", DateTime.Now);
// Create ToDoItem instances and link them to ModifiedDetails
var todoItem1 = new ToDoItem("Buy groceries", DateTime.Today.AddDays(2), false, modifiedDetail1);
var todoItem2 = new ToDoItem("Finish report", DateTime.Today.AddDays(0), true, modifiedDetail2);
// Create a ToDoList and add the ToDoItems to it
var todoList1 = new ToDoList("Daily Tasks");
todoList1.Items.Add(todoItem1);
todoList1.Items.Add(todoItem2);
Console.WriteLine($"Attempting to save ToDoList: '{todoList1.Title}' with {todoList1.Items.Count} items.");
int saveResult = await toDoListService.SaveAsync(todoList1);
Console.WriteLine($"Save operation result for ToDoList1: {saveResult} (expected > 0 for success).");
Console.WriteLine($"ToDoList1 ID after save (should be populated if new): {todoList1.Id}");
Console.WriteLine($"ToDoItem1 ID after save (should be populated if new): {todoItem1.Id}");
Console.WriteLine($"ModifiedDetail1 ID after save (should be populated if new): {modifiedDetail1.Id}");
if (saveResult > 0 && todoList1.Id != 0)
{
Console.WriteLine($"\n--- Test Case 2: Retrieve the saved ToDoList by ID ---");
ToDoList? retrievedList = await toDoListService.GetAsync(todoList1.Id);
if (retrievedList != null)
{
Console.WriteLine($"Successfully retrieved ToDoList: '{retrievedList.Title}' (ID: {retrievedList.Id})");
Console.WriteLine($"Number of associated items: {retrievedList.Items?.Count ?? 0}");
if (retrievedList.Items != null)
{
foreach (var item in retrievedList.Items)
{
Console.WriteLine($" - Item: '{item.Description}', Completed: {item.IsCompleted}, Due: {item.DueDate.ToShortDateString()}");
if (item.ModifiedDetails != null)
{
Console.WriteLine($" Modified By: {item.ModifiedDetails.ModifiedBy} on {item.ModifiedDetails.ModifiedDate}");
}
}
}
}
else
{
Console.WriteLine("Failed to retrieve ToDoList with the given ID.");
}
Console.WriteLine($"\n--- Test Case 3: Create and save another ToDoList with no items initially ---");
var todoList2 = new ToDoList("Weekend Plans");
int saveResult2 = await toDoListService.SaveAsync(todoList2);
Console.WriteLine($"Save operation result for ToDoList2: {saveResult2}. ToDoList2 ID: {todoList2.Id}");
Console.WriteLine($"\n--- Test Case 4: Retrieve all ToDoLists ---");
List<ToDoList> allLists = await toDoListService.GetAllAsync();
Console.WriteLine($"Retrieved {allLists.Count} ToDoLists.");
foreach (var list in allLists)
{
Console.WriteLine($"- List: '{list.Title}' (ID: {list.Id}), Items Count: {list.Items?.Count ?? 0}");
}
Console.WriteLine($"\n--- Test Case 5: Update a ToDoItem within a retrieved ToDoList ---");
if (retrievedList != null && retrievedList.Items != null && retrievedList.Items.Any())
{
var itemToUpdate = retrievedList.Items.First(); // Get the first item from the retrieved list
Console.WriteLine($"Original item description: '{itemToUpdate.Description}' (ID: {itemToUpdate.Id})");
itemToUpdate.Description = "Buy organic groceries and fresh produce"; // Update description
itemToUpdate.IsCompleted = true; // Mark as completed
// Create a new ModifiedDetail for the update.
// Note: In a real app, you might update an existing ModifiedDetail or create a new one.
// Here, we're creating a new one to demonstrate saving it.
var newModifiedDetail = new ModifiedDetail("User C (Updater)", DateTime.Now.AddHours(1));
itemToUpdate.ModifiedDetails = newModifiedDetail;
// Save the updated ToDoItem. It should update the existing record.
int updateItemResult = await toDoItemService.SaveAsync(itemToUpdate, itemToUpdate.Id);
// Also save the new/updated ModifiedDetail
await modifiedDetailService.SaveAsync(itemToUpdate.ModifiedDetails, itemToUpdate.ModifiedDetails.Id);
Console.WriteLine($"Update item result: {updateItemResult}");
// Re-retrieve the entire list to verify the item update and its associated details.
Console.WriteLine($"Re-retrieving ToDoList {retrievedList.Id} to verify item update.");
retrievedList = await toDoListService.GetAsync(retrievedList.Id);
var updatedItem = retrievedList?.Items.FirstOrDefault(i => i.Id == itemToUpdate.Id);
if (updatedItem != null)
{
Console.WriteLine($"Verified updated item: '{updatedItem.Description}', Completed: {updatedItem.IsCompleted}");
if (updatedItem.ModifiedDetails != null)
{
Console.WriteLine($" Modified By: {updatedItem.ModifiedDetails.ModifiedBy} on {updatedItem.ModifiedDetails.ModifiedDate}");
}
else
{
Console.WriteLine(" Modified Details were not loaded after update.");
}
}
else
{
Console.WriteLine("Failed to find updated item after re-retrieval.");
}
}
else
{
Console.WriteLine("No items found in the first ToDoList to demonstrate update.");
}
Console.WriteLine($"\n--- Test Case 6: Delete a ToDoList and its nested items ---");
// Retrieve all lists again to ensure we have the latest state before deleting
allLists = await toDoListService.GetAllAsync();
if (allLists.Any(l => l.Id == todoList2.Id)) // Ensure todoList2 still exists before trying to delete
{
var listToDelete = allLists.First(l => l.Id == todoList2.Id); // Delete the second list created
Console.WriteLine($"Attempting to delete ToDoList: '{listToDelete.Title}' (ID: {listToDelete.Id})");
int deleteResult = await toDoListService.DeleteAsync(listToDelete);
Console.WriteLine($"Delete operation result: {deleteResult} (expected > 0 for success).");
Console.WriteLine($"\n--- Test Case 7: Verify deletion ---");
allLists = await toDoListService.GetAllAsync();
Console.WriteLine($"Retrieved {allLists.Count} ToDoLists after deletion. Expected 1 list remaining (ToDoList1).");
foreach (var list in allLists)
{
Console.WriteLine($"- Remaining List: '{list.Title}' (ID: {list.Id})");
}
// Verify that the deleted list and its items/details are truly gone
ToDoList? deletedListCheck = await toDoListService.GetAsync(listToDelete.Id);
Console.WriteLine($"Check for deleted list (ID {listToDelete.Id}): {(deletedListCheck == null ? "Not found (correct)" : "Found (incorrect)")}");
}
else
{
Console.WriteLine("ToDoList2 was not found, skipping deletion test for it.");
}
}
else
{
Console.WriteLine("Initial ToDoList save failed. Cannot proceed with further tests.");
}
Console.WriteLine("\nDatabase Service Test Complete.");
// Clean up the database file at the end of the test run.
await databaseService.ClearAllTables();
await databaseService.CloseInitialisedDatabaseAsync();
if (File.Exists(Constants.DatabasePath))
{
File.Delete(Constants.DatabasePath);
Console.WriteLine($"\nFinal cleanup: Deleted database file: {Constants.DatabasePath}");
}
else
{
Console.WriteLine("\nNo database file to clean up.");
}
}
}
.NET MAUI Application Insights
Before integrating Application Insights into your application, create an Application Insights resource in the Azure portal:
-
Navigate to the Azure portal:
Open Azure Portal in your browser. -
Create a New Resource:
Click on Create a resource. In the New window, search for "Application Insights". -
Select and Create:
Select the Application Insights option, then click Create.
Enter your desired settings (such as subscription, resource group, region, etc.), and press Review + Create, then click Create again. -
Save the Connection String:
Once created, the Application Insights resource will generate a Connection String. This string is essential later when configuring your .NET MAUI application.
To integrate Application Insights, you need the appropriate SDK in your .NET MAUI solution:
-
Using NuGet Package Manager:
Open the NuGet Package Manager in Visual Studio, search forMicrosoft.Extensions.Logging.ApplicationInsights
, and install it in your solution. -
Or add the package reference directly:
Include the following in your project file:<PackageReference Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.22.0-beta3" />
This package integrates Application Insights logging with .NET's logging framework.
Configure Application Insights in your .NET MAUI application using the connection string obtained from Azure. In your MauiProgram.cs
(or equivalent startup file), add the following code:
builder.Logging.AddApplicationInsights(configuration =>
{
// Add a custom telemetry initializer to attach additional context data
configuration.TelemetryInitializers.Add(new ApplicationInitializer());
// Set the connection string from your Azure Application Insights resource
configuration.ConnectionString = "YOUR-CONNECTION-STRING";
}, options =>
{
// Option to include scopes for richer logging context
options.IncludeScopes = true;
});
Replace "YOUR-CONNECTION-STRING"
with the actual connection string from your Azure resource.
To enrich your telemetry data with context like session details and device information, implement the ITelemetryInitializer
interface. Create a class like the one below:
public class ApplicationInitializer : ITelemetryInitializer
{
public string SessionId { get; } = Guid.NewGuid().ToString();
public string? DeviceOperationSystem { get; } = DeviceInfo.Current.Platform.ToString();
public string DeviceOemName { get; } = DeviceInfo.Current.Manufacturer;
public string DeviceModel { get; } = DeviceInfo.Current.Model;
public string ComponentVersion { get; } = AppInfo.Current.VersionString;
public void Initialize(ITelemetry telemetry)
{
telemetry.Context.Session.Id = SessionId;
telemetry.Context.Device.OperatingSystem = DeviceOperationSystem;
telemetry.Context.Device.OemName = DeviceOemName;
telemetry.Context.Device.Model = DeviceModel;
telemetry.Context.Component.Version = ComponentVersion;
}
}
Explanation:
This initializer attaches details such as a unique session ID, device operating system, manufacturer, model, and application version to every telemetry event, enabling deeper diagnostics and analysis.
With Application Insights initialized, you can now effectively log events, exceptions, and dependencies throughout your application.
Here’s an example using the ILogger
interface to log a custom event:
private readonly ILogger<MyService> logger;
public MyService(ILogger<MyService> logger)
{
logger = logger;
}
public void MyAction(string parameter)
{
// Log information when an action is executed
logger.LogInformation("My action executed with parameter: {Parameter}", parameter);
}
To log exceptions and capture error details:
try
{
// Code that might throw an exception
}
catch (Exception ex)
{
// Log the exception details using the logger
logger.LogError(ex, "Something went wrong");
}
Fix SSH Not Working in macOS
If you’re running into a compatibility issue between Visual Studio’s SSH pairing tool and the updated SSH setup in macOS Sequoia. Even though Remote Login is enabled for all users, recent macOS releases (like Sequoia and Ventura) ship with newer builds of OpenSSH that disable legacy algorithms—most notably, RSA signatures using SHA‑1 are now off by default. This means that Visual Studio’s connection, which often expects those older protocols, might be rejected even when everything “looks” correctly configured on your Mac.
Here are some steps to try and resolve the issue:
- Install xcode: https://apps.apple.com/us/app/xcode (Then manually open Xcode, after installation, so that it can add additional components.)
- Install Mono: https://www.mono-project.com
- Install Rosetta: softwareupdate --install-rosetta (Run with terminal.) OR /usr/sbin/softwareupdate --install-rosetta
-
Verify Remote Login and Firewall Settings
– Double‑check your Mac’s System Settings (or System Preferences) under Sharing to confirm that “Remote Login” is turned on and that it’s set to allow access for the correct user accounts.
– Review your firewall settings to ensure that incoming connections on port 22 (the default SSH port) aren’t being blocked. -
Edit the SSH Daemon Configuration
With the update to OpenSSH (likely something like OpenSSH_9.0p1 on Sequoia), you may need to explicitly allow the older algorithms that Visual Studio relies on.
– Open Terminal on your Mac and run:sudo nano /etc/ssh/sshd_config
– Add the following lines at the end of the file:
HostkeyAlgorithms +ssh-rsa PubkeyAcceptedAlgorithms +ssh-rsa
– Save the file (in nano, press Ctrl+O to write and Ctrl+X to exit).
-
Restart the SSH Service
To apply the changes, restart SSH. You can do this by unloading and loading the SSH daemon:sudo launchctl unload /System/Library/LaunchDaemons/ssh.plist sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
Alternatively, a full system reboot will also do the trick.
-
Test the Connection
Before trying Visual Studio again, use an SSH client from another machine (or from a Windows terminal if you have something like PuTTY or Windows Subsystem for Linux installed) to connect to your Mac:ssh your_username@your_mac_ip
If you can log in without issues, that’s a good sign that the server side is now accepting the connection.
-
Update Visual Studio / Workloads
Microsoft has been releasing updates to address connectivity and compatibility issues with the latest macOS releases. Make sure your Visual Studio on Windows is updated to the latest version so that any improvements in its SSH handling are in place. In some cases, updating the relevant workload (for example, the Xamarin or MAUI build workloads) on your Windows machine has resolved similar issues.
This solution has worked for others encountering similar SSH connection errors after updating to macOS Sequoia or Ventura . In addition, if you still experience issues when pairing from Visual Studio, consider clearing any cached SSH host keys on your Windows machine or checking for network isolation between your Windows computer and the Mac.
Beyond these steps, if connectivity remains an issue, it might be worth exploring whether Visual Studio’s internal SSH client (or the way it invokes SSH for pairing) is misbehaving. Sometimes, reinstalling or repairing the Visual Studio installation (or the specific iOS build components) can help clear out stale settings that interfere with the connection.
Feel free to ask more if you run into other issues or need further detail on any of these steps!It sounds like you’re running into a compatibility issue between Visual Studio’s SSH pairing tool and the updated SSH setup in macOS Sequoia. Even though Remote Login is enabled for all users, recent macOS releases (like Sequoia and Ventura) ship with newer builds of OpenSSH that disable legacy algorithms—most notably, RSA signatures using SHA‑1 are now off by default. This means that Visual Studio’s connection, which often expects those older protocols, might be rejected even when everything “looks” correctly configured on your Mac.
Here are some steps to try and resolve the issue:
-
Verify Remote Login and Firewall Settings
– Double‑check your Mac’s System Settings (or System Preferences) under Sharing to confirm that “Remote Login” is turned on and that it’s set to allow access for the correct user accounts.
– Review your firewall settings to ensure that incoming connections on port 22 (the default SSH port) aren’t being blocked. -
Edit the SSH Daemon Configuration
With the update to OpenSSH (likely something like OpenSSH_9.0p1 on Sequoia), you may need to explicitly allow the older algorithms that Visual Studio relies on.
– Open Terminal on your Mac and run:sudo nano /etc/ssh/sshd_config
– Add the following lines at the end of the file:
HostkeyAlgorithms +ssh-rsa PubkeyAcceptedAlgorithms +ssh-rsa
– Save the file (in nano, press Ctrl+O to write and Ctrl+X to exit).
-
Restart the SSH Service
To apply the changes, restart SSH. You can do this by unloading and loading the SSH daemon:sudo launchctl unload /System/Library/LaunchDaemons/ssh.plist sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
Alternatively, a full system reboot will also do the trick.
-
Test the Connection
Before trying Visual Studio again, use an SSH client from another machine (or from a Windows terminal if you have something like PuTTY or Windows Subsystem for Linux installed) to connect to your Mac:ssh your_username@your_mac_ip
If you can log in without issues, that’s a good sign that the server side is now accepting the connection.
-
Update Visual Studio / Workloads
Microsoft has been releasing updates to address connectivity and compatibility issues with the latest macOS releases. Make sure your Visual Studio on Windows is updated to the latest version so that any improvements in its SSH handling are in place. In some cases, updating the relevant workload (for example, the Xamarin or MAUI build workloads) on your Windows machine has resolved similar issues.
Android YML Deployment
What are the steps to generate an APK file in a MAUI project for distributing the Android application?
- Double click the Properties folder of your app, then you will open a page.
- Select Android option on the left -> Options-> set Android package format to apk.
trigger:
- main
variables:
- group: versioning
- group: mono
- group: ios-signing
- group: android-signing
- name: buildConfiguration
value: 'Release'
- name: outputDirectory
value: '$(build.binariesDirectory)/$(buildConfiguration)'
- name: iosVersionFile
value: 'Apps/WeatherTwentyOne/Platforms/iOS/Info.plist'
- name: versionFile
value: 'Apps/WeatherTwentyOne/Platforms/Android/AndroidManifest.xml'
- name: BuildConfiguration
value: Release
- name: DotNetVersion
value: 9.0.100
- name: VSVERSION
value: 17/pre
stages:
- stage: BuildiOS
jobs:
- job: BuildMAUIApps
displayName: Build App
pool:
vmImage: 'macOS-15'
demands:
- MSBuild
steps:
- script: sudo xcode-select -s /Applications/Xcode_16.3.app
displayName: 'Select Xcode 16.3'
- task: InstallAppleCertificate@2
inputs:
certSecureFile: '$(cert.name)'
certPwd: '$(cert.passwordmaui)'
keychain: 'temp'
- task: InstallAppleCertificate@2
inputs:
certSecureFile: '$(cert.namedev)'
certPwd: '$(cert.passwordmaui)'
keychain: 'temp'
- task: InstallAppleCertificate@2
inputs:
certSecureFile: '$(cert.namedevtm)'
certPwd: '$(cert.passwordmaui)'
keychain: 'temp'
- task: InstallAppleProvisioningProfile@1
inputs:
provisioningProfileLocation: 'secureFiles'
provProfileSecureFile: '$(profile.name)'
removeProfile: true
- task: InstallAppleProvisioningProfile@1
inputs:
provisioningProfileLocation: 'secureFiles'
provProfileSecureFile: '$(profile.namedev)'
removeProfile: true
- task: UseDotNet@2
displayName: .NET Version
inputs:
packageType: 'sdk'
version: '$(DotNetVersion)'
- task: Bash@3
displayName: Install MAUI
inputs:
targetType: 'inline'
script: |
dotnet nuget locals all --clear
dotnet workload install maui --source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet7/nuget/v3/index.json --source https://api.nuget.org/v3/index.json
dotnet workload install android ios maccatalyst tvos macos maui wasm-tools --source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet7/nuget/v3/index.json --source https://api.nuget.org/v3/index.json
- task: Bash@3
displayName: Restore nuget
inputs:
targetType: 'inline'
script: |
cd Apps/WeatherTwentyOne/
dotnet restore WeatherTwentyOne.csproj
- task: Bash@3
displayName: Build iOS App
inputs:
targetType: 'inline'
script: |
cd Apps/WeatherTwentyOne/
dotnet publish WeatherTwentyOne.csproj -f net9.0-ios -c $(buildConfiguration)
- task: Bash@3
displayName: Build Android App
inputs:
targetType: 'inline'
script: |
cd Apps/WeatherTwentyOne/
dotnet build WeatherTwentyOne.csproj -f net9.0-android35.0 -c $(buildConfiguration)
- task: AndroidSigning@3
displayName: 'Signing and aligning APK file(s) **/*.apk'
inputs:
apkFiles: '**/*.apk'
apksign: true
apksignerKeystoreFile: 'app.keystore'
apksignerKeystorePassword: $(keystore.password)
apksignerKeystoreAlias: $(keystore.alias)
apksignerKeyPassword: $(key.password)
zipalign: true
- task: Bash@3
displayName: Rename Signed APK
inputs:
targetType: 'inline'
script: |
cd Apps/WeatherTwentyOne/bin/$(buildConfiguration)/net9.0-android35.0/
mv weathertwentyone.droid-Signed.apk weathertwentyone.apk
- task: CopyFiles@2
inputs:
Contents: |
**/*.apk
**/*.app
**/*.ipa
**/release-notes.txt
TargetFolder: '$(outputDirectory)'
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: '$(outputDirectory)'
ArtifactName: 'drop'
publishLocation: 'Container'