Skip to content

Commit

Permalink
Add .NET MAUI Blazor Hybrid frontend app (#255)
Browse files Browse the repository at this point in the history
## Purpose

Adds a .NET MAUI Blazor Hybrid frontend app that re-uses the same Razor
Components used in the Blazor Web frontend app. This helps showcase how
a .NET MAUI cross-platform app can share the same Azure OpenAI
functionality as a web app, and run natively on Windows, macOS, iOS, and
Android devices.

Fixes #253

## Does this introduce a breaking change?

[ ] Yes
[x] No

## Pull Request Type

What kind of change does this Pull Request introduce?

[ ] Bugfix
[x] Feature
[ ] Code style update (formatting, local variables)
[x] Refactoring (no functional changes, no api changes)
[ ] Documentation content changes
[ ] Other... Please describe:

## How to Test

1. Ensure all existing repo pre-reqs are installed for .NET 8 and Visual
Studio, including the .NET MAUI workload
1. Clone repo
1. Follow the existing steps in the repo to publish the app to Azure
1. Get the URL of the app that was published to Azure Container Apps
(something like `https://MY_HOSTED_APP.example.azurecontainerapps.io/`)
1. Update the file `app/maui-blazor/MauiProgram.cs` to use that URL to
set the `client.BaseAddress` value (there is a 'TODO' note in the file)
1. Set the .NET MAUI Blazor Hybrid app as the startup project
1. Run the app on your device of choice (Windows Desktop, iPhone/iPad,
MacBook, Android phone/tablet, etc.) and you should see a UI that
exactly matches the Blazor Web app, except that the app is running
locally (API calls still go out to Azure as usual)
1. Profit

## Other Information

This PR conceptually has a few areas of change:

1. Almost all of the Razor components in the `app/frontend` Blazor Web
app were moved to a new shared `app/SharedWebComponents` Razor Class
Library (RCL)
2. The Blazor Web app now references that shared code
3. There is a new .NET MAUI Blazor Hybrid project that is mostly just
the default template code. Almost all of the brand new code in this PR
is standard template code. This new project references the same shared
RCL to reuse the exact same functionality and implementation
4. The few notable changes in the apps are:
1. The PDF viewer logic was put behind a service interface so that it
could use a web-based viewer in the Blazor Web app, and in the future a
native PDF viewer in the .NET MAUI app
2. A UI rendering bug was fixed in
`app/SharedWebComponents/Pages/Chat.razor` so that it displays properly
on narrow screens (this was a pre-existing issue in the web app, but was
very noticable on narrow mobile devices)
   3. Some try/catch statements were added in the speech API calls

cc @BretJohnson @mattleibow @maddymontaquila @lutzroeder

---------

Co-authored-by: Bret Johnson <[email protected]>
Co-authored-by: Lutz Roeder <[email protected]>
Co-authored-by: Matthew Leibowitz <[email protected]>
Co-authored-by: Bret Johnson <[email protected]>
Co-authored-by: David Pine <[email protected]>
  • Loading branch information
6 people authored Feb 15, 2024
1 parent 030964c commit caf922e
Show file tree
Hide file tree
Showing 113 changed files with 1,374 additions and 146 deletions.
8 changes: 7 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
},
"envFile": "${input:dotEnvFilePath}"
},
{
"name": "Frontend: .NET MAUI client",
"type": "maui",
"request": "launch",
"preLaunchTask": "maui: Build"
},
{
"name": "Backend: Minimal API",
"type": "coreclr",
Expand Down Expand Up @@ -51,4 +57,4 @@
]
}
]
}
}
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,18 @@ If you have existing resources in Azure that you wish to use, you can configure

Navigate to <http://localhost:7181>, and test out the app.

#### Running locally with the .NET MAUI client

This sample includes a .NET MAUI client, packaging the experience as an app that can run on a Windows/macOS desktop or on Android and iOS devices. The MAUI client here is implemented using Blazor hybrid, letting it share most code with the website frontend.

1. Open _app/app-maui.sln_ to open the solution that includes the MAUI client

1. Edit _app/maui-blazor/MauiProgram.cs_, updating `client.BaseAddress` with the URL for the backend.

If it's running in Azure, use the URL for the service backend from the steps above. If running locally, use <http://localhost:7181>.

1. Set **MauiBlazor** as the startup project and run the app

#### Sharing Environments

Run the following if you want to give someone else access to the deployed and existing environment.
Expand Down
7 changes: 7 additions & 0 deletions app/Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<MauiVersion>8.0.6</MauiVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Azure.AI.FormRecognizer" Version="4.1.0" />
Expand All @@ -16,12 +17,15 @@
<PackageVersion Include="Blazor.SpeechRecognition.WebAssembly" Version="8.0.0" />
<PackageVersion Include="Blazor.SpeechSynthesis.WebAssembly" Version="8.0.0" />
<PackageVersion Include="bunit" Version="1.25.3" />
<PackageVersion Include="CommunityToolkit.Maui" Version="7.0.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.0" />
<PackageVersion Include="Markdig" Version="0.33.0" />
<PackageVersion Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0-beta3" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Storage" Version="6.2.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.16.2" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="1.20.0" />
Expand All @@ -30,7 +34,10 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageVersion Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
<PackageVersion Include="Microsoft.ML" Version="3.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.3.0" />
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
? @Icons.Custom.FileFormats.FilePdf
: null;
<MudChip Variant="Variant.Text" Color="Color.Info"
Icon="@icon" OnClick="@(_ => OnShowCitation(citation))">
Icon="@icon" OnClick="@(_ => OnShowCitationAsync(citation))">
@($"{citation.Number}. {citation.Name}")
</MudChip>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class Answer
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class Answer
{
[Parameter, EditorRequired] public required ApproachResponse Retort { get; set; }
[Parameter, EditorRequired] public required EventCallback<string> FollowupQuestionClicked { get; set; }

[Inject] public required IDialogService Dialog { get; set; }
[Inject] public required IPdfViewer PdfViewer { get; set; }

private HtmlParsedAnswer? _parsedAnswer;

Expand All @@ -26,21 +26,7 @@ private async Task OnAskFollowupAsync(string followupQuestion)
await FollowupQuestionClicked.InvokeAsync(followupQuestion);
}
}

private void OnShowCitation(CitationDetails citation) => Dialog.Show<PdfViewerDialog>(
$"📄 {citation.Name}",
new DialogParameters
{
[nameof(PdfViewerDialog.FileName)] = citation.Name,
[nameof(PdfViewerDialog.BaseUrl)] = citation.BaseUrl,
},
new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true,
CloseOnEscapeKey = true
});
private ValueTask OnShowCitationAsync(CitationDetails citation) => PdfViewer.ShowDocumentAsync(citation.Name, citation.BaseUrl);

private MarkupString RemoveLeadingAndTrailingLineBreaks(string input) => (MarkupString)HtmlLineBreakRegex().Replace(input, "");

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class AnswerError
{
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class Examples
{
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class SettingsPanel : IDisposable
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class SupportingContent
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class SupportingContent
{
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class VoiceDialog : IDisposable
{
Expand All @@ -21,7 +21,20 @@ protected override async Task OnInitializedAsync()
_state = RequestVoiceState.RequestingVoices;

await GetVoicesAsync();
SpeechSynthesis.OnVoicesChanged(() => GetVoicesAsync(true));

try
{
SpeechSynthesis.OnVoicesChanged(() => GetVoicesAsync(true));
}
catch
{
// TODO: Find a better way to do this
// The Blazor.SpeechSynthesis.WebAssembly API supports listening to changes,
// however the underlying code does not do this using a DI-friendly way.
// The code assumes the concrete implementation for the ISpeechSynthesisService
// service is the concrete Web Assembly type which is not valid.
// There is no alternative API that MAUI apps can use.
}

_voicePreferences = new VoicePreferences(LocalStorage);

Expand Down Expand Up @@ -55,7 +68,22 @@ private void OnValueChanged(string selectedVoice) => _voicePreferences = _voiceP

private void OnCancel() => Dialog.Close(DialogResult.Ok(_voicePreferences));

public void Dispose() => SpeechSynthesis.UnsubscribeFromVoicesChanged();
public void Dispose()
{
try
{
SpeechSynthesis.UnsubscribeFromVoicesChanged();
}
catch
{
// TODO: Find a better way to do this
// The Blazor.SpeechSynthesis.WebAssembly API supports listening to changes,
// however the underlying code does not do this using a DI-friendly way.
// The code assumes the concrete implementation for the ISpeechSynthesisService
// service is the concrete Web Assembly type which is not valid.
// There is no alternative API that MAUI apps can use.
}
}
}

internal enum RequestVoiceState
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Components;
namespace SharedWebComponents.Components;

public sealed partial class VoiceTextInput : IDisposable
{
Expand Down Expand Up @@ -103,7 +103,7 @@ private void OnRecognized(string transcript)
Value = Value switch
{
null => transcript,
_ => $"{Value.Trim()} {transcript}".Trim()
_ => $"{Value.Trim()} {transcript.Trim()}"
};

StateHasChanged();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Extensions;
namespace SharedWebComponents.Extensions;

public static class LongExtensions
{
Expand Down
26 changes: 26 additions & 0 deletions app/SharedWebComponents/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.

global using System.Globalization;
global using System.Net.Http.Json;
global using System.Runtime.CompilerServices;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Text.RegularExpressions;
global using SharedWebComponents.Components;
global using SharedWebComponents.Extensions;
global using SharedWebComponents.Models;
global using SharedWebComponents.Services;
global using Markdig;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Forms;
global using Microsoft.AspNetCore.Components.Routing;
global using Microsoft.AspNetCore.Components.Web;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using Microsoft.JSInterop;
global using MudBlazor;
global using Shared.Json;
global using Shared.Models;

[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ClientApp.Tests")]
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public readonly record struct AnswerResult<TRequest>(
bool IsSuccessful,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public record class AzureCulture
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public record CitationDetails(string Name, string BaseUrl, int Number = 0);
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public enum LanguageDirection
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public record RequestSettingsOverrides
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public record class SharedCultures
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public readonly record struct UserQuestion(
string Question,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Models;
namespace SharedWebComponents.Models;

public record class VoicePreferences
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
else
{
<MudBadge Origin="Origin.TopLeft" Overlap="true" Color="Color.Secondary"
Icon="@Icons.Material.Filled.AutoAwesome">
Icon="@Icons.Material.Filled.AutoAwesome"
Style="display:inherit">
<Answer Retort="@answer" FollowupQuestionClicked="@OnAskQuestionAsync" />
</MudBadge>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

namespace ClientApp.Pages;
namespace SharedWebComponents.Pages;

public sealed partial class Chat
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
<RowTemplate>
<MudTd DataLabel="Preview" Style="text-align:center">
<MudFab Color="Color.Primary" StartIcon="@Icons.Material.Filled.Pageview"
Size="Size.Small" OnClick="@(() => OnShowDocument(context))" />
Size="Size.Small" OnClick="@(() => OnShowDocumentAsync(context))" />
</MudTd>
<MudTd DataLabel="Status" Style="text-align:center">
@{
Expand Down
Loading

0 comments on commit caf922e

Please sign in to comment.