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,6 +1,7 @@
|
||||
<div
|
||||
appThemeNode="appRoot"
|
||||
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
||||
[class.metoyou-safe-area-shell]="isMobile()"
|
||||
>
|
||||
<div
|
||||
class="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||
<div
|
||||
class="fixed inset-0 z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
|
||||
class="fixed metoyou-fixed-safe-viewport z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="onBackdropClick()"
|
||||
@@ -14,7 +14,7 @@
|
||||
></div>
|
||||
|
||||
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
||||
<div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
||||
<div class="fixed metoyou-fixed-safe-viewport z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
||||
<div
|
||||
appThemeNode="settingsModalSurface"
|
||||
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
-->
|
||||
<div
|
||||
appThemeNode="bottomSheetSurface"
|
||||
class="bottom-sheet-panel fixed inset-x-0 bottom-0 z-[141] flex max-h-[85vh] flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
|
||||
class="bottom-sheet-panel fixed metoyou-fixed-safe-bottom-sheet z-[141] flex flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
|
||||
[style.transform]="'translateY(' + translateY() + 'px)'"
|
||||
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
|
||||
[attr.aria-label]="title() || ariaLabel()"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@if (dismissable()) {
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60"
|
||||
class="fixed metoyou-fixed-safe-viewport bg-black/60"
|
||||
[class.backdrop-blur-sm]="blur()"
|
||||
[style.z-index]="zIndex()"
|
||||
(click)="dismiss()"
|
||||
@@ -12,7 +12,7 @@
|
||||
></div>
|
||||
} @else {
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60"
|
||||
class="fixed metoyou-fixed-safe-viewport bg-black/60"
|
||||
[class.backdrop-blur-sm]="blur()"
|
||||
[style.z-index]="zIndex()"
|
||||
role="presentation"
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
.metoyou-bottom-sheet-panel {
|
||||
width: 100% !important;
|
||||
max-width: 100vw;
|
||||
max-height: 85vh;
|
||||
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))) !important;
|
||||
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) !important;
|
||||
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) !important;
|
||||
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) !important;
|
||||
border-top-left-radius: 1rem;
|
||||
border-top-right-radius: 1rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
@@ -126,12 +129,32 @@
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.metoyou-safe-area-shell {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
.metoyou-fixed-safe-viewport {
|
||||
top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
.metoyou-fixed-safe-bottom-sheet {
|
||||
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||
}
|
||||
|
||||
/*
|
||||
* Keep border-box on every element (Tailwind preflight does this too). Do not use
|
||||
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to
|
||||
|
||||
Reference in New Issue
Block a user