diff --git a/apps/postcards/src/lib/store/useSettings.ts b/apps/postcards/src/lib/store/useSettings.ts index 9abb620..0178dff 100644 --- a/apps/postcards/src/lib/store/useSettings.ts +++ b/apps/postcards/src/lib/store/useSettings.ts @@ -139,8 +139,9 @@ function loadAutoGuides(): boolean { // On first run the map is the bundled offline vector basemap — zero outbound // requests. Streaming detailed tiles from OpenStreetMap (which discloses your IP // and the area you're looking at) waits for a deliberate opt-in: the one-tap -// "Show detailed map" consent on the map, or this toggle in Settings. Only "1" -// enables, so a fresh install never reaches the network for a tile. +// "Show detailed map" consent on the map, this toggle in Settings, or Start on the +// welcome page with "Detailed map" left on. Only "1" enables, so a fresh install +// never reaches the network for a tile before one of those. function loadOnlineMap(): boolean { return readLocal(ONLINE_MAP_KEY) === "1"; } diff --git a/apps/postcards/src/ui/IntroScreen.tsx b/apps/postcards/src/ui/IntroScreen.tsx index 911754a..97d03e4 100644 --- a/apps/postcards/src/ui/IntroScreen.tsx +++ b/apps/postcards/src/ui/IntroScreen.tsx @@ -37,10 +37,26 @@ function Switch({ ); } +/** The welcome page has been closed once (App writes this when it closes). */ +const INTRO_KEY = "postcards-intro-seen"; +function introSeen(): boolean { + try { + return localStorage.getItem(INTRO_KEY) != null; + } catch { + return true; + } +} + /** * First-run welcome — a full page (not a modal) with a live spinning earth, * one line about what Postcards is, and a switch per optional feature so a * newcomer can flip on what they want on the spot. Skippable; shown once. + * + * On a first run, "Detailed map" and "Full city list" start switched on, but + * nothing reaches the network until the newcomer taps Start with them still on: + * that tap, with both switches in view, is the opt-in (Constitution: data leaves + * the device only on explicit user action). Skip applies neither. Opened again + * later, the switches show and change the current settings directly. */ export function IntroScreen({ onClose }: { onClose: () => void }) { const t = useT(); @@ -54,6 +70,10 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { const setReduceMapWork = useSettings((s) => s.setReduceMapWork); const showToast = useToast((s) => s.show); const startRef = useRef(null); + // First run: the two network features wait for Start (see above). + const [firstRun] = useState(() => !introSeen()); + const [wantMap, setWantMap] = useState(true); + const [wantCities, setWantCities] = useState(true); // Detect a phone / slower device (touch + small screen, or touch + few CPU // cores) so we can recommend the smooth-map settings up front. A laptop/desktop @@ -103,6 +123,27 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { showToast(ok ? t("settings.cities.toast.done") : t("settings.cities.toast.failed")); } + // Offline mode makes no optional request, so nothing waits for Start there: + // flipping the city list on downloads it at once, as it always has. + const deferred = firstRun && !offlineMode; + const mapOn = deferred ? wantMap : onlineMap; + const citiesOn = cities !== "idle" || (deferred && wantCities); + function toggleMap(on: boolean) { + if (deferred) setWantMap(on); + else setOnlineMap(on); + } + function toggleCities(on: boolean) { + if (deferred) setWantCities(on); + else if (on) void getCities(); + } + function start() { + if (deferred) { + setOnlineMap(wantMap); + if (wantCities && cities === "idle") void getCities(); + } + onClose(); + } + return (
- + )} @@ -196,17 +237,17 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { /> - {/* Offline city search — flipping it on downloads the full gazetteer - (~17 MB); once saved it stays on. */} + {/* Offline city search — on, it downloads the full gazetteer (~17 MB), on a + first run once Start is tapped; once saved it stays on. */}
  • 🏙️ {t("intro.cities.title")} {t("intro.cities.desc")}
    void getCities()} + onChange={toggleCities} label={t("intro.cities.title")} />
  • @@ -219,7 +260,7 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { className="intro-start" type="button" title={t("intro.start")} - onClick={onClose} + onClick={start} > {t("intro.start")} diff --git a/apps/postcards/tests/unit/introDefaults.spec.tsx b/apps/postcards/tests/unit/introDefaults.spec.tsx new file mode 100644 index 0000000..c694e75 --- /dev/null +++ b/apps/postcards/tests/unit/introDefaults.spec.tsx @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +const download = vi.fn(async () => true); +vi.mock("../../src/lib/reference/referenceData", async (orig) => ({ + ...(await orig()), + downloadFullCities: () => download(), + fullCitiesEnabled: () => false, +})); + +import { IntroScreen } from "../../src/ui/IntroScreen"; +import { useSettings } from "../../src/lib/store/useSettings"; + +beforeEach(() => { + localStorage.clear(); + download.mockClear(); + useSettings.getState().setOnlineMap(false); + useSettings.getState().setOfflineMode(false); +}); +afterEach(cleanup); + +const map = () => screen.getByRole("switch", { name: "Detailed map" }) as HTMLInputElement; +const cities = () => screen.getByRole("switch", { name: "Full city list" }) as HTMLInputElement; + +it("starts a first run with the detailed map and full city list switched on", () => { + render( {}} />); + expect(map().checked).toBe(true); + expect(cities().checked).toBe(true); + // Nothing reaches the network before Start. + expect(useSettings.getState().onlineMap).toBe(false); + expect(download).not.toHaveBeenCalled(); +}); + +it("turns both on when Start is tapped", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Get started" })); + expect(useSettings.getState().onlineMap).toBe(true); + expect(download).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalled(); +}); + +it("leaves off what was switched off, and Skip applies neither", () => { + render( {}} />); + fireEvent.click(cities()); + fireEvent.click(screen.getByRole("button", { name: "Get started" })); + expect(useSettings.getState().onlineMap).toBe(true); + expect(download).not.toHaveBeenCalled(); + cleanup(); + + useSettings.getState().setOnlineMap(false); + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: "Skip" })); + expect(useSettings.getState().onlineMap).toBe(false); + expect(download).not.toHaveBeenCalled(); +}); + +it("shows the current settings once the welcome has been seen", () => { + localStorage.setItem("postcards-intro-seen", "1"); + render( {}} />); + expect(map().checked).toBe(false); + expect(cities().checked).toBe(false); +});