feat(settings): onglet "Paramètres d'application" (#86)
Some checks failed
Some checks failed
Nouvelle route /parametres/application + entrée dans SETTINGS_ITEMS (desktop et sheet mobile, un seul tableau partagé). Thème et "rejouer un tutoriel" restent inchangés dans /parametres/preferences-utilisateur (décision explicite du chantier). Le contenu affiché diffère réellement selon la plateforme, pas par simple choix cosmétique — chaque moitié de la page est inerte sur l'autre plateforme, pas juste hors-sujet : - Version installée + "Vérifier les mises à jour" (natif seulement) : useAppUpdateCheck (déjà utilisé par la bannière silencieuse existante) gagne un checkNow() qui relance la même comparaison sans attendre le throttle de 6h, réutilisant la logique existante plutôt que de la dupliquer (fetchLatestVersionResult extraite en fonction partagée). Nouveau hook useInstalledVersion (App.getInfo() natif, __APP_VERSION__ web). - "Toujours rester sur le site web" (web seulement, tout appareil) : pilote STAY_ON_WEB_PREFERENCE_KEY (déjà exporté par mobile-app-redirect.ts, ticket #84). Volontairement masqué côté natif : le WebView Capacitor tourne sur sa propre origine https://localhost, un localStorage totalement séparé de celui du vrai site web que lit attemptOpenNativeApp — modifier la préférence depuis l'app ne changerait donc jamais rien pour un visiteur mobile réel. Documenté explicitement dans le commentaire de la page pour ne pas réintroduire ce contrôle-fantôme plus tard. Couverture Cypress : nouveau app-settings-page.cy.ts (contenu web, persistance du toggle après reload) ; sidebar.cy.ts étendu pour couvrir le nouveau lien. Le déclenchement natif réel (App.getInfo(), l'appel réseau de checkNow) n'a pas de point d'entrée testable depuis un run Cypress web — même posture déjà acceptée pour useAppUpdateCheck/ UpdateBanner, la logique de comparaison pure reste couverte par version-compare.cy.tsx (inchangé). Suite complète : biome check, tsc -b --force, build, 146/146 e2e, 71/71 composants — tous verts. Closes #86 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
23bc19b482
commit
e1589e63d0
9 changed files with 362 additions and 25 deletions
68
apps/web/cypress/e2e/app-settings-page.cy.ts
Normal file
68
apps/web/cypress/e2e/app-settings-page.cy.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
||||
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
|
||||
<Route path="/parametres/application" element={<AppSettingsPage />} />
|
||||
<Route path="/parametres/credits" element={<CreditsPage />} />
|
||||
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -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<AppUpdateCheckResult> {
|
||||
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<AppUpdateCheckResult>;
|
||||
} {
|
||||
const [availableVersion, setAvailableVersion] = useState<string | null>(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<AppUpdateCheckResult> {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
43
apps/web/src/features/app-update/useInstalledVersion.ts
Normal file
43
apps/web/src/features/app-update/useInstalledVersion.ts
Normal file
|
|
@ -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<string>(__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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@
|
|||
"preferences": "Préférences alimentaires",
|
||||
"household": "Foyer",
|
||||
"userPreferences": "Préférences utilisateur",
|
||||
"application": "Paramètres d'application",
|
||||
"credits": "Crédits"
|
||||
}
|
||||
},
|
||||
|
|
@ -1390,5 +1391,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
128
apps/web/src/pages/settings/AppSettingsPage.tsx
Normal file
128
apps/web/src/pages/settings/AppSettingsPage.tsx
Normal file
|
|
@ -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<CheckState>("idle");
|
||||
const [foundVersion, setFoundVersion] = useState<string | null>(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 (
|
||||
<div className="settings-page">
|
||||
<h1>{t("appSettings.title")}</h1>
|
||||
|
||||
<div className="settings-page__section">
|
||||
<h2>{t("appSettings.version.title")}</h2>
|
||||
<p className="app-settings__version">
|
||||
{t("appSettings.version.installed", { version: installedVersion })}
|
||||
</p>
|
||||
|
||||
{isNative ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCheckNow()}
|
||||
disabled={checkState === "checking"}
|
||||
>
|
||||
{checkState === "checking"
|
||||
? t("appSettings.version.checking")
|
||||
: t("appSettings.version.checkButton")}
|
||||
</button>
|
||||
|
||||
{checkState === "up-to-date" && (
|
||||
<p className="settings-page__saved">{t("appSettings.version.upToDate")}</p>
|
||||
)}
|
||||
{checkState === "update-available" && foundVersion && (
|
||||
<p className="app-settings__update-found">
|
||||
{t("appUpdate.banner.text", { version: foundVersion })}{" "}
|
||||
<a href={`${API_BASE_URL}/mobile/download`} target="_blank" rel="noreferrer">
|
||||
{t("appUpdate.banner.update")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{checkState === "error" && (
|
||||
<p className="field-error">{t("appSettings.version.checkError")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="settings-page__hint">{t("appSettings.version.webHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isNative && (
|
||||
<div className="settings-page__section">
|
||||
<h2>{t("appSettings.redirect.title")}</h2>
|
||||
<CheckboxOption checked={stayOnWeb} onChange={handleStayOnWebChange}>
|
||||
{t("appSettings.redirect.stayOnWebLabel")}
|
||||
</CheckboxOption>
|
||||
<p className="settings-page__hint">{t("appSettings.redirect.stayOnWebHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue