fix: GL maps read the stored copy again while offline, and a switch that is on keeps a visible knob in the dark scheme

This commit is contained in:
Maurice
2026-11-08 12:21:52 +01:00
parent 06c03cbded
commit eea83bb1c9
6 changed files with 83 additions and 26 deletions
@@ -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(<MToggle checked onChange={onChange} ariaLabel="Offline" />);
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(<MToggle checked={false} onChange={onChange} ariaLabel="Offline" />);
expect((screen.getByRole('switch').firstElementChild as HTMLElement).className).toContain(
'bg-[color:var(--m-knob)]'
);
fireEvent.click(screen.getByRole('switch'));
expect(onChange).toHaveBeenCalledWith(true);
});
});
+9 -3
View File
@@ -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 (
<button
@@ -27,8 +31,10 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
} ${className}`}
>
<span
className={`block h-[18px] w-[18px] rounded-full bg-[color:var(--m-knob)] transition-transform duration-[180ms] ${
checked ? 'translate-x-[18px] rtl:-translate-x-[18px]' : 'translate-x-0'
className={`block h-[18px] w-[18px] rounded-full transition-transform duration-[180ms] ${
checked
? 'translate-x-[18px] bg-[color:var(--m-actfg)] rtl:-translate-x-[18px]'
: 'translate-x-0 bg-[color:var(--m-knob)]'
}`}
/>
</button>
+23 -14
View File
@@ -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);
});
});
+17 -3
View File
@@ -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();
}
+1 -1
View File
@@ -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<string, Response>();
const cache = { match: vi.fn(async (url: string) => cached.get(url)?.clone()) };
+11 -5
View File
@@ -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<Cache | null> {
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);
}