diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md index 4092ffe..9058d15 100644 --- a/agents-docs/features/mobile-capacitor.md +++ b/agents-docs/features/mobile-capacitor.md @@ -201,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active. ## Safe area (Android) -- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. +- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready. - `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets. -- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode. +- Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar. +- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets. ## Self-hosted HTTPS signal servers (Android) diff --git a/toju-app/android/app/src/main/res/values/styles.xml b/toju-app/android/app/src/main/res/values/styles.xml index be874e5..3a23078 100644 --- a/toju-app/android/app/src/main/res/values/styles.xml +++ b/toju-app/android/app/src/main/res/values/styles.xml @@ -2,21 +2,31 @@ - - \ No newline at end of file + diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index c69f08c..5082a3d 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -1,6 +1,7 @@
-
+
({ + 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(); 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' }); + }); }); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts index 7f1fa08..b7d68a5 100644 --- a/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts @@ -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 { + 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(() => {}); +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts index 383a64b..d561cf8 100644 --- a/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts @@ -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; } diff --git a/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html b/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html index 9e8a386..4411d09 100644 --- a/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html +++ b/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html @@ -11,7 +11,7 @@ -->
} @else {