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);
}