[UI mobile] Onglet "Paramètres d'application" #100
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",
|
"href",
|
||||||
"/parametres/preferences-utilisateur",
|
"/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", () => {
|
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 { RecipeFormPage } from "./pages/recipes/RecipeFormPage";
|
||||||
import { RecipesPage } from "./pages/recipes/RecipesPage";
|
import { RecipesPage } from "./pages/recipes/RecipesPage";
|
||||||
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
||||||
|
import { AppSettingsPage } from "./pages/settings/AppSettingsPage";
|
||||||
import { CreditsPage } from "./pages/settings/CreditsPage";
|
import { CreditsPage } from "./pages/settings/CreditsPage";
|
||||||
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
||||||
import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
import { PreferencesPage } from "./pages/settings/PreferencesPage";
|
||||||
|
|
@ -39,11 +40,12 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
* itself redirects to `/login` if needed.
|
* itself redirects to `/login` if needed.
|
||||||
*
|
*
|
||||||
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences
|
* `/parametres/*` (compte/préférences alimentaires/foyer/préférences
|
||||||
* utilisateur/crédits) are the settings pages, reachable from the sidebar's
|
* utilisateur/application/crédits) are the settings pages, reachable from
|
||||||
* bottom "Paramètres" menu and the account menu (see `AppLayout`) — nested under
|
* the sidebar's bottom "Paramètres" menu and the account menu (see
|
||||||
* `AppLayout` like every other authenticated section. `/foyer` is the old,
|
* `AppLayout`) — nested under `AppLayout` like every other authenticated
|
||||||
* pre-split combined page's path; it now just redirects to
|
* section. `/foyer` is the old, pre-split combined page's path; it now
|
||||||
* `/parametres/foyer` so an existing bookmark/link keeps working.
|
* just redirects to `/parametres/foyer` so an existing bookmark/link keeps
|
||||||
|
* working.
|
||||||
*
|
*
|
||||||
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
|
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
|
||||||
* `RequireAuth`-gated — reached right after signup, once a session already
|
* `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/preferences" element={<PreferencesPage />} />
|
||||||
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
||||||
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
|
<Route path="/parametres/preferences-utilisateur" element={<UserPreferencesPage />} />
|
||||||
|
<Route path="/parametres/application" element={<AppSettingsPage />} />
|
||||||
<Route path="/parametres/credits" element={<CreditsPage />} />
|
<Route path="/parametres/credits" element={<CreditsPage />} />
|
||||||
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
|
<Route path="/foyer" element={<Navigate to="/parametres/foyer" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,43 @@ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
|
||||||
const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt";
|
const LAST_CHECKED_KEY = "appUpdate_lastCheckedAt";
|
||||||
const DISMISSED_VERSION_KEY = "appUpdate_dismissedVersion";
|
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
|
* 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
|
* 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
|
* **Native only** — on the web, "the app" is just whatever the server is
|
||||||
* currently serving; there is no "installed version" to compare against,
|
* 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
|
* none of them enter the web bundle (same pattern as `main.tsx`'s own
|
||||||
* `capacitor-token-store` bootstrap) — this hook is safe to call
|
* `capacitor-token-store` bootstrap) — this hook is safe to call
|
||||||
* unconditionally from `AppLayout`, which renders on both platforms.
|
* 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) —
|
* 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
|
* Google's In-App Update API has no purchase here at all, so this is a
|
||||||
* from-scratch check, not a wrapper around it.
|
* 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
|
* 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(): {
|
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). */
|
/** 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;
|
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. */
|
/** Hides the banner for `availableVersion` specifically — it reappears only once an even newer version ships, never just on the next app start. */
|
||||||
dismiss: () => void;
|
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);
|
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -43,28 +93,21 @@ export function useAppUpdateCheck(): {
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function checkForUpdate() {
|
async function backgroundCheck() {
|
||||||
try {
|
try {
|
||||||
const [{ App }, { Preferences }] = await Promise.all([
|
const { Preferences } = await import("@capacitor/preferences");
|
||||||
import("@capacitor/app"),
|
|
||||||
import("@capacitor/preferences"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY });
|
const { value: lastCheckedRaw } = await Preferences.get({ key: LAST_CHECKED_KEY });
|
||||||
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
|
const lastCheckedAt = lastCheckedRaw ? Number(lastCheckedRaw) : 0;
|
||||||
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return;
|
if (Date.now() - lastCheckedAt < CHECK_INTERVAL_MS) return;
|
||||||
|
|
||||||
const [{ version: installedVersion }, { version: latestTag }] = await Promise.all([
|
const result = await fetchLatestVersionResult();
|
||||||
App.getInfo(),
|
|
||||||
apiClient.getLatestMobileVersion(),
|
|
||||||
]);
|
|
||||||
await Preferences.set({ key: LAST_CHECKED_KEY, value: String(Date.now()) });
|
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 });
|
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 {
|
} catch {
|
||||||
// Silent — see this hook's own doc comment. Nothing to log to
|
// Silent — see this hook's own doc comment. Nothing to log to
|
||||||
// (apps/web has no client-side LoggerService equivalent yet), and a
|
// (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
|
// 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.
|
// above is what actually keeps this from hitting the network every time.
|
||||||
let removeListener: (() => void) | undefined;
|
let removeListener: (() => void) | undefined;
|
||||||
void import("@capacitor/app").then(({ App }) => {
|
void import("@capacitor/app").then(({ App }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
App.addListener("appStateChange", ({ isActive }) => {
|
App.addListener("appStateChange", ({ isActive }) => {
|
||||||
if (isActive) void checkForUpdate();
|
if (isActive) void backgroundCheck();
|
||||||
}).then((handle) => {
|
}).then((handle) => {
|
||||||
removeListener = () => void handle.remove();
|
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 "./AppLayout.scss";
|
||||||
import {
|
import {
|
||||||
AccountIcon,
|
AccountIcon,
|
||||||
|
AppSettingsIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
CreditsIcon,
|
CreditsIcon,
|
||||||
DietPreferencesIcon,
|
DietPreferencesIcon,
|
||||||
|
|
@ -51,6 +52,7 @@ const SETTINGS_ITEMS = [
|
||||||
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
|
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
|
||||||
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
|
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
|
||||||
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
|
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
|
||||||
|
{ to: "/parametres/application", key: "application", Icon: AppSettingsIcon },
|
||||||
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
|
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ export {
|
||||||
Rss as SourcesIcon,
|
Rss as SourcesIcon,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
ShoppingCart as ShoppingListIcon,
|
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,
|
Star as FavoriteIcon,
|
||||||
User as AccountIcon,
|
User as AccountIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@
|
||||||
"preferences": "Préférences alimentaires",
|
"preferences": "Préférences alimentaires",
|
||||||
"household": "Foyer",
|
"household": "Foyer",
|
||||||
"userPreferences": "Préférences utilisateur",
|
"userPreferences": "Préférences utilisateur",
|
||||||
|
"application": "Paramètres d'application",
|
||||||
"credits": "Crédits"
|
"credits": "Crédits"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1390,5 +1391,22 @@
|
||||||
"update": "Mettre à jour",
|
"update": "Mettre à jour",
|
||||||
"dismiss": "Plus tard"
|
"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