Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ private partial void BlazorWebViewInitialized(object? sender, BlazorWebViewIniti
? (e.WebView.WebChromeClient ?? new Android.Webkit.WebChromeClient())
: new Android.Webkit.WebChromeClient();
e.WebView.SetWebChromeClient(new PermissionManagingBlazorWebChromeClient(baseClient, activity));

// Push real system-bar insets into the WebView as CSS custom properties so
// edge-to-edge layouts (Android 15+) don't render under the status bar / gesture nav.
AndroidEdgeToEdgeInsets.Install(e.WebView);
}

private partial void BlazorWebViewOnUrlLoading(object? sender, UrlLoadingEventArgs e)
Expand Down
148 changes: 148 additions & 0 deletions backend/FwLite/FwLiteMaui/Platforms/Android/AndroidEdgeToEdgeInsets.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#if ANDROID
using Android.Util;
using AndroidX.Core.View;
using AView = Android.Views.View;
using AWebView = Android.Webkit.WebView;
using AInsets = AndroidX.Core.Graphics.Insets;

namespace FwLiteMaui.Platforms.Android;

// Wires up edge-to-edge support for the Blazor WebView on Android 15+ (SDK 35).
// Reads the real system-bar insets (status bar at top, gesture-nav / 3-button nav at bottom)
// and writes them onto the WebView's root element as two tiers of CSS custom properties:
//
// --android-safe-{top,right,bottom,left} -- "chrome" inset, SystemBars + DisplayCutout
// only. Used for general page chrome (status bar / nav bar / notch clearance) where
// Material Design's standard safe area is appropriate. This is what .app pads with.
//
// --android-wide-{top,right,bottom,left} -- "wide" inset, chrome union with
// MandatorySystemGestures + TappableElement. Used for FLOATING elements (FABs,
// toasters) that need extra clearance from the gesture-nav tappable region.
// Without this split, the gesture-area reservation bleeds into every page chrome
// consumer and over-reserves visible space.
//
// Also writes --android-ime-bottom for the soft keyboard, kept separate from system bars
// so chrome surfaces (sidebars, drawers) can ignore it while scrollable content can shrink.
// CSS consumes them with sensible env() fallbacks,
// e.g. var(--android-safe-bottom, env(safe-area-inset-bottom)).
//
// We do this in JS rather than relying on CSS env(safe-area-inset-*) because that has been
// observed to report 0 inside the Android System WebView even with viewport-fit=cover.
//
// We must NOT replace the WebView's WebViewClient — Blazor's own client intercepts
// https://0.0.0.0/ requests and serves the embedded app. Anything that breaks that
// chain bricks the WebView. The insets-listener alone is enough; CSS vars set on
// document.documentElement.style survive Blazor's DOM swap.
internal static class AndroidEdgeToEdgeInsets
{
public static void Install(AWebView webView)
{
// Belt-and-suspenders: ensure the WebView doesn't auto-pad behind our back on
// OEM AppCompat themes that flip fitsSystemWindows=true via parent inheritance.
// We're handling insets ourselves via the listener below.
webView.SetFitsSystemWindows(false);
ViewCompat.SetOnApplyWindowInsetsListener(webView, new InsetsListener(webView));
ViewCompat.RequestApplyInsets(webView);
}

private sealed class InsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener
{
private readonly AWebView _webView;
private AInsets _lastChrome = AInsets.None!;
private AInsets _lastWide = AInsets.None!;
private int _lastImeBottom = -1;

public InsetsListener(AWebView webView) => _webView = webView;

public WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets)
{
if (insets is null) return insets;
// Two-tier insets:
// chrome = SystemBars + DisplayCutout. Material Design's standard "safe area"
// for general page chrome. What .app pads with.
// wide = chrome + MandatorySystemGestures + TappableElement. For floating
// elements (FABs, toasters) that must clear the gesture-nav tappable region
// which can be wider than the visual handle reported in SystemBars.
//
// IME is read separately (not unioned) so chrome surfaces can keep using the
// system-only height while scrollable content shrinks for the keyboard.
// WindowInsetsCompat.Type.Ime() backports keyboard dispatch on pre-API-30.
var chromeMask = WindowInsetsCompat.Type.SystemBars()
| WindowInsetsCompat.Type.DisplayCutout();
var wideMask = chromeMask
| WindowInsetsCompat.Type.MandatorySystemGestures()
| WindowInsetsCompat.Type.TappableElement();
var chrome = insets.GetInsets(chromeMask);
var wide = insets.GetInsets(wideMask);
var sysBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime());
// Standard reduction: subtract nav-bar bottom so we don't double-count when
// the IME sits behind an opaque nav bar.
var imeBottom = Math.Max(0, (ime?.Bottom ?? 0) - (sysBars?.Bottom ?? 0));
var chromeChanged = chrome is not null && !chrome.Equals(_lastChrome);
var wideChanged = wide is not null && !wide.Equals(_lastWide);
var imeChanged = imeBottom != _lastImeBottom;
if (chromeChanged || wideChanged || imeChanged)
{
LogBreakdown(insets, imeBottom);
if (chrome is not null) _lastChrome = chrome;
if (wide is not null) _lastWide = wide;
_lastImeBottom = imeBottom;
Apply(_webView, _lastChrome, _lastWide, imeBottom);
}
// Don't consume - let MAUI's own listeners (if any) still see the insets.
return insets;
}

private static void LogBreakdown(WindowInsetsCompat insets, int imeReducedBottom)
{
// Diagnostic: surface the individual inset categories so we can see on-device
// (`adb logcat -s FwLiteInsets`) why the bottom value is what it is.
var sb = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
var cut = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout());
var gest = insets.GetInsets(WindowInsetsCompat.Type.MandatorySystemGestures());
var tap = insets.GetInsets(WindowInsetsCompat.Type.TappableElement());
var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime());
Log.Debug("FwLiteInsets",
$"SystemBars b={sb?.Bottom} t={sb?.Top}; Cutout b={cut?.Bottom} t={cut?.Top}; " +
$"MandatoryGestures b={gest?.Bottom}; Tappable b={tap?.Bottom}; " +
$"Ime b={ime?.Bottom} (reduced={imeReducedBottom})");
}
}

private static void Apply(AWebView webView, AInsets chrome, AInsets wide, int imeBottomPx)
{
var density = webView.Resources?.DisplayMetrics?.Density ?? 1f;
// The WebView's viewport is measured in CSS pixels; system insets come back in physical px.
// Use ceiling so we never under-reserve by a sub-CSS-pixel - truncation could leak
// up to ~1px of content under the bar at non-integer densities (e.g. 2.75x).
var cTop = (int)Math.Ceiling(chrome.Top / density);
var cRight = (int)Math.Ceiling(chrome.Right / density);
var cBottom = (int)Math.Ceiling(chrome.Bottom / density);
var cLeft = (int)Math.Ceiling(chrome.Left / density);
var wTop = (int)Math.Ceiling(wide.Top / density);
var wRight = (int)Math.Ceiling(wide.Right / density);
var wBottom = (int)Math.Ceiling(wide.Bottom / density);
var wLeft = (int)Math.Ceiling(wide.Left / density);
var ime = (int)Math.Ceiling(imeBottomPx / density);
Log.Debug("FwLiteInsets",
$"Applied CSS px: chrome=({cTop},{cRight},{cBottom},{cLeft}) " +
$"wide=({wTop},{wRight},{wBottom},{wLeft}) ime={ime} density={density}");
var js = $$"""
(function() {
var s = document.documentElement.style;
s.setProperty('--android-safe-top', '{{cTop}}px');
s.setProperty('--android-safe-right', '{{cRight}}px');
s.setProperty('--android-safe-bottom', '{{cBottom}}px');
s.setProperty('--android-safe-left', '{{cLeft}}px');
s.setProperty('--android-wide-top', '{{wTop}}px');
s.setProperty('--android-wide-right', '{{wRight}}px');
s.setProperty('--android-wide-bottom', '{{wBottom}}px');
s.setProperty('--android-wide-left', '{{wLeft}}px');
s.setProperty('--android-ime-bottom', '{{ime}}px');
})();
""";
webView.Post(() => webView.EvaluateJavascript(js, null));
}
}
#endif
8 changes: 6 additions & 2 deletions backend/FwLite/FwLiteMaui/Platforms/Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
//custom style, declared in Android/Resources/values/styles.xml, values-v35 is used based on the android version
Theme?.ApplyStyle(Resource.Style.OptOutEdgeToEdgeEnforcement, force: false);
// Android 15+ (SDK 35) enforces edge-to-edge by default. Rather than opting out
// (which has historically been fragile - the attribute is deprecated on Android 16+
// and CSS env(safe-area-inset-*) inside the BlazorWebView has been observed to
// return 0 even with viewport-fit=cover) we embrace edge-to-edge and propagate
// the real system-bar insets to the WebView as CSS custom properties via
// AndroidEdgeToEdgeInsets (installed when the BlazorWebView is initialized).
base.OnCreate(savedInstanceState);
}

Expand Down

This file was deleted.

2 changes: 1 addition & 1 deletion frontend/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/icon.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<title>FieldWorks Lite</title>
<style>
body, html, #app {
Expand Down
2 changes: 1 addition & 1 deletion frontend/viewer/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


<TooltipProvider delayDuration={300}>
<div class="app">
<div class="app min-h-dvh">
<Router>
<AppRoutes />
</Router>
Expand Down
4 changes: 3 additions & 1 deletion frontend/viewer/src/ShadcnProjectView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
</script>
<svelte:window on:message={onMessage}/>
<DialogsProvider/>
<div class="h-screen flex PortalTarget overflow-hidden shadcn-root" {...rest}>
<!-- h-content-screen = safe viewport minus the IME so the entries list shrinks
above the keyboard on Android. Chrome surfaces inside use h-safe-screen. -->
<div class="h-content-screen flex PortalTarget overflow-hidden shadcn-root" {...rest}>
<Sidebar.Provider bind:open>
<ProjectSidebar/>
<Sidebar.Inset class="flex-1 relative">
Expand Down
152 changes: 152 additions & 0 deletions frontend/viewer/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,158 @@
@apply bg-background text-foreground;
}

/*
Reserve space for system insets (status bar, gesture nav) when the WebView
renders edge-to-edge - required since Android 15+ (SDK 35+) enforces this by
default. Pairs with `viewport-fit=cover` in the MAUI BlazorWebView host page.
Harmless on desktop where every term resolves to 0.

The --android-safe-* / --android-wide-* / --android-ime-bottom custom properties
are written by the MAUI Android host (see AndroidEdgeToEdgeInsets.cs) from real
WindowInsetsCompat values -- we don't trust CSS env(safe-area-inset-*) inside the
Android System WebView because it has been observed to report 0 even with
viewport-fit=cover. iOS WKWebView populates env() reliably so we fall back to it
there, and finally to 0 on desktop.

--safe-area-inset-*: "chrome" inset (SystemBars + DisplayCutout only). For general
page chrome -- status bar / nav bar / notch clearance. What `.app` pads with.
--wide-area-inset-*: "wide" inset (chrome unioned with MandatorySystemGestures and
TappableElement on Android). Use only for FLOATING elements (FABs, toasters)
that need clearance from the gesture-nav tappable region, which can be wider
than the visual handle in SystemBars. On iOS / desktop the env() fallback is
the same as --safe-area-inset-* -- the distinction only matters on Android.

--safe-viewport-height: system-bar-only safe height. Use for chrome surfaces
(sidebars, drawers, dialogs) that should stay put when the keyboard opens.
--content-viewport-height: safe height minus IME. Use for scrollable content
areas that should shrink to remain visible above the soft keyboard.
*/
:root {
--safe-area-inset-top: var(--android-safe-top, env(safe-area-inset-top, 0px));
--safe-area-inset-right: var(--android-safe-right, env(safe-area-inset-right, 0px));
--safe-area-inset-bottom: var(--android-safe-bottom, env(safe-area-inset-bottom, 0px));
--safe-area-inset-left: var(--android-safe-left, env(safe-area-inset-left, 0px));
--wide-area-inset-top: var(--android-wide-top, env(safe-area-inset-top, 0px));
--wide-area-inset-right: var(--android-wide-right, env(safe-area-inset-right, 0px));
--wide-area-inset-bottom: var(--android-wide-bottom, env(safe-area-inset-bottom, 0px));
--wide-area-inset-left: var(--android-wide-left, env(safe-area-inset-left, 0px));
--ime-inset-bottom: var(--android-ime-bottom, 0px);
--safe-viewport-height: calc(100dvh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
--content-viewport-height: calc(100dvh - var(--safe-area-inset-top) - var(--ime-inset-bottom));
}

/*
Padding goes on `.app` (not `body`) because some descendants are sized to the
visual viewport and would otherwise render under the status bar / gesture nav,
ignoring any padding on `body`. The height clamp is applied via `min-h-dvh` on
the `.app` element; padding stays here so it doesn't have to repeat on every
direction-utility.

Note: no padding-bottom. We deliberately let scroll content extend behind the
gesture nav / FAB so the entries list visually continues into the floating
chrome region. Floating elements (FAB, sonner) pay their own bottom inset.
*/
.app {
padding: var(--safe-area-inset-top) var(--safe-area-inset-right) 0 var(--safe-area-inset-left);
}

/*
Status-bar band: a themed strip behind the OS status bar on Android edge-to-edge.
Mirrors AppBar's background composite (primary @ 0.4 alpha over --background) so the
band visually continues the AppBar through the status-bar region and re-themes
automatically when the user toggles light/dark/accent in-app. Height collapses to 0
on iOS / desktop where --safe-area-inset-top resolves to 0.

z-index 100 puts the band above everything in-app, including modal overlays
(z-50). This is intentional: the band is OS-chrome-tier — its job is to keep
the status bar icons legible regardless of what's open underneath. Modals
darken in-app content; the status bar area stays bright.

Hidden on project view (see the `:has([data-variant=inset])` rule below):
there, `.app`'s bg-sidebar already covers the safe-top region in a single
continuous color, so the band would just paint a different-shaded strip on
top of it.
*/
body::before {
content: '';
position: fixed;
inset: 0 0 auto 0;
height: var(--safe-area-inset-top);
background:
linear-gradient(oklch(from var(--primary) l c h / 0.4)),
var(--background);
z-index: 100;
pointer-events: none;
}

/*
Project-view background: when the layout contains an inset-variant sidebar,
paint `.app` AND `body` with bg-sidebar so the cutout / nav region and any
area exposed when the IME shrinks `.app` (the strip alongside the keyboard
in landscape, for instance) stay visually continuous with the sidebar
instead of leaking through to bg-background. The themed status-bar band
(body::before) still paints on top in the safe-top region.
*/
.app:has([data-variant=inset]),
body:has([data-variant=inset]) {
background-color: var(--sidebar);
}

/*
svelte-sonner offset / mobileOffset are configured as inline styles via the
<Toaster> props (see lib/components/ui/sonner/sonner.svelte). External CSS-var
overrides here would lose to the library's own inline `style:--offset-*=...`
declarations on the <ol>, so we have to drive the values through the props.
*/

/* Safe-area utilities. Apply at call sites instead of overriding slot selectors. */
@utility min-h-safe-screen { min-height: var(--safe-viewport-height); }
@utility h-safe-screen { height: var(--safe-viewport-height); }
@utility max-h-safe-screen { max-height: var(--safe-viewport-height); }
@utility min-h-content-screen { min-height: var(--content-viewport-height); }
@utility h-content-screen { height: var(--content-viewport-height); }
/* Vaul drawer baseline is max-h-[90dvh]; this preserves the breathing room. */
@utility max-h-90-safe { max-height: calc(var(--safe-viewport-height) * 0.9); }
@utility pt-safe { padding-top: var(--safe-area-inset-top); }
@utility pr-safe { padding-right: var(--safe-area-inset-right); }
@utility pb-safe { padding-bottom: var(--safe-area-inset-bottom); }
@utility pl-safe { padding-left: var(--safe-area-inset-left); }
@utility px-safe {
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
@utility py-safe {
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
}
@utility pb-ime { padding-bottom: var(--ime-inset-bottom); }

/*
Wide-inset utilities. Mirror pb-safe / pr-safe but pull from --wide-area-inset-*
so floating elements (FAB, sonner offset calc) clear the gesture-nav tappable
region on Android. Identical to safe variants on iOS / desktop.
*/
@utility pb-wide { padding-bottom: var(--wide-area-inset-bottom); }
@utility pr-wide { padding-right: var(--wide-area-inset-right); }

/*
"Extra" wide insets: the difference between wide and chrome. Use on floating
elements that already sit inside `.app` (which adds chrome padding) but need to
clear the full wide-inset region. `max(0px, ...)` because the wide vars fall
back via env() on iOS where it can theoretically resolve unequal to safe.
*/
@utility pb-wide-extra { padding-bottom: max(0px, calc(var(--wide-area-inset-bottom) - var(--safe-area-inset-bottom))); }
@utility pr-wide-extra { padding-right: max(0px, calc(var(--wide-area-inset-right) - var(--safe-area-inset-right))); }

/*
Re-center a fixed element within the safe rect instead of the full viewport.
Use with `translate-x-[-50%] translate-y-[-50%]` (which still mean "shift by
half of self"); only the 50% anchor is biased by the inset asymmetry. When
insets are symmetric or zero, this collapses to plain `top: 50%; left: 50%`.
*/
@utility top-safe-center { top: calc(50% + (var(--safe-area-inset-top) - var(--safe-area-inset-bottom)) / 2); }
@utility left-safe-center { left: calc(50% + (var(--safe-area-inset-left) - var(--safe-area-inset-right)) / 2); }

@layer base {
/* shadcn generated styles */
* {
Expand Down
3 changes: 2 additions & 1 deletion frontend/viewer/src/home/HomeView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@
onclick={() => refreshProjects()}/>
</div>
<div>
{#each projects.filter((p) => p.crdt) as project (project)}
<!-- "?? project" seems to be needed sometimes. Probably just on dev machines. -->
{#each projects.filter((p) => p.crdt) as project (project.id ?? project)}
{@const server = project.server}
{@const loading = deletingProject === project.id}
<div out:send={{key: 'project-' + project.code}} in:receive={{key: 'project-' + project.code}}>
Expand Down
Loading
Loading