Files
TREK/client/src/components/Layout/AppLockGate.tsx
T
Maurice b7951baf23 fix(ci): green the dev gates after the native and popup rounds
- RTL: the measured pills and centred controls use logical forms or a marked line
- i18n: the two native widget numbers are positions, not counts (NOT_PLURAL)
- Types: styleOverride on the lazy GL map, the location and shared-file payloads,
  the capabilities fallback, the transport sheet's stay metadata
- ESLint: latest-value refs move to layout effects, usePresence keeps the last
  open state in state, the booking import opens clean with a shared document
  kept in the list (the reset effect used to clear it again)
- Layers: the phone import sheet lives under mobile/ and reads through tripRepo;
  the offline host check moves to utils/tileUrl
- Sizes: plugin layer geometry, hardNavigate and the exit animations get
  modules of their own
- Tests follow the status radios, the stay chip overview, the dock + button
  for the first list and the field token; store registry lists the new stores
2026-11-06 18:24:25 +01:00

86 lines
3.2 KiB
TypeScript

/**
* AppLockGate: covers TREK until Face ID, Touch ID or the passcode unlocks it,
* when the app lock is on (native/appLock.ts). It locks at launch and again
* after the app spent more than half a minute in the background, and asks at
* once rather than waiting for a tap; the button is there for a second try.
* Rendered once in App; outside the app, or with the lock off, it is nothing.
*/
import { App } from '@capacitor/app';
import { Lock } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { RELOCK_AFTER_MS, isAppLockEnabled, unlockApp } from '../../native/appLock';
import { useNativeCapabilities } from '../../native/capabilities';
export default function AppLockGate(): React.ReactElement | null {
const { t } = useTranslation();
// Re-read once the binary has said what it can do.
const { caps } = useNativeCapabilities();
const [locked, setLocked] = useState(false);
const asking = useRef(false);
const backgroundSince = useRef<number | null>(null);
const ask = useCallback(async () => {
if (asking.current) return;
asking.current = true;
const ok = await unlockApp(t('native.lock.reason'));
asking.current = false;
if (ok) setLocked(false);
}, [t]);
// At launch, once the capabilities are known: locked in the same render the
// capabilities arrive in, so nothing of the app shows before the prompt.
const [capsSeen, setCapsSeen] = useState<typeof caps | undefined>(undefined);
if (caps !== capsSeen) {
setCapsSeen(caps);
if (isAppLockEnabled()) setLocked(true);
}
useEffect(() => {
// Asked after the effect, not inside it: the prompt's answer sets state.
if (isAppLockEnabled()) void Promise.resolve().then(ask);
}, [caps, ask]);
// Back from the background after a while.
useEffect(() => {
if (!isAppLockEnabled()) return;
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) {
backgroundSince.current = Date.now();
return;
}
const since = backgroundSince.current;
backgroundSince.current = null;
if (since != null && Date.now() - since > RELOCK_AFTER_MS) {
setLocked(true);
void ask();
}
});
return () => {
void listener.then((handle) => handle.remove());
};
}, [caps, ask]);
if (!locked) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('native.lock.title')}
className="fixed inset-0 z-[calc(var(--z-toast)+1)] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
>
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-content shadow-lg">
<Lock size={26} strokeWidth={2} aria-hidden="true" />
</span>
<p className="text-title font-semibold text-content">{t('native.lock.title')}</p>
<button
type="button"
onClick={() => void ask()}
className="rounded-full bg-accent px-6 py-3 text-body font-semibold text-accent-text transition-transform active:scale-[.98]"
>
{t('native.lock.unlock')}
</button>
</div>
);
}