diff --git a/apps/web/cypress/e2e/app-settings-page.cy.ts b/apps/web/cypress/e2e/app-settings-page.cy.ts new file mode 100644 index 0000000..9309d95 --- /dev/null +++ b/apps/web/cypress/e2e/app-settings-page.cy.ts @@ -0,0 +1,68 @@ +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database. +// +// Cypress always runs as a plain web browser — `Capacitor.isNativePlatform()` +// is never true here, so only this page's web-side content (the build +// version + the "stay on web" preference) is reachable from this spec. The +// native-only half (App.getInfo()/checkNow's actual version-comparison +// network call) has no seam to fake native from a web run — same +// untested-on-purpose posture already accepted for `useAppUpdateCheck`'s +// background check and `UpdateBanner` (neither has e2e coverage either); +// the underlying comparison logic itself is unit-tested in +// version-compare.cy.tsx, unchanged by this ticket. + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, +}; + +const STAY_ON_WEB_KEY = "batchcooking:stayOnWeb"; + +describe("App settings (/parametres/application)", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + }); + + it("shows the web build version, and the update-check section only on native", () => { + cy.visit("/parametres/application"); + + cy.contains("h1", "Paramètres d'application").should("be.visible"); + // The exact build number isn't asserted (would couple this test to + // package.json's version, bumped independently of this page) — just + // that a "Version X" line rendered at all, proving `useInstalledVersion` + // resolved something rather than being stuck empty. + cy.contains(".app-settings__version", "Version").should("be.visible"); + + cy.contains("button", "Vérifier les mises à jour").should("not.exist"); + cy.contains( + "La vérification des mises à jour n'est disponible que dans l'application mobile", + ).should("be.visible"); + }); + + it("defaults 'stay on web' to unchecked, and persists a toggle across a reload", () => { + cy.visit("/parametres/application"); + + cy.contains("label", "Toujours rester sur le site web") + .find('input[type="checkbox"]') + .should("not.be.checked"); + + cy.contains("label", "Toujours rester sur le site web").click(); + cy.contains("label", "Toujours rester sur le site web") + .find('input[type="checkbox"]') + .should("be.checked"); + cy.window().then((win) => { + expect(win.localStorage.getItem(STAY_ON_WEB_KEY)).to.equal("true"); + }); + + cy.reload(); + cy.contains("label", "Toujours rester sur le site web") + .find('input[type="checkbox"]') + .should("be.checked"); + }); +}); diff --git a/apps/web/cypress/e2e/sidebar.cy.ts b/apps/web/cypress/e2e/sidebar.cy.ts index 56d38e3..d1b94e1 100644 --- a/apps/web/cypress/e2e/sidebar.cy.ts +++ b/apps/web/cypress/e2e/sidebar.cy.ts @@ -43,6 +43,11 @@ describe("Sidebar — settings menu and account menu", () => { "href", "/parametres/preferences-utilisateur", ); + cy.contains("a", "Paramètres d'application").should( + "have.attr", + "href", + "/parametres/application", + ); }); it("opens the account menu from the greeting and links to Mon compte", () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8bc57ca..e1ebc69 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -24,6 +24,7 @@ import { ImportRecipePage } from "./pages/recipes/ImportRecipePage"; import { RecipeFormPage } from "./pages/recipes/RecipeFormPage"; import { RecipesPage } from "./pages/recipes/RecipesPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; +import { AppSettingsPage } from "./pages/settings/AppSettingsPage"; import { CreditsPage } from "./pages/settings/CreditsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage"; @@ -39,11 +40,12 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage"; * itself redirects to `/login` if needed. * * `/parametres/*` (compte/préférences alimentaires/foyer/préférences - * utilisateur/crédits) are the settings pages, reachable from the sidebar's - * bottom "Paramètres" menu and the account menu (see `AppLayout`) — nested under - * `AppLayout` like every other authenticated section. `/foyer` is the old, - * pre-split combined page's path; it now just redirects to - * `/parametres/foyer` so an existing bookmark/link keeps working. + * utilisateur/application/crédits) are the settings pages, reachable from + * the sidebar's bottom "Paramètres" menu and the account menu (see + * `AppLayout`) — nested under `AppLayout` like every other authenticated + * section. `/foyer` is the old, pre-split combined page's path; it now + * just redirects to `/parametres/foyer` so an existing bookmark/link keeps + * working. * * `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also * `RequireAuth`-gated — reached right after signup, once a session already @@ -97,6 +99,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/web/src/features/app-update/useAppUpdateCheck.ts b/apps/web/src/features/app-update/useAppUpdateCheck.ts index ddccf7b..e80a108 100644 --- a/apps/web/src/features/app-update/useAppUpdateCheck.ts +++ b/apps/web/src/features/app-update/useAppUpdateCheck.ts @@ -9,10 +9,43 @@ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt"; const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion"; +/** Outcome of a single version comparison — shared by the silent background check and {@link useAppUpdateCheck}'s manual `checkNow`, so a caller showing immediate feedback (issue #86's "Vérifier les mises à jour" button) can switch on it directly instead of re-deriving it from `availableVersion`. */ +export type AppUpdateCheckResult = + | { status: "up-to-date" } + | { status: "update-available"; version: string } + | { status: "error" }; + +/** + * Fetches the installed version (native `App.getInfo()`) and the latest + * tagged release, and compares them — the one place this comparison + * happens, shared by the background effect below and `checkNow`. No + * throttling and no `Preferences` side effects of its own; callers decide + * what to do with the result (persist a last-checked timestamp, filter + * against a dismissed version, etc.). + */ +async function fetchLatestVersionResult(): Promise { + try { + const { App } = await import("@capacitor/app"); + const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([ + App.getInfo(), + apiClient.getLatestMobileVersion(), + ]); + if (!latestTag || !isNewerVersion(installedVersion, latestTag)) { + return { status: "up-to-date" }; + } + return { status: "update-available", version: latestTag }; + } catch { + return { status: "error" }; + } +} + /** * Detects whether a newer version of the Android app has been tagged than * the one currently installed, and offers a way to dismiss the notice for - * that specific version. + * that specific version — plus, since issue #86, a manual `checkNow` for a + * "Vérifier les mises à jour" button (`AppSettingsPage`) that surfaces this + * same check's result immediately instead of waiting on the silent + * background timer. * * **Native only** — on the web, "the app" is just whatever the server is * currently serving; there is no "installed version" to compare against, @@ -21,20 +54,37 @@ const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion"; * none of them enter the web bundle (same pattern as `main.tsx`'s own * `capacitor-token-store` bootstrap) — this hook is safe to call * unconditionally from `AppLayout`, which renders on both platforms. + * `checkNow` itself still resolves on the web (`{ status: "error" }`, + * nothing to check) rather than rejecting — callers gate the *button* that + * would invoke it behind `Capacitor.isNativePlatform()` themselves + * (`AppSettingsPage` does), so this never actually runs there in practice. * * No Play Store integration exists (this app isn't distributed there) — * Google's In-App Update API has no purchase here at all, so this is a * from-scratch check, not a wrapper around it. * - * Fails silently on any network/parsing error — a background version check + * The background effect fails silently on any network/parsing error — it * must never surface an error banner or block anything, same posture as - * `ThemeContext`'s own preferences fetch. + * `ThemeContext`'s own preferences fetch. `checkNow` is the opposite: it's + * a direct response to an explicit click, so it returns its outcome + * (including `"error"`) for the caller to actually display. */ export function useAppUpdateCheck(): { /** The newer version's tag (e.g. `"v1.4.2"`), or `null` when none is available (or none has been checked/parsed yet). */ availableVersion: string | null; /** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */ dismiss: () => void; + /** + * Runs the check immediately, bypassing the background check's own + * throttle — an explicit "Vérifier les mises à jour" click should always + * hit the network, not silently no-op because a background check already + * ran recently. Also bypasses the dismissed-version filter: re-asking is + * itself a reason to show the result again, even for a version dismissed + * earlier. Updates `availableVersion` on `"update-available"` so the + * persistent {@link UpdateBanner} picks it up too, not just this call's + * own return value. + */ + checkNow: () => Promise; } { const [availableVersion, setAvailableVersion] = useState(null); @@ -43,28 +93,21 @@ export function useAppUpdateCheck(): { let cancelled = false; - async function checkForUpdate() { + async function backgroundCheck() { try { - const [{ App }, { Preferences }] = await Promise.all([ - import("@capacitor/app"), - import("@capacitor/preferences"), - ]); - + const { Preferences } = await import("@capacitor/preferences"); const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY }); const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0; if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return; - const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([ - App.getInfo(), - apiClient.getLatestMobileVersion(), - ]); + const result = await fetchLatestVersionResult(); await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }); - if (!latestTag || !isNewerVersion(installedVersion, latestTag)) return; + if (result.status !== "update-available") return; const { value: dismissedVersion } = await Preferences.get({ key: DISMISSED_VERSION_KEY }); - if (dismissedVersion === latestTag) return; + if (dismissedVersion === result.version) return; - if (!cancelled) setAvailableVersion(latestTag); + if (!cancelled) setAvailableVersion(result.version); } catch { // Silent — see this hook's own doc comment. Nothing to log to // (apps/web has no client-side LoggerService equivalent yet), and a @@ -72,16 +115,16 @@ export function useAppUpdateCheck(): { } } - void checkForUpdate(); + void backgroundCheck(); // Re-check whenever the app comes back to the foreground — the listener - // itself is cheap to keep registered, `checkForUpdate`'s own throttle + // itself is cheap to keep registered, `backgroundCheck`'s own throttle // above is what actually keeps this from hitting the network every time. let removeListener: (() => void) | undefined; void import("@capacitor/app").then(({ App }) => { if (cancelled) return; App.addListener("appStateChange", ({ isActive }) => { - if (isActive) void checkForUpdate(); + if (isActive) void backgroundCheck(); }).then((handle) => { removeListener = () => void handle.remove(); }); @@ -102,5 +145,27 @@ export function useAppUpdateCheck(): { ); } - return { availableVersion, dismiss }; + async function checkNow(): Promise { + try { + const result = await fetchLatestVersionResult(); + if (result.status === "update-available") setAvailableVersion(result.version); + // Best-effort — a manual check shouldn't make the *next* background + // check fire again immediately after. Not awaited: nothing the + // caller needs to wait on, and native-only anyway (a no-op import on + // the web build would just resolve to nothing useful). + void import("@capacitor/preferences").then(({ Preferences }) => + Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) }), + ); + return result; + } catch { + // `fetchLatestVersionResult` already turns every failure it can hit + // into `{ status: "error" }` rather than throwing — this only exists + // to keep `checkNow`'s own contract (a result, never a rejection) + // true in case that ever changes, per the repo's async/try-catch + // convention (see specs/dev-conventions.md). + return { status: "error" }; + } + } + + return { availableVersion, dismiss, checkNow }; } diff --git a/apps/web/src/features/app-update/useInstalledVersion.ts b/apps/web/src/features/app-update/useInstalledVersion.ts new file mode 100644 index 0000000..2c855ed --- /dev/null +++ b/apps/web/src/features/app-update/useInstalledVersion.ts @@ -0,0 +1,43 @@ +import { Capacitor } from "@capacitor/core"; +import { useEffect, useState } from "react"; + +/** + * The currently installed app version — `App.getInfo().version` (the real + * native package version, `versionName` from `android/app/build.gradle`) on + * native, the build-time `__APP_VERSION__` constant (baked from + * `package.json`, see `vite.config.ts`) on the web. There is no separate + * "installed" version on the web: the page currently loaded already *is* + * the current version, refreshing always gets the latest deploy. + * + * Starts as `__APP_VERSION__` even on native — a reasonable synchronous + * default (normally identical, both bumped together by the release + * pipeline) shown immediately rather than a blank/loading state — and is + * replaced by the native bridge's own answer once that resolves, in case + * the two ever drift. + * + * Extracted out of `useAppUpdateCheck` (which already does the exact same + * `@capacitor/app` dynamic import for its own background check) rather than + * folded into it: that hook's job is "is a newer version available", + * unrelated to "what version is this", and `AppSettingsPage` (issue #86) + * needs the latter regardless of whether an update check has run yet. + */ +export function useInstalledVersion(): string { + const [version, setVersion] = useState(__APP_VERSION__); + + useEffect(() => { + if (!Capacitor.isNativePlatform()) return; + let cancelled = false; + + void import("@capacitor/app").then(({ App }) => + App.getInfo().then(({ version: nativeVersion }) => { + if (!cancelled) setVersion(nativeVersion); + }), + ); + + return () => { + cancelled = true; + }; + }, []); + + return version; +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 6199617..d0ad9e9 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -8,6 +8,7 @@ import { useIsMobileViewport } from "../lib/useIsMobileViewport"; import "./AppLayout.scss"; import { AccountIcon, + AppSettingsIcon, ChevronLeftIcon, CreditsIcon, DietPreferencesIcon, @@ -51,6 +52,7 @@ const SETTINGS_ITEMS = [ { to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon }, { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon }, { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon }, + { to: "/parametres/application", key: "application", Icon: AppSettingsIcon }, { to: "/parametres/credits", key: "credits", Icon: CreditsIcon }, ] as const; diff --git a/apps/web/src/layouts/nav-icons.tsx b/apps/web/src/layouts/nav-icons.tsx index 6793608..8e662b9 100644 --- a/apps/web/src/layouts/nav-icons.tsx +++ b/apps/web/src/layouts/nav-icons.tsx @@ -30,6 +30,11 @@ export { Rss as SourcesIcon, Settings as SettingsIcon, ShoppingCart as ShoppingListIcon, + // "Paramètres d'application" (issue #86) — distinct from the generic + // Settings glyph (already the "Paramètres" section toggle itself) and + // from UserPreferencesIcon (theme/tutorials): this one is specifically + // about the app installation (version, updates, app-open behavior). + Smartphone as AppSettingsIcon, Star as FavoriteIcon, User as AccountIcon, } from "lucide-react"; diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 73a6956..356acc8 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -122,6 +122,7 @@ "preferences": "Préférences alimentaires", "household": "Foyer", "userPreferences": "Préférences utilisateur", + "application": "Paramètres d'application", "credits": "Crédits" } }, @@ -1395,5 +1396,22 @@ "update": "Mettre à jour", "dismiss": "Plus tard" } + }, + "appSettings": { + "title": "Paramètres d'application", + "version": { + "title": "Version installée", + "installed": "Version {{version}}", + "checkButton": "Vérifier les mises à jour", + "checking": "Vérification…", + "upToDate": "L'application est à jour.", + "checkError": "Impossible de vérifier les mises à jour pour le moment.", + "webHint": "La vérification des mises à jour n'est disponible que dans l'application mobile — sur le web, actualiser la page charge toujours la dernière version." + }, + "redirect": { + "title": "Ouverture de l'application", + "stayOnWebLabel": "Toujours rester sur le site web", + "stayOnWebHint": "Par défaut, ouvrir batchCooking sur votre téléphone propose de basculer vers l'application si elle est installée. Activez cette option pour rester sur le site web à chaque fois." + } } } diff --git a/apps/web/src/pages/settings/AppSettingsPage.tsx b/apps/web/src/pages/settings/AppSettingsPage.tsx new file mode 100644 index 0000000..ada4fd1 --- /dev/null +++ b/apps/web/src/pages/settings/AppSettingsPage.tsx @@ -0,0 +1,128 @@ +import { Capacitor } from "@capacitor/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { API_BASE_URL } from "../../api/client"; +import { CheckboxOption } from "../../components/ui/Checkbox"; +import { + type AppUpdateCheckResult, + useAppUpdateCheck, +} from "../../features/app-update/useAppUpdateCheck"; +import { useInstalledVersion } from "../../features/app-update/useInstalledVersion"; +import { STAY_ON_WEB_PREFERENCE_KEY } from "../../lib/mobile-app-redirect"; +import "./settings-pages.scss"; + +/** Status of the manual "Vérifier les mises à jour" click — `"idle"` before it's ever been pressed, `"checking"` while in flight, then whatever {@link AppUpdateCheckResult} it resolved to. */ +type CheckState = "idle" | "checking" | AppUpdateCheckResult["status"]; + +/** + * "Paramètres d'application" — routed at `/parametres/application` (issue + * #86). Deliberately distinct from `/parametres/preferences-utilisateur` + * (theme + "rejouer un tutoriel", chantier #42/#44): those stay exactly + * where they are, this page is new content only — the installed app's own + * identity/behavior, not how it looks. + * + * Shows a genuinely different section depending on platform, not just a + * cosmetic tweak — each half of this page is *inert* on the other + * platform, not merely irrelevant: + * - **Version + "Vérifier les mises à jour"** (native only): there is no + * "installed version" concept on the web (see {@link useInstalledVersion}), + * and `useAppUpdateCheck`'s underlying check itself is a no-op there. + * - **"Toujours rester sur le site web"** (web only, any device — not + * viewport-gated to phone, so it can be set ahead of time): drives + * `attemptOpenNativeApp` (`lib/mobile-app-redirect.ts`), which only ever + * runs from a real mobile *browser* tab. Reachable from *inside* the + * native app too (it's a normal in-app route), but toggling it there + * would be silently pointless: Capacitor's Android WebView runs on its + * own fixed `https://localhost` origin, a completely different + * `localStorage` than the public website a browser visitor actually + * lands on — so writing the preference from inside the app can never + * reach the code that reads it. Gating this section to non-native avoids + * ever presenting a control that visibly does nothing when toggled. + */ +export function AppSettingsPage() { + const { t } = useTranslation(); + const isNative = Capacitor.isNativePlatform(); + const installedVersion = useInstalledVersion(); + const { checkNow } = useAppUpdateCheck(); + const [checkState, setCheckState] = useState("idle"); + const [foundVersion, setFoundVersion] = useState(null); + const [stayOnWeb, setStayOnWeb] = useState( + () => localStorage.getItem(STAY_ON_WEB_PREFERENCE_KEY) === "true", + ); + + async function handleCheckNow() { + setCheckState("checking"); + try { + const result = await checkNow(); + setCheckState(result.status); + setFoundVersion(result.status === "update-available" ? result.version : null); + } catch { + // `checkNow` already resolves rather than rejects on every failure it + // can hit (see its own doc comment) — this exists only to keep this + // handler compliant with the repo's async/try-catch convention. + // Setting the error state below is itself the meaningful handling + // (same posture as UserPreferencesPage's own `handleChange`), so + // there's nothing to rethrow to — no caller is awaiting this handler. + setCheckState("error"); + } + } + + function handleStayOnWebChange(checked: boolean) { + setStayOnWeb(checked); + localStorage.setItem(STAY_ON_WEB_PREFERENCE_KEY, String(checked)); + } + + return ( +
+

{t("appSettings.title")}

+ +
+

{t("appSettings.version.title")}

+

+ {t("appSettings.version.installed", { version: installedVersion })} +

+ + {isNative ? ( + <> + + + {checkState === "up-to-date" && ( +

{t("appSettings.version.upToDate")}

+ )} + {checkState === "update-available" && foundVersion && ( +

+ {t("appUpdate.banner.text", { version: foundVersion })}{" "} + + {t("appUpdate.banner.update")} + +

+ )} + {checkState === "error" && ( +

{t("appSettings.version.checkError")}

+ )} + + ) : ( +

{t("appSettings.version.webHint")}

+ )} +
+ + {!isNative && ( +
+

{t("appSettings.redirect.title")}

+ + {t("appSettings.redirect.stayOnWebLabel")} + +

{t("appSettings.redirect.stayOnWebHint")}

+
+ )} +
+ ); +}