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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user