fix: Bug - Android app view is below titlebar and action bar on android

Respect Android system bar insets with safe-area shell padding, inset-aware modal and bottom-sheet layouts, transparent edge-to-edge themes, and a Capacitor SystemBars refresh on mobile startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 21:24:48 +02:00
parent a01abbb1bf
commit 6b9a39fe4a
10 changed files with 128 additions and 13 deletions

View File

@@ -1,17 +1,53 @@
import {
beforeEach,
describe,
expect,
it
it,
vi
} from 'vitest';
import { applyMobileSafeAreaDefaults, getSafeAreaInsetCSSValue } from './mobile-safe-area.rules';
import {
applyMobileSafeAreaDefaults,
getSafeAreaInsetCSSValue,
MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS,
MOBILE_FIXED_SAFE_VIEWPORT_CLASS,
MOBILE_SAFE_AREA_SHELL_CLASS,
syncMobileSafeAreaInsets
} from './mobile-safe-area.rules';
const systemBars = vi.hoisted(() => ({
setStyle: vi.fn(() => Promise.resolve())
}));
vi.mock('@capacitor/core', () => ({
SystemBars: systemBars,
SystemBarsStyle: { Dark: 'DARK' }
}));
vi.mock('./platform-detection.rules', () => ({
isCapacitorNativeRuntime: vi.fn(() => false)
}));
import { isCapacitorNativeRuntime } from './platform-detection.rules';
describe('mobile-safe-area.rules', () => {
beforeEach(() => {
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(false);
systemBars.setStyle.mockClear();
vi.unstubAllGlobals();
});
it('builds CSS values with Capacitor and env fallbacks', () => {
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
});
it('exports the global safe-area layout classes used by the mobile shell and overlays', () => {
expect(MOBILE_FIXED_SAFE_VIEWPORT_CLASS).toBe('metoyou-fixed-safe-viewport');
expect(MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS).toBe('metoyou-fixed-safe-bottom-sheet');
expect(MOBILE_SAFE_AREA_SHELL_CLASS).toBe('metoyou-safe-area-shell');
});
it('sets default safe-area variables on the document root', () => {
const properties = new Map<string, string>();
const root = {
@@ -34,4 +70,18 @@ describe('mobile-safe-area.rules', () => {
it('ignores null roots instead of throwing', () => {
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
});
it('asks Capacitor SystemBars to refresh CSS insets on native shells', async () => {
const onDOMReady = vi.fn();
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(true);
vi.stubGlobal('window', {
CapacitorSystemBarsAndroidInterface: { onDOMReady }
});
await syncMobileSafeAreaInsets();
expect(onDOMReady).toHaveBeenCalledTimes(1);
expect(systemBars.setStyle).toHaveBeenCalledWith({ style: 'DARK' });
});
});

View File

@@ -1,3 +1,5 @@
import { isCapacitorNativeRuntime } from './platform-detection.rules';
const SAFE_AREA_SIDES = [
'top',
'right',
@@ -7,6 +9,15 @@ const SAFE_AREA_SIDES = [
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
/** Global class that insets fixed full-viewport overlays below the status bar and above the nav bar. */
export const MOBILE_FIXED_SAFE_VIEWPORT_CLASS = 'metoyou-fixed-safe-viewport';
/** Global class for bottom sheets that anchor above the Android navigation bar. */
export const MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS = 'metoyou-fixed-safe-bottom-sheet';
/** Global class applied to the mobile app shell so routed content respects system insets. */
export const MOBILE_SAFE_AREA_SHELL_CLASS = 'metoyou-safe-area-shell';
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
@@ -28,3 +39,20 @@ export function applyMobileSafeAreaDefaults(root: HTMLElement | null = typeof do
}
}
}
/** Request Capacitor Android SystemBars to recompute CSS inset variables after the SPA boots. */
export async function syncMobileSafeAreaInsets(): Promise<void> {
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
return;
}
const androidInterface = (window as Window & {
CapacitorSystemBarsAndroidInterface?: { onDOMReady?: () => void };
}).CapacitorSystemBarsAndroidInterface;
androidInterface?.onDOMReady?.();
const { SystemBars, SystemBarsStyle } = await import('@capacitor/core');
await SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {});
}

View File

@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import { syncMobileSafeAreaInsets } from '../logic/mobile-safe-area.rules';
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -24,6 +25,7 @@ export class MobileAppLifecycleService {
await adapter.initialize();
this.mobilePlatform.refreshRuntimeDetection();
await syncMobileSafeAreaInsets();
await this.runtimePermissions.initialize();
this.initialized = true;
}