Eerste commit

This commit is contained in:
jf
2026-09-27 14:21:31 +00:00
commit de4ff128cb
295 changed files with 47374 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
<?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"
x:Class="MijnBesluit.App.App">
</Application>
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
public partial class App : Application
{
private readonly Terugknop _terugknop;
public App(Terugknop terugknop)
{
InitializeComponent();
_terugknop = terugknop;
}
protected override Window CreateWindow(IActivationState? activationState)
{
var venster = new Window(new HoofdPagina(_terugknop)) { Title = "MijnBesluit" };
// Android verbreekt de socketverbinding zodra de app een tijdje op de achtergrond staat -
// bijvoorbeeld terwijl iemand zijn e-mail leest om de inlogcode op te halen. Bij het
// terugkomen halen we de gegevens opnieuw op, zodat de melding "geen verbinding"
// verdwijnt zonder dat de gebruiker ergens op hoeft te drukken.
venster.Resumed += async (_, _) =>
{
var diensten = IPlatformApplication.Current?.Services;
// Eerst kijken of de app zichzelf had moeten uitloggen terwijl hij in de achtergrond
// stond. Dat is het geval waar automatisch uitloggen voor bedoeld is - een telefoon
// die een uur in een tas zit - en het hoort te gebeuren vóór we gegevens ophalen.
diensten?.GetService<Uitlogklok>()?.Hervatten();
var toestand = diensten?.GetService<AppToestand>();
if (toestand is { IsAangemeld: true }) await toestand.VerversAsync();
};
return venster;
}
}
@@ -0,0 +1,20 @@
@namespace MijnBesluit.App
@* De ingang van de app. De schermen zelf staan in MijnBesluit.AppUi, zodat website en app
dezelfde blijven; hier wordt alleen verteld waar ze te vinden zijn. *@
<Router AppAssembly="@typeof(Hoofdscherm).Assembly"
AdditionalAssemblies="new[] { typeof(MijnBesluit.AppUi.Schermen.Start).Assembly }">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(AppSchil)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(AppSchil)">
<div class="leeg">
<span class="teken">&#129300;</span>
Dit scherm bestaat niet. Ga terug naar het <a href="/">overzicht</a>.
</div>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,7 @@
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using MijnBesluit.App
@using MijnBesluit.AppUi
@using MijnBesluit.AppUi.Layout
@using MijnBesluit.AppUi.Schermen
@using MijnBesluit.Client
+17
View File
@@ -0,0 +1,17 @@
<?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:b="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
xmlns:local="clr-namespace:MijnBesluit.App"
x:Class="MijnBesluit.App.HoofdPagina"
BackgroundColor="#faf7f2">
<!-- De hele app is één webview met de Blazor-schermen erin. Dezelfde schermen als op
de website, zodat een wijziging maar op één plek hoeft. -->
<b:BlazorWebView x:Name="webview" HostPage="wwwroot/index.html">
<b:BlazorWebView.RootComponents>
<b:RootComponent Selector="#app" ComponentType="{x:Type local:Hoofdscherm}" />
</b:BlazorWebView.RootComponents>
</b:BlazorWebView>
</ContentPage>
+24
View File
@@ -0,0 +1,24 @@
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
public partial class HoofdPagina : ContentPage
{
private readonly Terugknop _terugknop;
public HoofdPagina(Terugknop terugknop)
{
InitializeComponent();
_terugknop = terugknop;
}
/// <summary>
/// De terugknop van het toestel. Android stuurt hem hierheen; geven we false terug, dan doet
/// het toestel wat het altijd deed en gaat de app dicht.
///
/// De schermen bepalen zelf wat terug betekent - een popup dicht, terug naar het overzicht -
/// via <see cref="Terugknop"/>. Hier staat alleen de doorgifte, want de webview zelf weet
/// niets van de knoppen van het toestel.
/// </summary>
protected override bool OnBackButtonPressed() => _terugknop.Ingedrukt();
}
+71
View File
@@ -0,0 +1,71 @@
using MijnBesluit.AppUi;
using MijnBesluit.Client;
namespace MijnBesluit.App;
public static class MauiProgram
{
/// <summary>
/// Waar de SocketServer draait. Voor het testen op een emulator wijst 10.0.2.2 naar je
/// eigen pc; op een echt toestel vul je hier de servernaam in.
/// </summary>
public const string Server = "mijnbesluit.nl";
public const int Poort = 5200;
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddMauiBlazorWebView();
// Eén verbinding en één sessie voor de hele app: alle schermen delen hem.
builder.Services.AddSingleton<ITokenopslag, ToestelTokenopslag>();
builder.Services.AddSingleton(sp => new MijnBesluitClient(
Server, Poort, tls: true, sp.GetRequiredService<ITokenopslag>()));
builder.Services.AddSingleton(sp => new AppToestand(
sp.GetRequiredService<MijnBesluitClient>(), sp.GetRequiredService<IMeldingen>()));
// Het kiezen van bestanden en het opnemen van video: dat kan de webview niet zelf.
builder.Services.AddSingleton<IBestandskiezer, Toestelbestanden>();
// Een advertentie opent bij de adverteerder, in de browser van de telefoon en niet in de
// app. Ook dat kan de webview niet zelf.
builder.Services.AddSingleton<IBuitenlink, Toestelbuitenlink>();
// De terugknop van het toestel. Eén exemplaar voor de hele app: de pagina geeft de druk
// erin, de schermen in de webview melden zich erop aan.
builder.Services.AddSingleton<Terugknop>();
// Het "trek naar beneden om te verversen"-gebaar: de schil meldt hier dat er is
// getrokken, het scherm dat op dat moment openstaat luistert en haalt zijn gegevens
// opnieuw op. Zie VernieuwSignaal.
builder.Services.AddSingleton<VernieuwSignaal>();
// Het slot van het toestel: vingerafdruk, gezicht of de code van de telefoon zelf.
builder.Services.AddSingleton<IToestelslot, Toestelslot>();
// Instellingen die alleen op dit toestel gelden, en de klok die de app zichzelf laat
// vergrendelen. Eén exemplaar, want de schil bewaakt hem en het instellingenscherm zet hem.
//
// De standaardtermijn hangt af van het toestel: kan de telefoon om een vingerafdruk of
// code vragen, dan gaat de klok standaard aan - dan kost vergeten je één aanraking.
// Kan dat niet, dan kost vergeten je een e-mail met een code, en dat leggen we niemand
// ongevraagd op.
builder.Services.AddSingleton<IAppinstellingen, Toestelinstellingen>();
builder.Services.AddSingleton(sp => new Uitlogklok(
sp.GetRequiredService<IAppinstellingen>(),
sp.GetRequiredService<IToestelslot>().Beschikbaar ? Uitlogklok.StandaardMetSlot : 0,
sp.GetRequiredService<IToestelslot>().Beschikbaar));
// Meldingen op het toestel. Zonder Firebase levert dit niets op en werkt de app
// gewoon door; zie docs/Meldingen.md.
builder.Services.AddSingleton<IMeldingen, Toestelmeldingen>();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
return builder.Build();
}
}
+106
View File
@@ -0,0 +1,106 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<!-- De schermen zijn voor allebei dezelfde; alleen wat het toestel zelf doet verschilt.
Zie Platforms\ en de #if-blokken in Toestelslot.cs en Toestelmeldingen.cs.
iOS staat standaard UIT, en dat is geen halfheid maar de enige werkbare stand. Zodra
net10.0-ios in deze lijst staat, wil elke build - ook een build van alleen de APK - de
iOS-werkbelasting en een Mac hebben. Op een machine zonder die twee struikelt dan het
maken van de Android-app over iets wat er niets mee te maken heeft.
Aanzetten doe je per keer:
dotnet build -p:MetIos=true
tools\Apk-maken.ps1 (blijft gewoon Android, hier verandert niets)
of blijvend, door MetIos in een Directory.Build.props of in de omgeving te zetten op de
machine die wél aan een Mac hangt. Zie docs/iOS-app.md. -->
<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="'$(MetIos)' == 'true'">$(TargetFrameworks);net10.0-ios</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>MijnBesluit.App</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>MijnBesluit</ApplicationTitle>
<ApplicationId>nl.mijnbesluit.app</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
<!-- Op papier kan MAUI vanaf iOS 12.2, maar onder 13 zijn er meldingen van apps die op een
echt toestel omvallen, en 15.0 is wat het sjabloon zelf gebruikt. Dat draait op een
iPhone 6s en nieuwer - toestellen uit 2015. Lager gaan levert je een handvol toestellen
op en een soort fouten dat je niet kunt nabootsen. -->
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<!-- Het echte logo. Het pictogram is alleen het witte woordmerk op een doorzichtige
achtergrond, met het logogroen eronder: zo maakt Android er zelf een rond, vierkant of
druppelvormig pictogram van zonder dat er letters afvallen. Het woordmerk staat daarom
op zeventig procent - alles buiten die cirkel kan een toestel wegsnijden.
Het startscherm laat het hele logo zien op de rustige achtergrondkleur. -->
<MauiIcon Include="Resources\AppIcon\appicon.png" Color="#C1D13F" />
<MauiSplashScreen Include="Resources\Splash\splash.png" Color="#faf7f2" BaseSize="128,128" />
<MauiFont Include="Resources\Fonts\*" />
</ItemGroup>
<ItemGroup>
<!-- Sinds .NET 8 brengt <UseMaui>true</UseMaui> deze packages niet meer vanzelf mee.
$(MauiVersion) volgt de werkbelasting die op deze machine staat, dus hier hoeft geen
versienummer met de hand bijgehouden te worden. -->
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
<ProjectReference Include="..\MijnBesluit.AppUi\MijnBesluit.AppUi.csproj" />
</ItemGroup>
<!-- Meldingen op de telefoon, via Firebase.
Dit blok doet niets zolang google-services.json er niet staat, en gaat vanzelf aan zodra
je hem uit de Firebase-console in Platforms\Android zet. Zo is er geen stap "en vergeet
het csproj niet aan te passen" die je een keer overslaat, en blijft het project bouwen op
een machine waar dat bestand niet staat.
De schakelaar FIREBASE hoort bij de #if in Toestelmeldingen.cs: zonder het package is die
code niet te compileren, dus die staat uit tot het package er is.
Zie docs/Meldingen.md. -->
<PropertyGroup Condition="Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
<DefineConstants>$(DefineConstants);FIREBASE</DefineConstants>
</PropertyGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
<!-- Binding op com.google.firebase:firebase-messaging. Het versienummer moet passen bij de
werkbelasting op deze machine; 125.1.1.1 hoort bij net10.0-android. Klaagt de build over
versies, kijk dan in NuGet welke bij jouw .NET-versie hoort. -->
<PackageReference Include="Xamarin.Firebase.Messaging" Version="125.1.1.1" />
<!-- Firebase's afhankelijkheden pinnen oudere AndroidX .Ktx-packages, terwijl MAUI de
bijbehorende basispackages al nieuwer binnenhaalt. Dat gaf niet alleen de NU1608-
waarschuwingen, maar ook een harde fout bij het dexen: sinds AndroidX Fragment 1.9.0
zitten de Kotlin-uitbreidingen in het hoofdpackage zelf, dus kwam bijvoorbeeld
androidx.fragment.app.FragmentManagerKt twee keer binnen: een keer uit fragment.aar
en een keer uit het oude fragment-ktx.aar.
Door deze vijf expliciet op de versie te zetten die bij het al opgeloste basispackage
hoort, zijn de .Ktx-packages leeg (ze verwijzen alleen nog naar het hoofdpackage) en
is de dubbele klasse weg. Verhoogt een nieuwere MAUI de basispackages, dan komen de
NU1608-waarschuwingen terug; dat is het signaal om deze nummers mee te verhogen, niet
om het blok weg te halen. De nummers komen uit NuGet: Fragment.Ktx 1.9.0 bestaat,
Collection.Ktx 1.6.0.1, en de drie Lifecycle-packages op 2.11.0.1. -->
<PackageReference Include="Xamarin.AndroidX.Fragment.Ktx" Version="1.9.0" />
<PackageReference Include="Xamarin.AndroidX.Collection.Ktx" Version="1.6.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData" Version="2.11.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData.Core.Ktx" Version="2.11.0.1" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
<GoogleServicesJson Include="Platforms\Android\google-services.json" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true"
android:icon="@mipmap/appicon"
android:roundIcon="@mipmap/appicon_round"
android:supportsRtl="true">
<!-- Op welk kanaal een melding terechtkomt die binnenkomt terwijl de app dicht is. Zonder
deze regel maakt Firebase daar zelf een kanaal "Overig" voor aan, en staat dezelfde
soort melding dus op twee plekken in de instellingen van de telefoon. De naam hoort
gelijk te zijn aan Meldingsdienst.Kanaal. -->
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="mijnbesluit-berichten" />
</application>
<!-- Om de app weer te openen nadat hij zichzelf heeft vergrendeld. Dit geeft geen toegang
tot vingerafdrukken: de app krijgt alleen ja of nee terug van het toestel. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- De app praat met de SocketServer. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera en galerij, voor foto's bij een kluisitem en video's bij een moment.
Hier stond eerder bewust niets: hoe minder rechten, hoe minder er mis kan gaan. Maar
zonder CAMERA meldt Android dat het toestel niet kan opnemen, en verdwijnt de knop
"Video opnemen" uit de app zonder dat er iets misgaat of iets over te zien is.
Wat er NIET bij staat en ook niet bij hoort: locatie, contacten, en het schrijven naar
de opslag van de telefoon. We lezen alleen wat de gebruiker zelf aanwijst. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Voor Android 12 en ouder; vanaf 13 doen de twee regels hierboven het werk. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Niet verplicht: een toestel zonder camera mag de app gewoon installeren, dan valt
alleen de opnameknop weg. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -0,0 +1,14 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace MijnBesluit.App;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
@@ -0,0 +1,24 @@
using Android.App;
using Android.Runtime;
namespace MijnBesluit.App;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) { }
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override void OnCreate()
{
base.OnCreate();
#if FIREBASE
// Het meldingenkanaal moet er zijn voordat de eerste melding binnenkomt, ook als die
// binnenkomt terwijl de app dicht is. Hier en niet in de dienst zelf, want dit is het
// eerste wat draait.
Meldingsdienst.MaakKanaal(this);
#endif
}
}
@@ -0,0 +1,104 @@
#if FIREBASE
using Android.App;
using Android.Content;
using AndroidX.Core.App;
using Firebase.Messaging;
namespace MijnBesluit.App;
/// <summary>
/// Vangt meldingen op die binnenkomen terwijl de app openstaat.
///
/// Android zet een melding alleen zelf in de balk als de app op de achtergrond staat. Staat hij
/// open, dan geeft Android hem door aan deze dienst en gebeurt er verder niets - tenzij we hem
/// hier zelf tonen. Dat was precies het geval waarin de proefmelding "verstuurd" heette en er
/// op de telefoon niets te zien was.
///
/// De tekst komt uit het data-deel van het bericht en niet uit het notification-deel. Beide
/// staan erin (zie FirebasePush), maar het data-deel is het stuk waar de naamgeving in de
/// Android-binding niet per versie verschilt.
/// </summary>
[Service(Exported = false)]
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
public class Meldingsdienst : FirebaseMessagingService
{
/// <summary>
/// Het kanaal waarop deze meldingen binnenkomen. Vanaf Android 8 hoort elke melding bij een
/// kanaal, en kan de gebruiker per kanaal zeggen hoe opdringerig het mag zijn. De naam staat
/// ook in AndroidManifest.xml, zodat meldingen die binnenkomen terwijl de app dicht is op
/// hetzelfde kanaal terechtkomen en niet onder "Overig".
/// </summary>
public const string Kanaal = "mijnbesluit-berichten";
/// <summary>
/// Maakt het kanaal aan als het er nog niet is. Mag zo vaak aangeroepen worden als je wilt;
/// Android werkt een bestaand kanaal dan alleen bij. Gebeurt bij het starten van de app, want
/// een melding die binnenkomt terwijl de app dicht is heeft het kanaal al nodig.
/// </summary>
public static void MaakKanaal(Context context)
{
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;
var kanaal = new NotificationChannel(Kanaal, "Berichten", NotificationImportance.High)
{
Description = "Laat weten dat er iets voor je klaarstaat. Wat erin staat komt er nooit bij te staan."
};
var beheer = (NotificationManager?)context.GetSystemService(Context.NotificationService);
beheer?.CreateNotificationChannel(kanaal);
}
public override void OnMessageReceived(RemoteMessage bericht)
{
var gegevens = bericht.Data;
gegevens.TryGetValue("titel", out var titel);
gegevens.TryGetValue("tekst", out var tekst);
if (string.IsNullOrWhiteSpace(titel)) titel = "MijnBesluit";
if (string.IsNullOrWhiteSpace(tekst)) tekst = "Er staat iets voor je klaar.";
Toon(this, titel, tekst);
}
/// <summary>
/// Firebase geeft een toestel af en toe een nieuw kenmerk. Het oude werkt dan niet meer.
/// De app meldt zich bij elke aanmelding opnieuw aan met het kenmerk van dat moment, dus dat
/// herstelt zichzelf zodra je weer inlogt. Zolang je ingelogd blijft kan er een melding
/// tussendoor vallen; dat is bewust niet hier opgelost, want hier is geen verbinding met de
/// server en geen sleutel om mee te praten.
/// </summary>
public override void OnNewToken(string kenmerk) => base.OnNewToken(kenmerk);
private static void Toon(Context context, string titel, string tekst)
{
MaakKanaal(context);
// Aantikken opent de app. SingleTop, zodat je niet een tweede keer hetzelfde scherm
// opent bovenop het scherm waar je al was.
var openen = new Intent(context, typeof(MainActivity));
openen.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
var tik = PendingIntent.GetActivity(context, 0, openen,
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
// ApplicationInfo.Icon en niet een eigen tekening: dat is een nummer dat Android zelf
// bijhoudt, dus hier hoeft geen tweede plaatje bijgehouden te worden dat kan ontbreken.
var melding = new NotificationCompat.Builder(context, Kanaal)
.SetContentTitle(titel)
.SetContentText(tekst)
.SetStyle(new NotificationCompat.BigTextStyle().BigText(tekst))
.SetSmallIcon(context.ApplicationInfo!.Icon)
.SetAutoCancel(true)
.SetPriority((int)NotificationPriority.High)
.SetContentIntent(tik)
.Build();
// Elk bericht een eigen nummer, anders vervangt de tweede melding de eerste.
NotificationManagerCompat.From(context).Notify(Volgnummer(), melding);
}
private static int _laatste;
private static int Volgnummer() => Interlocked.Increment(ref _laatste);
}
#endif
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "1047194010071",
"project_id": "mijnbesluit-f04b5",
"storage_bucket": "mijnbesluit-f04b5.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1047194010071:android:815622efe5c7b59a084b92",
"android_client_info": {
"package_name": "nl.mijnbesluit.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDPDKCc6tQm8Juh9LwvvWlxmUFTrMCU4So"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
@@ -0,0 +1,13 @@
using Foundation;
namespace MijnBesluit.App;
/// <summary>
/// De iOS-kant van het opstarten. Tegenhanger van MainApplication op Android: hij doet niets
/// meer dan dezelfde MauiProgram aanroepen, want alles wat de app ís staat in gedeelde code.
/// </summary>
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<!-- 1 = iPhone, 2 = iPad. De schermen zijn gemaakt voor een telefoon, maar een iPad
weigeren levert alleen een gebruiker op die het niet snapt. -->
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<!-- ==========================================================================
Waarvoor de app iets van het toestel vraagt.
Deze teksten zijn geen formaliteit. iOS laat ze letterlijk zien op het moment
dat de app het vraagt, en als de tekst ontbreekt sluit iOS de app ter plekke
af - geen foutmelding, hij is gewoon weg. Dat is de klassieke "hij doet het
op Android wel"-val.
Ze staan in het Nederlands omdat de app Nederlands is; iemand die hier "ja"
op moet zeggen, hoort te kunnen lezen waarvoor.
========================================================================== -->
<key>NSFaceIDUsageDescription</key>
<string>Om MijnBesluit te openen zonder telkens een code uit je mail te halen. Je gezicht wordt door je telefoon gecontroleerd; MijnBesluit krijgt het nooit te zien.</string>
<key>NSCameraUsageDescription</key>
<string>Om een foto of video te maken die je in je kluis bewaart of als bericht meestuurt.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Om het geluid op te nemen bij een videobericht.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Om een foto of document uit je fotobibliotheek toe te voegen aan je kluis of aan een bericht.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Om een foto uit MijnBesluit op te slaan in je fotobibliotheek.</string>
<!-- ==========================================================================
BEWUST NOG NIET INGEVULD: ITSAppUsesNonExemptEncryption.
Apple vraagt bij elke inzending of de app encryptie gebruikt die buiten de
vrijstellingen valt. Zet je hier `false`, dan verklaar je dat dat niet zo is -
en dat is een verklaring met juridisch gewicht, geen vinkje. Deze app
versleutelt een kluis met gegevens, dus dat antwoord is niet vanzelfsprekend
"nee" zoals bij een app die alleen HTTPS gebruikt.
Laat dit staan zoals het is: zonder deze sleutel vraagt App Store Connect het
bij elke inzending, en dan beantwoord je het bewust. Wil je er vanaf, laat het
dan eerst nakijken door iemand die deze regels kent, en zet het antwoord er
daarna bij met de datum en de reden erbij in dit bestand.
Wat hier NIET hoort: een uitzondering op App Transport Security. De verbinding
met de SocketServer is een eigen TLS-socket en geen URL-verkeer, dus ATS gaat
er niet over. Een uitzondering toevoegen die je niet nodig hebt, levert alleen
vragen op bij de beoordeling.
========================================================================== -->
</dict>
</plist>
@@ -0,0 +1,14 @@
using ObjCRuntime;
using UIKit;
namespace MijnBesluit.App;
/// <summary>Het startpunt op iOS. Android heeft dit niet nodig; daar start Android de Activity.</summary>
public class Program
{
private static void Main(string[] args)
{
// Vanaf hier neemt UIKit het over en roept het AppDelegate aan.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+36
View File
@@ -0,0 +1,36 @@
using MijnBesluit.Client;
namespace MijnBesluit.App;
/// <summary>
/// Bewaart het sessietoken in de beveiligde opslag van het toestel: op Android is dat de
/// Keystore, op iOS de Keychain. Dus niet in een gewoon bestand, want daar kan een andere
/// app of iemand met het toestel in handen bij.
/// </summary>
public class ToestelTokenopslag : ITokenopslag
{
private const string Sleutel = "mijnbesluit-sessie";
public async Task<string?> LeesAsync()
{
try { return await SecureStorage.Default.GetAsync(Sleutel); }
catch
{
// Op sommige toestellen is de beveiligde opslag stuk na een herstel uit back-up.
// Dan is er gewoon geen sessie en logt de gebruiker opnieuw in.
return null;
}
}
public async Task SchrijfAsync(string token)
{
try { await SecureStorage.Default.SetAsync(Sleutel, token); }
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
}
public Task WisAsync()
{
SecureStorage.Default.Remove(Sleutel);
return Task.CompletedTask;
}
}
+159
View File
@@ -0,0 +1,159 @@
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Graphics.Platform;
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
/// <summary>
/// Het kiezen en maken van bestanden op een Android- of iOS-toestel, met de kiezers die MAUI
/// daarvoor meelevert. De schermen zien hier niets van; die kennen alleen
/// <see cref="IBestandskiezer"/>.
///
/// Foto's worden hier verkleind voordat ze de deur uit gaan. Een toestelcamera maakt tegenwoordig
/// bestanden van tien megabyte of meer, en die gaan in stukken van een halve megabyte over de
/// lijn - op een mobiele verbinding duurt dat te lang voor iets waar je alleen maar naar kijkt.
/// Naar 1600 pixels terugbrengen scheelt een factor tien tot twintig en is op een telefoon niet
/// te zien. Video's blijven zoals ze zijn: opnieuw coderen kost meer tijd dan het oplevert.
/// </summary>
public class Toestelbestanden : IBestandskiezer
{
/// <summary>De langste zijde van een foto die wordt verstuurd.</summary>
public const int MaxZijde = 1600;
/// <summary>Hoe hard er op de foto mag worden ingeleverd. 0.8 is ruim voldoende om te tonen.</summary>
private const float Kwaliteit = 0.8f;
/// <summary>Onder deze grootte is verkleinen de moeite niet waard.</summary>
private const int VerkleinVanaf = 400 * 1024;
public bool KanCamera => MediaPicker.Default.IsCaptureSupported;
public async Task<GekozenBestand?> KiesDocumentAsync()
{
var gekozen = await FilePicker.Default.PickAsync(new PickOptions
{
PickerTitle = "Kies een document"
});
return await LeesAsync(gekozen, "application/octet-stream");
}
public async Task<GekozenBestand?> MaakFotoAsync()
{
if (!MediaPicker.Default.IsCaptureSupported) return null;
// De camera geeft een naam van tweeëndertig tekens zonder betekenis. Die staat straks in
// je kluis en zegt je niets; het moment waarop je de foto maakte wel.
return await LeesAsync(await MediaPicker.Default.CapturePhotoAsync(), "image/jpeg",
Eigennaam("Foto", ".jpg"));
}
public async Task<GekozenBestand?> KiesFotoAsync()
{
// PickPhotosAsync in plaats van PickPhotoAsync: die laatste is vervallen. We nemen de
// eerste foto; meerdere tegelijk kiezen komt later als het nodig blijkt.
var gekozen = await MediaPicker.Default.PickPhotosAsync();
return await LeesAsync(gekozen?.FirstOrDefault(), "image/jpeg");
}
public async Task<GekozenBestand?> NeemVideoOpAsync()
{
if (!MediaPicker.Default.IsCaptureSupported) return null;
return await LeesAsync(await MediaPicker.Default.CaptureVideoAsync(), "video/mp4",
Eigennaam("Video", ".mp4"));
}
public async Task<GekozenBestand?> KiesVideoAsync()
{
// PickVideosAsync in plaats van PickVideoAsync, om dezelfde reden als bij de foto's: die
// laatste is vervallen. We nemen de eerste video; meerdere tegelijk kiezen komt later als
// het nodig blijkt.
var gekozen = await MediaPicker.Default.PickVideosAsync();
return await LeesAsync(gekozen?.FirstOrDefault(), "video/mp4");
}
/// <summary>
/// Leest het gekozen bestand helemaal in. Dat kan omdat er een grens op de grootte staat;
/// zonder die grens zou een lange video-opname het geheugen van de app opmaken.
/// </summary>
private static async Task<GekozenBestand?> LeesAsync(FileResult? gekozen, string standaardSoort,
string? naam = null)
{
if (gekozen is null) return null;
using var stroom = await gekozen.OpenReadAsync();
using var buffer = new MemoryStream();
await stroom.CopyToAsync(buffer);
var soort = string.IsNullOrWhiteSpace(gekozen.ContentType) ? standaardSoort : gekozen.ContentType;
var inhoud = buffer.ToArray();
if (soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && inhoud.Length > VerkleinVanaf)
inhoud = Verklein(inhoud) ?? inhoud;
return new GekozenBestand(naam ?? Bruikbaar(gekozen.FileName), soort, inhoud);
}
/// <summary>
/// Een naam waar je later nog iets aan hebt: "Foto 15-09-2026 14.10". De tijd erbij, want op
/// één dag maak je er zo drie en dan wil je ze uit elkaar kunnen houden.
/// </summary>
private static string Eigennaam(string soort, string extensie) =>
// De punt tussen uren en minuten staat tussen enkele aanhalingstekens. Dat is hoe een
// opmaakpatroon in .NET zegt "letterlijk dit teken"; een backslash zou hier een
// ongeldige ontsnappingsreeks in C# zijn, en een kale punt hoort bij .fff.
$"{soort} {DateTime.Now:dd-MM-yyyy HH'.'mm}{extensie}";
/// <summary>
/// Een naam die het toestel zelf aandraagt, tenzij die nergens op slaat. Een galerij geeft
/// soms alleen een lange reeks tekens terug; daar is een datum beter dan.
/// </summary>
private static string Bruikbaar(string? naam)
{
if (string.IsNullOrWhiteSpace(naam)) return Eigennaam("Bestand", "");
var kaal = Path.GetFileNameWithoutExtension(naam);
var extensie = Path.GetExtension(naam);
// Twintig tekens of meer, en geen enkele klinker of streepje: dat is geen naam maar een
// kenmerk. Denk aan 824c9d46a1b749ab9f92986b.
var onleesbaar = kaal.Length >= 20 && kaal.All(char.IsLetterOrDigit)
&& !kaal.Any(c => "aeiouAEIOU".Contains(c));
return onleesbaar ? Eigennaam("Bestand", extensie) : naam;
}
/// <summary>
/// Brengt een foto terug tot <see cref="MaxZijde"/> pixels op de langste zijde.
///
/// Gaat er iets mis - een bestand dat geen afbeelding blijkt, een formaat dat het toestel
/// niet kent - dan geven we null terug en gaat het origineel gewoon de deur uit. Een foto
/// die traag verstuurt is vervelend; een foto die helemaal niet aankomt is erger.
/// </summary>
private static byte[]? Verklein(byte[] inhoud)
{
try
{
using var bron = new MemoryStream(inhoud);
using var afbeelding = PlatformImage.FromStream(bron);
// Downsize doet niets als de foto al kleiner is; dan zijn we alsnog klaar, want het
// opnieuw opslaan levert bij die grootte weinig op.
if (afbeelding.Width <= MaxZijde && afbeelding.Height <= MaxZijde) return null;
using var kleiner = afbeelding.Downsize(MaxZijde, disposeOriginal: false);
using var uit = new MemoryStream();
kleiner.Save(uit, ImageFormat.Jpeg, Kwaliteit);
var verkleind = uit.ToArray();
// Alleen als het echt scheelt. Een enkele keer is het resultaat groter dan het
// origineel; dan is het origineel de betere keus.
return verkleind.Length > 0 && verkleind.Length < inhoud.Length ? verkleind : null;
}
catch (Exception)
{
return null;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
/// <summary>
/// Opent een adres in de browser van de telefoon. Bewust buiten de app: een advertentielink die
/// binnen de webview opent maakt van MijnBesluit een browser, en dan staat iemand op de website
/// van een adverteerder zonder weg terug naar zijn eigen kluis.
/// </summary>
public class Toestelbuitenlink : IBuitenlink
{
public async Task OpenAsync(string adres)
{
if (!Uri.TryCreate(adres, UriKind.Absolute, out var adresje)) return;
// Alleen http en https. Een ander schema kan een andere app openen, en dat is niet wat
// hier hoort te gebeuren.
if (adresje.Scheme != Uri.UriSchemeHttp && adresje.Scheme != Uri.UriSchemeHttps) return;
try
{
await Browser.Default.OpenAsync(adresje, BrowserLaunchMode.SystemPreferred);
}
catch (Exception)
{
// Geen browser op dit toestel, of de gebruiker brak het af. Dat is geen reden om de
// app te laten vallen.
}
}
}
@@ -0,0 +1,25 @@
using MijnBesluit.AppUi;
namespace MijnBesluit.App;
/// <summary>
/// Instellingen die alleen op dit toestel gelden, in de gewone voorkeursopslag van Android.
///
/// Bewust niet in de Keystore: daar hoort alleen in wat geheim is. Hoe lang het duurt voordat
/// de app zichzelf uitlogt is geen geheim, en de Keystore is traag en kan stuk na een herstel
/// uit back-up.
/// </summary>
public class Toestelinstellingen : IAppinstellingen
{
public int Lees(string sleutel, int standaard)
{
try { return Preferences.Default.Get(sleutel, standaard); }
catch { return standaard; }
}
public void Schrijf(string sleutel, int waarde)
{
try { Preferences.Default.Set(sleutel, waarde); }
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
}
}
+79
View File
@@ -0,0 +1,79 @@
using MijnBesluit.AppUi;
#if ANDROID && FIREBASE
// AsAsync() maakt van een Task van Google Play Services een gewone await-bare Task. Het is een
// uitbreidingsmethode, dus zonder deze using bestaat hij niet en zegt de compiler alleen dat
// "Task" hem niet kent. Staat achter dezelfde schakelaar als de code die hem gebruikt: zonder
// het Firebase-package bestaat deze naamruimte niet.
using Android.Gms.Extensions;
#endif
namespace MijnBesluit.App;
/// <summary>
/// Haalt het Firebase-kenmerk van dit toestel op.
///
/// Let op: de regels hieronder met <c>#if ANDROID</c> werken pas als het package
/// <c>Xamarin.Firebase.Messaging</c> in MijnBesluit.App zit én <c>google-services.json</c>
/// in Platforms\Android staat. Zolang dat er niet is, geeft deze klasse null terug en werkt
/// de app gewoon - alleen zonder meldingen. Zie docs/Meldingen.md.
///
/// Op iOS is dat nu ook zo: er komt geen kenmerk en dus geen melding, en de rest van de app
/// werkt gewoon. Dat is met opzet geen half werkende poging. Meldingen op iOS lopen via APNs,
/// en daarvoor is meer nodig dan code: een APNs-sleutel uit het Apple Developer-account, de
/// Push Notifications-capability, een provisioning profile en een Entitlements.plist. Dat hangt
/// aan het account en niet aan dit bestand, dus het komt erbij zodra dat account er is.
/// Zie docs/iOS-app.md.
/// </summary>
public class Toestelmeldingen : IMeldingen
{
public string Platform =>
#if IOS
"ios";
#else
"android";
#endif
public string? Toestelnaam => DeviceInfo.Current.Name;
public async Task<string?> KenmerkAsync()
{
// Android 13 en hoger vraagt apart toestemming voor meldingen. Zonder die toestemming
// komt er wel een kenmerk, maar ziet de gebruiker nooit iets.
if (!await ToestemmingAsync()) return null;
#if ANDROID && FIREBASE
try
{
// GetToken staat in deze versie van de binding als verouderd gemarkeerd, maar het is
// wel de methode die werkt en waar Google zelf naar verwijst. Zodra er een opvolger
// is die het kenmerk teruggeeft, hoort die hier te komen.
var taak = Firebase.Messaging.FirebaseMessaging.Instance.GetToken();
var kenmerk = await taak.AsAsync<Java.Lang.String>();
return kenmerk?.ToString();
}
catch (Exception)
{
// Geen Google Play-diensten op dit toestel, of Firebase niet ingericht.
return null;
}
#else
return null;
#endif
}
private static async Task<bool> ToestemmingAsync()
{
#if ANDROID
if (!OperatingSystem.IsAndroidVersionAtLeast(33)) return true;
var stand = await Permissions.CheckStatusAsync<Permissions.PostNotifications>();
if (stand != PermissionStatus.Granted)
stand = await Permissions.RequestAsync<Permissions.PostNotifications>();
return stand == PermissionStatus.Granted;
#else
return await Task.FromResult(true);
#endif
}
}
+141
View File
@@ -0,0 +1,141 @@
using MijnBesluit.AppUi;
#if ANDROID
using Android.App;
using Android.Content;
using Android.Hardware.Biometrics;
using Android.OS;
#elif IOS
using LocalAuthentication;
#endif
namespace MijnBesluit.App;
/// <summary>
/// Vraagt het toestel om iemand te controleren: vingerafdruk, gezicht, of de pincode of het
/// patroon van de telefoon zelf.
///
/// Wat hier NIET gebeurt: de app vraagt geen eigen pincode en bewaart er ook geen. Er is dus
/// niets bij te houden, niets te vergeten en niets te lekken. De telefoon doet de controle in
/// een apart stuk hardware waar geen enkele app bij kan, en geeft alleen ja of nee terug.
///
/// Vanaf Android 10. Daarvoor bestaat dit venster nog niet in de vorm waarin het ook de code
/// van de telefoon accepteert, en dan zou een vingerafdrukloze telefoon er niet meer in komen.
/// Op oudere toestellen is <see cref="Beschikbaar"/> dus onwaar en logt de app uit zoals eerst.
///
/// Op iOS doet LocalAuthentication hetzelfde, met dezelfde afspraak: Face ID of Touch ID, en
/// de toestelcode als terugval. Dat laatste zit daar in de keuze van het beleid
/// (DeviceOwnerAuthentication) in plaats van in een aparte schakelaar.
/// </summary>
public class Toestelslot : IToestelslot
{
#if ANDROID
public bool Beschikbaar
{
get
{
// Zonder schermslot heeft vergrendelen geen zin: dan komt iedereen die de telefoon
// in handen heeft er met één veeg weer in, en is uitloggen de enige echte grendel.
if (!OperatingSystem.IsAndroidVersionAtLeast(29)) return false;
var bewaking = (KeyguardManager?)Platform.AppContext.GetSystemService(Context.KeyguardService);
return bewaking?.IsDeviceSecure == true;
}
}
public Task<bool> OntgrendelAsync(string titel, string uitleg)
{
if (!Beschikbaar) return Task.FromResult(false);
var activiteit = Platform.CurrentActivity;
if (activiteit is null) return Task.FromResult(false);
var klaar = new TaskCompletionSource<bool>();
try
{
// SetDeviceCredentialAllowed is vanaf Android 11 als verouderd gemarkeerd, maar het
// is de enige manier die vanaf Android 10 werkt én de code van de telefoon als
// terugval toelaat. De opvolger (SetAllowedAuthenticators) bestaat pas vanaf 11.
// Zolang de app vanaf 10 werkt, hoort dit hier te staan.
var venster = new BiometricPrompt.Builder(activiteit)
.SetTitle(titel)
.SetDescription(uitleg)
.SetDeviceCredentialAllowed(true)
.Build();
// Geen annuleersein dat we zelf afbreken: het venster heeft zijn eigen knop, en de
// uitkomst daarvan komt via OnAuthenticationError binnen.
venster.Authenticate(new CancellationSignal(), activiteit.MainExecutor!, new Antwoord(klaar));
}
catch (Exception)
{
// Een toestel dat hier struikelt hoort de app niet te laten vallen. Het slot blijft
// dan dicht en je kunt nog altijd helemaal uitloggen.
klaar.TrySetResult(false);
}
return klaar.Task;
}
/// <summary>Wat het venster terugmeldt.</summary>
private sealed class Antwoord : BiometricPrompt.AuthenticationCallback
{
private readonly TaskCompletionSource<bool> _klaar;
public Antwoord(TaskCompletionSource<bool> klaar) => _klaar = klaar;
public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? uitkomst) =>
_klaar.TrySetResult(true);
/// <summary>Afgebroken, te vaak mis, of het toestel kan het niet. Hoe dan ook: niet open.</summary>
public override void OnAuthenticationError(BiometricErrorCode code, Java.Lang.ICharSequence? tekst) =>
_klaar.TrySetResult(false);
/// <summary>
/// Eén poging die niet klopte - een vinger die verkeerd lag. Het venster blijft staan en
/// laat het opnieuw proberen, dus hier hoort niets te gebeuren. Pas als het toestel er
/// mee ophoudt komt OnAuthenticationError.
/// </summary>
public override void OnAuthenticationFailed() { }
}
#elif IOS
public bool Beschikbaar
{
get
{
// DeviceOwnerAuthentication en niet DeviceOwnerAuthenticationWithBiometrics: het
// eerste is "Face ID of Touch ID, en anders de code van de telefoon", het tweede
// eist een vingerafdruk of gezicht en niets anders. Dat laatste zou betekenen dat
// een toestel met alleen een cijfercode er niet meer in komt - precies de gebruiker
// die we op Android ook niet buitensluiten.
using var vraag = new LAContext();
return vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _);
}
}
public async Task<bool> OntgrendelAsync(string titel, string uitleg)
{
// iOS toont één regel tekst en geen kop, dus de titel valt hier weg. Wat er in beeld
// komt is de uitleg; die hoort dus op zichzelf te kunnen staan.
//
// Let op: staat NSFaceIDUsageDescription niet in Info.plist, dan sluit iOS de app hier
// ter plekke af - geen foutmelding, hij is weg. Zie Platforms/iOS/Info.plist; er is een
// zelfcontrole die daarop let.
using var vraag = new LAContext();
if (!vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _)) return false;
// Afgebroken, te vaak mis, of het toestel kan het niet: dan komt hier onwaar uit en
// blijft het slot dicht. De fout zelf laten we liggen - er valt voor de gebruiker niets
// mee te doen, en op het slotscherm staat al wat hij nog wél kan.
var (gelukt, _) = await vraag.EvaluatePolicyAsync(LAPolicy.DeviceOwnerAuthentication, uitleg);
return gelukt;
}
#else
public bool Beschikbaar => false;
public Task<bool> OntgrendelAsync(string titel, string uitleg) => Task.FromResult(false);
#endif
}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>MijnBesluit</title>
<!-- Dezelfde opmaak als de website; het bestand komt uit MijnBesluit.AppUi. -->
<link rel="stylesheet" href="_content/MijnBesluit.AppUi/app.css" />
<style>
/* Ruimte voor de statusbalk en de knoppen onderaan de telefoon. */
body { padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left); }
#blazor-error-ui {
display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: #fbf1e3; color: #8a5a22; padding: .8rem 1rem;
font: 15px "Segoe UI", system-ui, sans-serif;
}
</style>
</head>
<body>
<div id="app">
<div style="padding:2rem;text-align:center;color:#6b645c;font:17px 'Segoe UI',system-ui,sans-serif">
Even geduld…
</div>
</div>
<div id="blazor-error-ui">
Er ging iets mis. <a href="" class="reload">Opnieuw laden</a>
</div>
<script src="_content/MijnBesluit.AppUi/validatie.js"></script>
<script src="_framework/blazor.webview.js" autostart="false"></script>
</body>
</html>