diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 92abe3210e..4d7629cd9a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -129,15 +129,18 @@ export default class EntryAbility extends UIAbility { return; } const dark = colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; - const background = dark ? MobileDesignColors.pageBg.dark : MobileDesignColors.pageBg.light; const content = dark ? MobileDesignColors.ink.dark : MobileDesignColors.ink.light; try { this.mainWindow.setWindowSystemBarProperties({ - // The status bar keeps the page colour it has always had — the window - // does not draw behind it on every device, and an opaque bar cannot - // regress the header band either way. The navigation bar is transparent - // instead: the chat page's bottom fade is what should be visible there. - statusBarColor: background, + // Both bars are transparent, because the window is laid out full-screen + // and the strips they occupy are the page's own edges: the drawer's fill + // and the chat page's blurred header band are what should show there. + // An opaque status bar painted the page colour over both, which clipped + // the sidebar's title, and it also sat over the pull-down notification + // centre's frosted background as a white strip instead of blending into + // it. `statusBarContentColor` is what keeps the clock and the icons + // legible against whatever the page puts behind them. + statusBarColor: MobileDesignColors.transparent.light, navigationBarColor: MobileDesignColors.transparent.light, statusBarContentColor: content, navigationBarContentColor: content diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 6ce7e8bef4..911d90b97c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -53,7 +53,9 @@ export struct AppSidebar { * the screen edge for the compact drawer — and the session rows scroll under * the navigation indicator, so the strip is the list's own business now. The * floating footer keeps its distance from that edge itself, which is what - * keeps the buttons above the bar while the rows run under it. + * keeps the buttons above the bar while the rows run under it. The panel's top + * edge is the screen edge too, so the status bar strip is the panel's own + * padding rather than a band the framework reserved for it. */ @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); /** @@ -156,7 +158,12 @@ export struct AppSidebar { // the session rows are content that scrolls under the navigation indicator. // The footer, its fade and the list's tail are what keep the strip clear, // each against the edge this panel actually has. - .padding({ left: 20, right: 20, top: 0 }) + // + // The panel's top edge is the screen edge in both hosts, so its own first + // row is what has to keep clear of the status bar: the fill still reaches + // the top of the window, and the title, the search field and the session + // rows start below the strip. + .padding({ left: 20, right: 20, top: this.insets.top }) .backgroundColor(SIDEBAR_BG) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets index 3c4c7cd554..d225c3d762 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -176,8 +176,12 @@ export struct FilePreviewSurface { }) } .width('100%') - .height(68) - .padding({ left: 8, right: 8, top: 8, bottom: 8 }) + .height(68 + this.insets.top) + // The header band is the surface's top edge in both hosts — the compact + // full-page overlay and the wide preview pane — so it carries the status bar + // strip itself: the band's fill still reaches the top of the window and the + // back control and title sit below the clock and the indicators. + .padding({ left: 8, right: 8, top: 8 + this.insets.top, bottom: 8 }) .alignItems(VerticalAlign.Center) .border({ width: { bottom: 1 }, color: LINE }) .backgroundColor(PAGE_BG) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets index d1638d5b7f..945a403141 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -27,6 +27,10 @@ export struct MiniAppSurface { build() { Column() { + // The gallery is a full-screen surface, so its header band owns the top + // edge of the window: the box grows by the status bar strip and the + // controls keep the 56 the design gives them below it, instead of sitting + // under the clock and the indicators. Row({ space: 8 }) { NavigationBackButton({ onBack: (): void => { if (this.state.appId) this.actions.back(); @@ -37,7 +41,7 @@ export struct MiniAppSurface { .fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center).layoutWeight(1) Blank().width(48) - }.width('100%').height(56).padding({ left: 12, right: 12 }) + }.width('100%').height(56 + this.insets.top).padding({ left: 12, right: 12, top: this.insets.top }) if (this.state.document) { MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request }) .layoutWeight(1) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index 18572f5cde..e334010441 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -27,6 +27,7 @@ import { } from './remote/RemoteSurfaceHost'; import { SidebarToggleButton } from './SidebarToggleButton'; import { SidebarWorkspaceSection } from './SidebarWorkspaceSection'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { LINE, PAGE_BG, @@ -63,6 +64,23 @@ export struct WideConversationHost { @Event onRestoreMasterPane: () => void = () => {}; @Event onToggleFilePreviewExpanded: () => void = () => {}; @Event onOpenRemoteViewSettings: () => void = () => {}; + /** + * The window's own insets, in vp. Every pane in this host is pinned to the + * window's edges, so the floating controls that are not part of a page's own + * header band keep the status bar strip clear themselves. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } build() { if (this.showsRemoteConversation() && @@ -117,8 +135,10 @@ export struct WideConversationHost { .width('100%').height('100%').justifyContent(FlexAlign.Start).backgroundColor(PAGE_BG) if (this.wideMasterPaneCollapsed) { + // The detail pane's top edge is the window's, so the floating control + // keeps the status bar strip above it instead of sitting under it. SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) - .position({ x: 12, y: 12 }).zIndex(2) + .position({ x: 12, y: 12 + this.insets.top }).zIndex(2) .hitTestBehavior(HitTestMode.Default) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 4e37d4f443..dff71d963e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -17,6 +17,7 @@ import { RemoteSessionList } from '../RemoteSessionList'; import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView'; import { SidebarToggleButton } from '../SidebarToggleButton'; import { SessionActionPresentation } from '../SessionActionSurface'; +import { WindowInsetsBinding } from '../../../services/WindowSystemBarService'; import { SettingsPlacement, SettingsPlacementPolicy, @@ -62,6 +63,13 @@ export struct RemoteSurfaceHost { @Param wideMasterPaneCollapsed: boolean = false; @Param sessionDetailsPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); + /** + * The window's own insets, in vp. The home surfaces are full-screen pages — + * the compact header and the wide placeholder are both the page's top edge — + * so their header bands reserve the status bar themselves rather than being + * handed a page that already ends below it. + */ + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onCloseSettings: () => void = () => {}; @@ -71,11 +79,19 @@ export struct RemoteSurfaceHost { // down for a change that is only page state — the sidebar rebuild this used // to pay for. See AppShellViewModel.replaceRouteWithoutAnimation. aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } RemoteLogger.info( `remote surface mounted mode=${this.mode} connection=${this.remotePageState.connectionState} sessions=${this.remotePageState.visibleSessions().length}` ); } + aboutToDisappear(): void { + this.insets.unbind(); + } + build() { if (this.mode !== RemoteSurfaceMode.Settings && RemoteCompactHomePolicy.shouldShowWelcomeHome(this.remotePageState.controlTargetType, @@ -219,6 +235,9 @@ export struct RemoteSurfaceHost { @Builder private CompactHomeContent() { Column() { + // The compact home is a page, not a sheet: its header band is the top edge + // of the window, so the band reserves the status bar and the drawer + // control and title start below the clock and the indicators. ConversationHeader({ title: RemoteI18n.t('app.title'), subtitle: this.compactHeaderContext(), @@ -226,6 +245,7 @@ export struct RemoteSurfaceHost { showSidebarButton: true, onOpenSidebar: this.onOpenSidebar }) + .padding({ top: this.insets.top }) if (RemoteCompactHomePolicy.shouldShowConnectHome(this.remotePageState.connectionState)) { this.ConnectionEntry() } else { @@ -270,8 +290,11 @@ export struct RemoteSurfaceHost { // No hairline under the welcome header. The compact header does not draw // one and neither does the desktop client; the wide surface is the same // page, not a sheet, so it should not grow a divider just because it is - // wider. - .width('100%').height(76).padding({ left: 16, right: 16, top: 14, bottom: 12 }) + // wider. The band's box grows by the status bar strip for the same reason + // the compact header's does: the wide detail pane's top edge is the + // window's top edge too. + .width('100%').height(76 + this.insets.top) + .padding({ left: 16, right: 16, top: 14 + this.insets.top, bottom: 12 }) this.CompactEmptyState() diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 0244883fa1..41c1fa4010 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -30,6 +30,8 @@ const sessionActionSurface = source('entry/src/main/ets/pages/components/Session const sessionDetailsView = source('entry/src/main/ets/pages/components/SessionDetailsView.ets'); const conversationViewSettings = source('entry/src/main/ets/pages/components/ConversationViewSettings.ets'); const welcomeHome = source('entry/src/main/ets/pages/components/WelcomeHome.ets'); +const remoteSurfaceHost = source('entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets'); +const wideConversationHost = source('entry/src/main/ets/pages/components/WideConversationHost.ets'); // Reads one @Builder out of a component, so an assertion can name the layer it // is about instead of counting matches in the whole file. @@ -78,13 +80,19 @@ test('the shell runs the window full-screen for every page', () => { 'the welcome page must not switch the window layout any more'); }); -test('the navigation bar is transparent and the status bar keeps the page colour', () => { - // The fade is what should be visible where the navigation bar sits, and the - // header band keeps its opaque page colour so the top does not regress. +test('both system bars are transparent so the page owns their strips', () => { + // The window runs full-screen, so the status bar's strip is the page's own + // top edge: the drawer's fill and the chat page's blurred header band are what + // should show there. An opaque bar painted the page colour over both — it + // clipped the sidebar's title — and it also sat over the pull-down + // notification centre's frosted background as a white strip. The content + // colour is what keeps the clock and the icons legible either way. assert.match(entryAbility, /navigationBarColor: MobileDesignColors\.transparent\.light/, 'the navigation bar must be transparent so the page bottom shows through'); - assert.match(entryAbility, /statusBarColor: background/, - 'the status bar must keep the page colour'); + assert.match(entryAbility, /statusBarColor: MobileDesignColors\.transparent\.light/, + 'the status bar must be transparent so the page top shows through'); + assert.match(entryAbility, /statusBarContentColor: content/, + 'the status bar icons must keep following the colour mode'); }); test('the window inset service is the only place the strip is measured', () => { @@ -131,6 +139,45 @@ test('the chat page header band reserves the status bar itself', () => { 'the band that carries the inset must stay the one that paints the header'); }); +test('every surface whose top edge is the screen edge reserves the status bar', () => { + // Immersive layout moved every page's origin to the top of the window, so the + // first row of a full-screen surface is what has to keep clear of the status + // bar. The band that owns the surface's fill carries the padding, which is + // what keeps that fill reaching the top of the window while the controls start + // below the clock and the indicators: the drawer panel's title, the gallery's + // back control, the preview's header and the home headers' drawer control are + // all one strip lower than they were while the page area ended below it. + assert.match(normalize(builderBody(appSidebar, 'SidebarContent')), + /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/, + 'the drawer panel must reserve the strip above its title'); + assert.match(normalize(miniAppSurface), + /\.height\(56 \+ this\.insets\.top\)\.padding\(\{ left: 12, right: 12, top: this\.insets\.top \}\)/, + 'the mini-app gallery header must reserve the strip'); + assert.match(normalize(filePreviewSurface), + /\.height\(68 \+ this\.insets\.top\)[\s\S]*?\.padding\(\{ left: 8, right: 8, top: 8 \+ this\.insets\.top, bottom: 8 \}\)/, + 'the file preview header must reserve the strip'); + assert.match(normalize(remoteSurfaceHost), + /\.padding\(\{ top: this\.insets\.top \}\)/, + 'the compact home header must reserve the strip'); + assert.match(normalize(remoteSurfaceHost), + /\.height\(76 \+ this\.insets\.top\) \.padding\(\{ left: 16, right: 16, top: 14 \+ this\.insets\.top, bottom: 12 \}\)/, + 'the wide home header must reserve the strip'); + // A floating control on a pane whose top edge is the window's keeps the strip + // clear itself, the way the surfaces' fixed bottom controls keep the + // navigation bar clear. + assert.match(normalize(wideConversationHost), + /\.position\(\{ x: 12, y: 12 \+ this\.insets\.top \}\)/, + 'the floating master-restore control must reserve the strip'); + for (const [name, text] of [['RemoteSurfaceHost', remoteSurfaceHost], + ['WideConversationHost', wideConversationHost]]) { + assert.match(text, /@Local insets: WindowInsetsBinding = new WindowInsetsBinding\(\);/, + `${name} must hold the shared inset binding`); + assert.match(text, /this\.insets\.bind\(this\.getUIContext\(\), context\)/, + `${name} must bind the insets while it is mounted`); + assert.match(text, /this\.insets\.unbind\(\)/, `${name} must release the insets when it goes`); + } +}); + test('the settings sheet viewport reaches the screen edge and its rows end in a tail spacer', () => { // The sheet is a bindSheet, which the framework does not inset while the // window is immersive, so its own box decides where content can scroll. The @@ -171,8 +218,8 @@ test('the sidebar list scrolls under the bar and its floating footer keeps the s // the footer keeps its distance from that edge itself, and the list ends in // a tail spacer so its last row rests above the navigation bar. const content = normalize(builderBody(appSidebar, 'SidebarContent')); - assert.match(content, /\.padding\(\{ left: 20, right: 20, top: 0 \}\)/, - 'the panel root must not shrink its scrolling viewport'); + assert.match(content, /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/, + 'the panel root must reserve the status bar without shrinking its scrolling viewport'); assert.match(content, /\.padding\(\{ bottom: this\.scrollTailPadding\(\) \}\)/, 'the session list must end in a tail spacer'); assert.match(content, /\.margin\(\{ bottom: this\.footerBottomPadding\(\) \}\)/,