فهرست منبع

Let the installed app fill the screen

An installed web app cannot ask to launch fullscreen on a desktop. Chrome
and Edge honour the manifest's fullscreen display mode on Android and
ChromeOS only, and fall back to a plain window on every desktop, so the
manifest alone cannot answer this. The Fullscreen API can, but it needs a
user gesture and so cannot fire on load.

So the manifest asks for fullscreen where asking works, and the app grows
a toggle for where it does not. The toggle remembers the answer and spends
it on the first click or keypress after launch: open the app, click
anything, it fills the screen.

Only ever automatic for the installed app. Taking over the first click of
a browser tab somebody opened alongside others is a hijack, not a feature.
Bernt Christian Egeland 1 ماه پیش
والد
کامیت
7cf65814d8

+ 3 - 1
messages/de/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Barcode eingeben…",
     "enterBarcode": "Barcode eingeben…",
     "scanHint": "Richten Sie die Kamera auf einen Barcode oder geben Sie ihn manuell ein",
     "scanHint": "Richten Sie die Kamera auf einen Barcode oder geben Sie ihn manuell ein",
     "iosInstallHint": "Auf Teilen > Zum Home-Bildschirm tippen",
     "iosInstallHint": "Auf Teilen > Zum Home-Bildschirm tippen",
-    "installDescription": "Zum Home-Bildschirm hinzufügen für schnellen Zugriff"
+    "installDescription": "Zum Home-Bildschirm hinzufügen für schnellen Zugriff",
+    "enterFullscreen": "Vollbild",
+    "exitFullscreen": "Vollbild beenden"
   }
   }
 }
 }

+ 3 - 1
messages/en/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Enter barcode...",
     "enterBarcode": "Enter barcode...",
     "scanHint": "Point your camera at a barcode or enter it manually",
     "scanHint": "Point your camera at a barcode or enter it manually",
     "iosInstallHint": "Tap Share > Add to Home Screen",
     "iosInstallHint": "Tap Share > Add to Home Screen",
-    "installDescription": "Add to your home screen for quick access"
+    "installDescription": "Add to your home screen for quick access",
+    "enterFullscreen": "Fullscreen",
+    "exitFullscreen": "Exit fullscreen"
   }
   }
 }
 }

+ 3 - 1
messages/es/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Introducir código de barras…",
     "enterBarcode": "Introducir código de barras…",
     "scanHint": "Apunte la cámara a un código de barras o introdúzcalo manualmente",
     "scanHint": "Apunte la cámara a un código de barras o introdúzcalo manualmente",
     "iosInstallHint": "Toque Compartir > Añadir a inicio",
     "iosInstallHint": "Toque Compartir > Añadir a inicio",
-    "installDescription": "Añádela a tu pantalla de inicio para acceder rápido"
+    "installDescription": "Añádela a tu pantalla de inicio para acceder rápido",
+    "enterFullscreen": "Pantalla completa",
+    "exitFullscreen": "Salir de pantalla completa"
   }
   }
 }
 }

+ 3 - 1
messages/fr/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Saisir un code-barres…",
     "enterBarcode": "Saisir un code-barres…",
     "scanHint": "Pointez la caméra vers un code-barres ou saisissez-le manuellement",
     "scanHint": "Pointez la caméra vers un code-barres ou saisissez-le manuellement",
     "iosInstallHint": "Touchez Partager > Sur l'écran d'accueil",
     "iosInstallHint": "Touchez Partager > Sur l'écran d'accueil",
-    "installDescription": "Ajoutez-la à votre écran d'accueil pour un accès rapide"
+    "installDescription": "Ajoutez-la à votre écran d'accueil pour un accès rapide",
+    "enterFullscreen": "Plein écran",
+    "exitFullscreen": "Quitter le plein écran"
   }
   }
 }
 }

+ 3 - 1
messages/it/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Inserisci codice a barre…",
     "enterBarcode": "Inserisci codice a barre…",
     "scanHint": "Punta la fotocamera su un codice a barre oppure inseriscilo manualmente",
     "scanHint": "Punta la fotocamera su un codice a barre oppure inseriscilo manualmente",
     "iosInstallHint": "Tocca Condividi > Aggiungi a Home",
     "iosInstallHint": "Tocca Condividi > Aggiungi a Home",
-    "installDescription": "Aggiungila alla schermata Home per un accesso rapido"
+    "installDescription": "Aggiungila alla schermata Home per un accesso rapido",
+    "enterFullscreen": "Schermo intero",
+    "exitFullscreen": "Esci da schermo intero"
   }
   }
 }
 }

+ 3 - 1
messages/lt/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Įveskite brūkšninį kodą…",
     "enterBarcode": "Įveskite brūkšninį kodą…",
     "scanHint": "Nukreipkite kamerą į brūkšninį kodą arba įveskite jį ranka",
     "scanHint": "Nukreipkite kamerą į brūkšninį kodą arba įveskite jį ranka",
     "iosInstallHint": "Palieskite Bendrinti > Įtraukti į pradžios ekraną",
     "iosInstallHint": "Palieskite Bendrinti > Įtraukti į pradžios ekraną",
-    "installDescription": "Įtraukite į pradžios ekraną, kad pasiektumėte greičiau"
+    "installDescription": "Įtraukite į pradžios ekraną, kad pasiektumėte greičiau",
+    "enterFullscreen": "Visas ekranas",
+    "exitFullscreen": "Išjungti visą ekraną"
   }
   }
 }
 }

+ 3 - 1
messages/nb/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Skriv inn strekkode…",
     "enterBarcode": "Skriv inn strekkode…",
     "scanHint": "Rett kameraet mot en strekkode, eller skriv den inn manuelt",
     "scanHint": "Rett kameraet mot en strekkode, eller skriv den inn manuelt",
     "iosInstallHint": "Trykk Del > Legg til på Hjem-skjerm",
     "iosInstallHint": "Trykk Del > Legg til på Hjem-skjerm",
-    "installDescription": "Legg den til på hjem-skjermen for rask tilgang"
+    "installDescription": "Legg den til på hjem-skjermen for rask tilgang",
+    "enterFullscreen": "Fullskjerm",
+    "exitFullscreen": "Avslutt fullskjerm"
   }
   }
 }
 }

+ 3 - 1
messages/nl/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Barcode invoeren…",
     "enterBarcode": "Barcode invoeren…",
     "scanHint": "Richt uw camera op een barcode of voer hem handmatig in",
     "scanHint": "Richt uw camera op een barcode of voer hem handmatig in",
     "iosInstallHint": "Tik op Deel > Zet op beginscherm",
     "iosInstallHint": "Tik op Deel > Zet op beginscherm",
-    "installDescription": "Zet de app op je beginscherm voor snelle toegang"
+    "installDescription": "Zet de app op je beginscherm voor snelle toegang",
+    "enterFullscreen": "Volledig scherm",
+    "exitFullscreen": "Volledig scherm afsluiten"
   }
   }
 }
 }

+ 3 - 1
messages/pl/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Wprowadź kod kreskowy…",
     "enterBarcode": "Wprowadź kod kreskowy…",
     "scanHint": "Skieruj aparat na kod kreskowy albo wprowadź go ręcznie",
     "scanHint": "Skieruj aparat na kod kreskowy albo wprowadź go ręcznie",
     "iosInstallHint": "Dotknij Udostępnij > Do ekranu początkowego",
     "iosInstallHint": "Dotknij Udostępnij > Do ekranu początkowego",
-    "installDescription": "Dodaj do ekranu głównego, aby mieć szybki dostęp"
+    "installDescription": "Dodaj do ekranu głównego, aby mieć szybki dostęp",
+    "enterFullscreen": "Pełny ekran",
+    "exitFullscreen": "Zamknij pełny ekran"
   }
   }
 }
 }

+ 3 - 1
messages/pt-BR/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Digitar código de barras…",
     "enterBarcode": "Digitar código de barras…",
     "scanHint": "Aponte a câmera para um código de barras ou digite-o manualmente",
     "scanHint": "Aponte a câmera para um código de barras ou digite-o manualmente",
     "iosInstallHint": "Toque em Compartilhar > Adicionar à Tela de Início",
     "iosInstallHint": "Toque em Compartilhar > Adicionar à Tela de Início",
-    "installDescription": "Adicione à tela de início para acesso rápido"
+    "installDescription": "Adicione à tela de início para acesso rápido",
+    "enterFullscreen": "Tela cheia",
+    "exitFullscreen": "Sair da tela cheia"
   }
   }
 }
 }

+ 3 - 1
messages/ru/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Введите штрихкод…",
     "enterBarcode": "Введите штрихкод…",
     "scanHint": "Наведите камеру на штрихкод или введите его вручную",
     "scanHint": "Наведите камеру на штрихкод или введите его вручную",
     "iosInstallHint": "Нажмите «Поделиться» > «На экран «Домой»»",
     "iosInstallHint": "Нажмите «Поделиться» > «На экран «Домой»»",
-    "installDescription": "Добавьте на главный экран для быстрого доступа"
+    "installDescription": "Добавьте на главный экран для быстрого доступа",
+    "enterFullscreen": "Полный экран",
+    "exitFullscreen": "Выйти из полного экрана"
   }
   }
 }
 }

+ 3 - 1
messages/tr/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Barkod girin…",
     "enterBarcode": "Barkod girin…",
     "scanHint": "Kamerayı bir barkoda doğrultun ya da elle girin",
     "scanHint": "Kamerayı bir barkoda doğrultun ya da elle girin",
     "iosInstallHint": "Paylaş > Ana Ekrana Ekle'ye dokunun",
     "iosInstallHint": "Paylaş > Ana Ekrana Ekle'ye dokunun",
-    "installDescription": "Hızlı erişim için ana ekranınıza ekleyin"
+    "installDescription": "Hızlı erişim için ana ekranınıza ekleyin",
+    "enterFullscreen": "Tam ekran",
+    "exitFullscreen": "Tam ekrandan çık"
   }
   }
 }
 }

+ 145 - 0
src/__tests__/components/fullscreen-toggle.test.tsx

@@ -0,0 +1,145 @@
+/**
+ * The fullscreen control for the installed app.
+ *
+ * Two things here are easy to get wrong and invisible when they are. The
+ * remembered preference must only ever be spent inside the installed app,
+ * because hijacking the first click on a browser tab is hostile. And the
+ * preference has to be written on the way out as well as the way in, or
+ * turning fullscreen off is undone by the next launch.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+
+let installed = false
+vi.mock('@/components/pwa-install-prompt', () => ({
+  useInstallPrompt: () => ({ installed }),
+  SidebarInstallButton: () => null,
+}))
+
+vi.mock('next-intl', () => ({
+  useTranslations: () => (key: string) => key,
+}))
+
+vi.mock('@/components/ui/sidebar', () => ({
+  SidebarGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  SidebarMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  SidebarMenuButton: ({
+    children,
+    onClick,
+  }: {
+    children: React.ReactNode
+    onClick?: () => void
+  }) => (
+    <button type="button" data-testid="toggle" onClick={onClick}>
+      {children}
+    </button>
+  ),
+}))
+
+const { FullscreenToggle } = await import('@/components/fullscreen-toggle')
+
+const requestFullscreen = vi.fn().mockResolvedValue(undefined)
+const exitFullscreen = vi.fn().mockResolvedValue(undefined)
+
+function setFullscreen(on: boolean) {
+  Object.defineProperty(document, 'fullscreenElement', {
+    value: on ? document.documentElement : null,
+    configurable: true,
+  })
+}
+
+beforeEach(() => {
+  installed = false
+  localStorage.clear()
+  requestFullscreen.mockClear()
+  exitFullscreen.mockClear()
+  Object.defineProperty(document, 'fullscreenEnabled', { value: true, configurable: true })
+  document.documentElement.requestFullscreen = requestFullscreen
+  document.exitFullscreen = exitFullscreen
+  setFullscreen(false)
+})
+
+afterEach(() => {
+  localStorage.clear()
+})
+
+describe('the remembered preference', () => {
+  it('is spent on the first gesture inside the installed app', () => {
+    installed = true
+    localStorage.setItem('app-fullscreen', 'true')
+    render(<FullscreenToggle />)
+
+    expect(requestFullscreen).not.toHaveBeenCalled()
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).toHaveBeenCalled()
+  })
+
+  it('is ignored in a browser tab', () => {
+    // Taking over the first click of a page somebody opened alongside others
+    // is the difference between a feature and a hijack.
+    installed = false
+    localStorage.setItem('app-fullscreen', 'true')
+    render(<FullscreenToggle />)
+
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).not.toHaveBeenCalled()
+  })
+
+  it('is not spent when nobody asked for it', () => {
+    installed = true
+    render(<FullscreenToggle />)
+
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).not.toHaveBeenCalled()
+  })
+
+  it('is spent once, not on every click', () => {
+    installed = true
+    localStorage.setItem('app-fullscreen', 'true')
+    render(<FullscreenToggle />)
+
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('the button', () => {
+  it('records the choice when turning fullscreen on', () => {
+    render(<FullscreenToggle />)
+    act(() => {
+      screen.getByTestId('toggle').click()
+    })
+    expect(requestFullscreen).toHaveBeenCalled()
+    expect(localStorage.getItem('app-fullscreen')).toBe('true')
+  })
+
+  it('records the choice when turning it off, so the next launch respects it', () => {
+    localStorage.setItem('app-fullscreen', 'true')
+    setFullscreen(true)
+    render(<FullscreenToggle />)
+
+    act(() => {
+      screen.getByTestId('toggle').click()
+    })
+    expect(exitFullscreen).toHaveBeenCalled()
+    expect(localStorage.getItem('app-fullscreen')).toBe('false')
+  })
+
+  it('is absent where the browser has no fullscreen to give', () => {
+    // iOS Safari on the phone, where requesting it throws rather than refuses.
+    Object.defineProperty(document, 'fullscreenEnabled', { value: false, configurable: true })
+    render(<FullscreenToggle />)
+    expect(screen.queryByTestId('toggle')).toBeNull()
+  })
+})

+ 5 - 0
src/app/manifest.ts

@@ -9,6 +9,11 @@ export default function manifest(): MetadataRoute.Manifest {
       'Workshop management platform for automotive service businesses. Manage work orders, invoices, customers, inventory, and vehicle service history.',
       'Workshop management platform for automotive service businesses. Manage work orders, invoices, customers, inventory, and vehicle service history.',
     start_url: '/',
     start_url: '/',
     scope: '/',
     scope: '/',
+    // Chrome and Edge honour 'fullscreen' on Android and ChromeOS, which is
+    // what a tablet in the bay wants. Every desktop falls back down the chain
+    // to 'standalone', because no desktop browser launches a web app
+    // fullscreen from the manifest. The in-app toggle covers that case.
+    display_override: ['fullscreen', 'standalone'],
     display: 'standalone',
     display: 'standalone',
     orientation: 'any',
     orientation: 'any',
     background_color: '#09090b',
     background_color: '#09090b',

+ 2 - 0
src/components/app-sidebar.tsx

@@ -81,6 +81,7 @@ import {
   NotificationPanel,
   NotificationPanel,
 } from '@/features/notifications/Components/NotificationPanel'
 } from '@/features/notifications/Components/NotificationPanel'
 import { SidebarInstallButton } from '@/components/pwa-install-prompt'
 import { SidebarInstallButton } from '@/components/pwa-install-prompt'
+import { FullscreenToggle } from '@/components/fullscreen-toggle'
 import { FeatureHint } from '@/components/feature-hint'
 import { FeatureHint } from '@/components/feature-hint'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 
 
@@ -453,6 +454,7 @@ export function AppSidebar({
           </SidebarGroup>
           </SidebarGroup>
         )}
         )}
         <SidebarInstallButton />
         <SidebarInstallButton />
+        <FullscreenToggle />
       </SidebarContent>
       </SidebarContent>
       <SidebarFooter>
       <SidebarFooter>
         <SidebarMenu>
         <SidebarMenu>

+ 131 - 0
src/components/fullscreen-toggle.tsx

@@ -0,0 +1,131 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Maximize, Minimize } from 'lucide-react'
+import {
+  SidebarGroup,
+  SidebarMenu,
+  SidebarMenuButton,
+  SidebarMenuItem,
+} from '@/components/ui/sidebar'
+import { useInstallPrompt } from '@/components/pwa-install-prompt'
+
+/**
+ * Kept in localStorage, not in workshop settings, unlike the feature hints.
+ *
+ * This one is genuinely per-device. The desk machine has a keyboard and other
+ * windows to switch between; the tablet in the bay is held in one hand and
+ * wants every pixel. The same person wants opposite answers on each, so a
+ * workshop-wide setting would be wrong on one of them.
+ */
+const PREF_KEY = 'app-fullscreen'
+
+/**
+ * A refused fullscreen request is not worth reporting.
+ *
+ * The browser refuses for reasons the person cannot act on, such as a
+ * permissions policy on an embedded install, and the button not visibly doing
+ * anything says everything a toast would. The button's own state resyncs from
+ * the fullscreenchange event either way.
+ */
+function ignoreRefusal() {
+  return undefined
+}
+
+function prefersFullscreen(): boolean {
+  try {
+    return localStorage.getItem(PREF_KEY) === 'true'
+  } catch {
+    return false
+  }
+}
+
+/**
+ * Fullscreen for the installed app.
+ *
+ * An installed web app cannot ask to launch fullscreen on a desktop: Chrome
+ * and Edge honour the manifest's fullscreen display mode on Android and
+ * ChromeOS, and fall back to a plain window everywhere else. The only route on
+ * a desktop is the Fullscreen API, which needs a user gesture and so cannot
+ * fire on load.
+ *
+ * So the preference is remembered and spent on the first gesture after launch:
+ * open the app, click anything, and it fills the screen. That is as close to
+ * "launches fullscreen" as the platform allows, and it stays honest about
+ * needing the click rather than failing silently on load.
+ *
+ * Only ever automatic for the installed app. Doing this to a browser tab would
+ * hijack the first click on a page somebody opened alongside others.
+ */
+export function FullscreenToggle() {
+  const t = useTranslations('common.shared')
+  const { installed } = useInstallPrompt()
+  const [supported, setSupported] = useState(false)
+  const [active, setActive] = useState(false)
+
+  useEffect(() => {
+    setSupported(typeof document !== 'undefined' && document.fullscreenEnabled)
+    const sync = () => setActive(document.fullscreenElement !== null)
+    sync()
+    document.addEventListener('fullscreenchange', sync)
+    return () => document.removeEventListener('fullscreenchange', sync)
+  }, [])
+
+  // Spend the remembered preference on the first click or keypress. Listening
+  // once, in capture, so the gesture still reaches whatever was clicked.
+  useEffect(() => {
+    if (!installed || !supported) return
+    if (!prefersFullscreen()) return
+    if (document.fullscreenElement) return
+
+    const enter = () => {
+      void document.documentElement.requestFullscreen().catch(ignoreRefusal)
+    }
+    document.addEventListener('pointerdown', enter, { once: true, capture: true })
+    document.addEventListener('keydown', enter, { once: true, capture: true })
+    return () => {
+      document.removeEventListener('pointerdown', enter, { capture: true })
+      document.removeEventListener('keydown', enter, { capture: true })
+    }
+  }, [installed, supported])
+
+  const toggle = useCallback(() => {
+    if (document.fullscreenElement) {
+      // Turning it off is also a decision: the next launch should respect it.
+      try {
+        localStorage.setItem(PREF_KEY, 'false')
+      } catch {
+        // Private mode, or storage disabled. The toggle still works for now.
+      }
+      void document.exitFullscreen().catch(ignoreRefusal)
+      return
+    }
+    try {
+      localStorage.setItem(PREF_KEY, 'true')
+    } catch {
+      // As above.
+    }
+    void document.documentElement.requestFullscreen().catch(ignoreRefusal)
+  }, [])
+
+  if (!supported) return null
+
+  return (
+    <SidebarGroup>
+      <SidebarMenu>
+        <SidebarMenuItem>
+          <SidebarMenuButton
+            onClick={toggle}
+            tooltip={active ? t('exitFullscreen') : t('enterFullscreen')}
+          >
+            {active ? <Minimize className="size-4" /> : <Maximize className="size-4" />}
+            <span className="font-medium">
+              {active ? t('exitFullscreen') : t('enterFullscreen')}
+            </span>
+          </SidebarMenuButton>
+        </SidebarMenuItem>
+      </SidebarMenu>
+    </SidebarGroup>
+  )
+}