import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { App as F7App, View, Views, Toolbar, Link, f7ready } from 'framework7-react'; import Framework7 from 'framework7/lite-bundle'; import Framework7React from 'framework7-react'; import routes from './routes'; import { apiClient, AUTH_EVENT } from './services/api'; import 'framework7/css/bundle'; // The icon font F7's iconIos/iconMd props reference; without it the props // render as their raw names. import 'framework7-icons'; import './f7theme.css'; Framework7.use(Framework7React); /** The five thumb-reachable destinations. Secondary screens are pushed on top * of whichever tab is active, the way an iOS app stacks them. */ const TABS = [ { id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' }, { id: 'health', path: '/health/', label: '健康', icon: 'heart' }, { id: 'trends', path: '/trends/', label: '趋势', icon: 'chart_bar_alt_fill' }, { id: 'exercise', path: '/exercise/', label: '运动', icon: 'flame' }, { id: 'settings', path: '/settings/', label: '设置', icon: 'gear_alt' }, ]; /** * Dark mode is a deliberate, validated palette rather than an inversion. The * choice is stamped on for our own tokens, and mirrored onto F7's * `.dark` class so its chrome follows the same switch. */ function useTheme() { const [theme, setTheme] = useState<'light' | 'dark' | 'system'>( () => (localStorage.getItem('ghl_theme') as any) || 'system' ); useEffect(() => { const root = document.documentElement; const media = window.matchMedia('(prefers-color-scheme: dark)'); const apply = () => { if (theme === 'system') root.removeAttribute('data-theme'); else root.setAttribute('data-theme', theme); root.classList.toggle( 'dark', theme === 'dark' || (theme === 'system' && media.matches) ); }; apply(); localStorage.setItem('ghl_theme', theme); media.addEventListener('change', apply); return () => media.removeEventListener('change', apply); }, [theme]); return [theme, setTheme] as const; } /** * A progress line across the top while a screen is being pushed. * * The transition itself is instant; what the user waits on is the new page's * first fetch, which without this reads as a dead tap. Shown on route change * and cleared once the incoming page has settled, with a floor on how briefly * it can appear so a fast navigation does not produce a flash. */ function useNavProgress() { const [busy, setBusy] = useState(false); useEffect(() => { let shownAt = 0; let timer: number | undefined; const show = () => { shownAt = Date.now(); window.clearTimeout(timer); setBusy(true); }; const hide = () => { const elapsed = Date.now() - shownAt; const wait = Math.max(0, 260 - elapsed); window.clearTimeout(timer); timer = window.setTimeout(() => setBusy(false), wait); }; f7ready((app) => { app.on('routeChange', show); app.on('pageAfterIn', hide); app.on('pageBeforeRemove', hide); }); return () => window.clearTimeout(timer); }, []); return busy; } /** * The progress bar lives on document.body, not inside . * * It was originally a child of the Framework7 root, and that single stray * element broke F7's initialisation: `framework7-initializing` was never * cleared, which forces `transition-duration: 0ms` on everything, so * `transitionend` never fired, so the router never finished a transition — * leaving every page with `pointer-events: none`. The visible symptom was * back buttons that did nothing, app-wide. */ function NavProgress({ on }: { on: boolean }) { return createPortal(
, document.body ); } /** * Clear Framework7's boot flag once the app is up. * * F7 puts `framework7-initializing` on its root to suppress animation during * boot, and removes it on the next animation frame. When that frame does not * arrive — a backgrounded tab, a throttled webview — the class stays, and its * `transition-duration: 0ms !important` means transitions never emit * `transitionend`. The router waits for that event to finish a page change, so * it sets `allowPageChange = false` and never sets it back: every subsequent * navigation, including every back button, silently does nothing. * * Clearing it ourselves is safe — by the time this runs the app is mounted, * which is all the flag was suppressing. */ function useClearBootFlag() { useEffect(() => { const clear = () => document .querySelector('.framework7-root') ?.classList.remove('framework7-initializing'); f7ready(clear); // Belt and braces: f7ready itself can be scheduled off a frame. const timer = window.setTimeout(clear, 600); return () => window.clearTimeout(timer); }, []); } /** * Keep the router from staying blocked. * * Framework7 sets `router.allowPageChange = false` when a page transition * starts and restores it when the transition's `animationend` arrives. If that * event never comes — a throttled or backgrounded webview, where the browser * does not run CSS animations at all — the flag stays false and every later * navigation is silently dropped. The visible symptom is a back button that * does nothing, and the only recovery is a full reload. * * This releases the flag once a page has settled, which is strictly later than * the transition F7 is waiting on. */ function useRouterWatchdog() { useEffect(() => { let timer: number | undefined; /* Walk the DOM rather than an app-level view registry: the shape of that registry differs between Framework7 versions, the view elements do not. */ const stuckSince = new WeakMap(); // A page transition is 400ms. Anything still "transitioning" well past // that is not transitioning — it is waiting for an event that will never // arrive, and the stale classes are themselves what block recovery. const STUCK_AFTER_MS = 1600; const release = () => { document.querySelectorAll('.view').forEach((el) => { const view = (el as any).f7View; if (!view?.router) return; const midTransition = el.classList.contains('router-transition'); if (midTransition) { const since = stuckSince.get(el); if (!since) { stuckSince.set(el, Date.now()); return; } if (Date.now() - since < STUCK_AFTER_MS) return; // Only the view's transition flags. A page's own page-previous / // page-next class is how F7 identifies the page to go back to; // removing those makes back() rebuild the screen and stack a // duplicate rather than popping. el.classList.remove( 'router-transition', 'router-transition-forward', 'router-transition-backward', 'router-transition-css-forward', 'router-transition-css-backward' ); } stuckSince.delete(el); if (view.router.allowPageChange === false) { view.router.allowPageChange = true; } }); }; f7ready((app) => { app.on('pageAfterIn', () => { window.clearTimeout(timer); timer = window.setTimeout(release, 600); }); }); // pageAfterIn is itself part of the transition pipeline, so it can be // missed too. A slow poll costs nothing and guarantees recovery. const poll = window.setInterval(release, 1500); return () => { window.clearTimeout(timer); window.clearInterval(poll); }; }, []); } /** * Whether there is a session, kept live for this tab. * * The gate belongs to the shell, not to each screen. It used to live in * `Screen`, which every one of the five tab views renders at once — so a * logged-out start had all five firing `navigate('/login/', {reloadAll:true})` * at the same view, each reload remounting the others. The login form was * being torn down and rebuilt continuously and could not be typed into. */ function useSession() { const [authed, setAuthed] = useState(() => apiClient.isAuthenticated()); useEffect(() => { const check = () => setAuthed(apiClient.isAuthenticated()); // `storage` covers other tabs; AUTH_EVENT covers this one. window.addEventListener('storage', check); window.addEventListener(AUTH_EVENT, check); return () => { window.removeEventListener('storage', check); window.removeEventListener(AUTH_EVENT, check); }; }, []); return authed; } function App() { useTheme(); const authed = useSession(); useClearBootFlag(); useRouterWatchdog(); const navigating = useNavProgress(); return ( <> {!authed ? ( /* One view, no tab bar: there is nothing to navigate to yet. */ ) : ( {TABS.map((tab) => ( ))} {/* Each tab keeps its own navigation stack, so pushing a detail screen inside 健康 does not disturb where 趋势 was left. */} {TABS.map((tab) => ( ))} )} ); } export default App;