diff --git a/client/src/mobile/components/MToggle.test.tsx b/client/src/mobile/components/MToggle.test.tsx new file mode 100644 index 000000000..97d1090b7 --- /dev/null +++ b/client/src/mobile/components/MToggle.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import MToggle from './MToggle'; + +vi.mock('../../native/haptics', () => ({ selectionHaptic: vi.fn() })); + +describe('MToggle', () => { + it('FE-MOB-TOGGLE-001: on, the knob takes the track ink so it stays visible on the near-white dark track', () => { + const onChange = vi.fn(); + const { rerender } = render(); + const knob = screen.getByRole('switch').firstElementChild as HTMLElement; + expect(knob.className).toContain('bg-[color:var(--m-actfg)]'); + expect(knob.className).not.toContain('--m-knob'); + + rerender(); + expect((screen.getByRole('switch').firstElementChild as HTMLElement).className).toContain( + 'bg-[color:var(--m-knob)]' + ); + fireEvent.click(screen.getByRole('switch')); + expect(onChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/client/src/mobile/components/MToggle.tsx b/client/src/mobile/components/MToggle.tsx index 1f11d37b0..41a963a46 100644 --- a/client/src/mobile/components/MToggle.tsx +++ b/client/src/mobile/components/MToggle.tsx @@ -7,7 +7,11 @@ interface MToggleProps { className?: string } -/** iOS-style switch: --m-act track when on, --m-trackoff when off, 18px knob. */ +/** + * iOS-style switch: --m-act track when on, --m-trackoff when off, 18px knob. The knob on + * takes the track's ink (--m-actfg): in the dark scheme the track is near white, and a + * white knob on it left the whole switch one white pill. + */ export default function MToggle({ checked, onChange, ariaLabel, disabled = false, className = '' }: MToggleProps) { return ( diff --git a/client/src/native/glSchemes.test.ts b/client/src/native/glSchemes.test.ts index 5c7f3052a..d8941560f 100644 --- a/client/src/native/glSchemes.test.ts +++ b/client/src/native/glSchemes.test.ts @@ -1,29 +1,38 @@ -import { describe, expect, it, vi } from 'vitest'; -import { hasNativeFeature } from './capabilities'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isEffectivelyOffline } from '../sync/networkMode'; import { GL_MAPS_THROUGH_APP_SCHEMES } from './glSchemes'; import { installOfflineProtocol, offlineStyle } from './offlineTiles'; -import { isNativeApp, isNativeIos } from './platform'; import { glTileStoreActive, mapboxTileCacheRequest, tileCacheActive } from './tileCache'; vi.mock('./platform', () => ({ isNativeIos: vi.fn(() => true), isNativeApp: vi.fn(() => true) })); vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn(() => true) })); +vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) })); + +const STYLE = 'https://tiles.openfreemap.org/styles/positron'; + +beforeEach(() => { + vi.stubGlobal('caches', { open: vi.fn() }); + vi.mocked(isEffectivelyOffline).mockReturnValue(false); +}); describe('GL maps and the app schemes', () => { - it('FE-NATIVE-GLSCHEME-001: in the iOS app the GL maps load straight from their servers, the raster store stays', () => { - expect(vi.mocked(isNativeIos)()).toBe(true); - expect(vi.mocked(isNativeApp)()).toBe(true); - expect(vi.mocked(hasNativeFeature)('tileCache')).toBe(true); + it('FE-NATIVE-GLSCHEME-001: online in the iOS app the GL maps load straight from their servers, the raster store stays', () => { expect(GL_MAPS_THROUGH_APP_SCHEMES).toBe(false); - - const style = 'https://tiles.openfreemap.org/styles/positron'; - expect(offlineStyle(style)).toBe(style); - const addProtocol = vi.fn(); - installOfflineProtocol({ addProtocol }); - expect(addProtocol).not.toHaveBeenCalled(); - + expect(offlineStyle(STYLE)).toBe(STYLE); const tile = 'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/1/1/1.vector.pbf'; expect(mapboxTileCacheRequest(tile, 'Tile')).toEqual({ url: tile }); expect(glTileStoreActive()).toBe(false); expect(tileCacheActive()).toBe(true); }); + + it('FE-NATIVE-GLSCHEME-002: offline, or set to be, the MapLibre basemap reads the stored copy through the protocol', () => { + const addProtocol = vi.fn(); + installOfflineProtocol({ addProtocol }); + expect(addProtocol).toHaveBeenCalledWith('trek-offline', expect.any(Function)); + + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + expect(offlineStyle(STYLE)).toBe('trek-offline://tiles.openfreemap.org/styles/positron'); + // Mapbox never goes through the store: offline it gives way to the stored MapLibre basemap. + expect(glTileStoreActive()).toBe(false); + }); }); diff --git a/client/src/native/glSchemes.ts b/client/src/native/glSchemes.ts index dce741fc7..edabe350a 100644 --- a/client/src/native/glSchemes.ts +++ b/client/src/native/glSchemes.ts @@ -1,3 +1,5 @@ +import { isEffectivelyOffline } from '../sync/networkMode'; + /** * Whether the GL maps (the Leaflet map's vector basemap and Mapbox GL) read * their style and tiles through the app's own URL schemes, trek-offline:// and @@ -7,8 +9,20 @@ * Mapbox black) while the 3D map, the Mapbox preview in the settings and the * raster maps, which do not go through those schemes for a GL map, drew * normally. The same maps draw normally in a browser. Until that is traced on a - * device, the GL maps load straight from their servers in the app, as they do - * in a browser, and keep no offline copy; the raster maps (Topo, satellite, a - * raster template of the user's) keep going through the tile store. + * device, the GL maps load straight from their servers in the app while it is + * online, as they do in a browser; offline they read the stored copy as before + * (glMapsThroughAppSchemes). The raster maps (Topo, satellite, a raster + * template of the user's) keep going through the tile store throughout. */ export const GL_MAPS_THROUGH_APP_SCHEMES = false; + +/** + * Whether a MapLibre basemap goes through trek-offline:// right now: always + * while the switch above is on, and otherwise while the app is offline (or set + * to be). Offline the stored copy is all there is to draw, so nothing is lost + * by trying it; online the map loads straight from its server. Mapbox does not + * follow: offline it gives way to the stored MapLibre basemap anyway. + */ +export function glMapsThroughAppSchemes(): boolean { + return GL_MAPS_THROUGH_APP_SCHEMES || isEffectivelyOffline(); +} diff --git a/client/src/native/offlineTiles.test.ts b/client/src/native/offlineTiles.test.ts index b25597489..0a031aaea 100644 --- a/client/src/native/offlineTiles.test.ts +++ b/client/src/native/offlineTiles.test.ts @@ -11,7 +11,7 @@ import { isNativeIos } from './platform'; vi.mock('./platform', () => ({ isNativeIos: vi.fn() })); // The mechanism is what is tested here; whether the GL maps use it is glSchemes.ts's switch. -vi.mock('./glSchemes', () => ({ GL_MAPS_THROUGH_APP_SCHEMES: true })); +vi.mock('./glSchemes', () => ({ GL_MAPS_THROUGH_APP_SCHEMES: true, glMapsThroughAppSchemes: () => true })); const cached = new Map(); const cache = { match: vi.fn(async (url: string) => cached.get(url)?.clone()) }; diff --git a/client/src/native/offlineTiles.ts b/client/src/native/offlineTiles.ts index 8b5651d65..2806617b2 100644 --- a/client/src/native/offlineTiles.ts +++ b/client/src/native/offlineTiles.ts @@ -1,6 +1,6 @@ import type { AddProtocolAction, GetResourceResponse, RequestParameters } from 'maplibre-gl'; import { VECTOR_CACHE } from '../sync/glPrefetcher'; -import { GL_MAPS_THROUGH_APP_SCHEMES } from './glSchemes'; +import { glMapsThroughAppSchemes } from './glSchemes'; import { isNativeIos } from './platform'; /** @@ -20,8 +20,14 @@ import { isNativeIos } from './platform'; const OFFLINE_SCHEME = 'trek-offline'; const PREFIX = `${OFFLINE_SCHEME}://`; +/** Whether the iOS app can serve the protocol at all. */ +function offlineProtocolAvailable(): boolean { + return isNativeIos() && typeof caches !== 'undefined'; +} + +/** Whether a style goes through the protocol right now (see glSchemes.ts). */ function offlineTilesActive(): boolean { - return GL_MAPS_THROUGH_APP_SCHEMES && isNativeIos() && typeof caches !== 'undefined'; + return offlineProtocolAvailable() && glMapsThroughAppSchemes(); } export function toOfflineUrl(url: string): string { @@ -106,8 +112,8 @@ async function load(url: string, networkFirst: boolean, signal: AbortSignal): Pr async function openCache(): Promise { try { return await caches.open(VECTOR_CACHE); - } catch (err) { - console.warn('[offline tiles] the vector cache is not available, reading from the network', err); + } catch { + // Refused by the web view: read from the network instead, which is all a cache miss does anyway. return null; } } @@ -127,5 +133,5 @@ export const offlineProtocol: AddProtocolAction = async ( export function installOfflineProtocol(maplibre: { addProtocol: (name: string, action: AddProtocolAction) => void; }): void { - if (offlineTilesActive()) maplibre.addProtocol(OFFLINE_SCHEME, offlineProtocol); + if (offlineProtocolAvailable()) maplibre.addProtocol(OFFLINE_SCHEME, offlineProtocol); }