feat: configurable map orientation (Settings > App)
Three modes selectable from Settings > App > Map orientation:
- North up: map always points north (MapLibre 'default')
- Compass: rotates with device compass heading ('heading')
- Course up: rotates to direction of travel ('course')
Default is North up. Setting persisted in AsyncStorage via ThemeContext.
This commit is contained in:
+40
-35
@@ -2,59 +2,63 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { PALETTES, FONT_SCALE, type PaletteKey, type FontSizeKey } from './theme';
|
import { PALETTES, FONT_SCALE, type PaletteKey, type FontSizeKey } from './theme';
|
||||||
|
|
||||||
|
export type MapOrientation = 'north' | 'compass' | 'course';
|
||||||
|
|
||||||
interface ThemeValue {
|
interface ThemeValue {
|
||||||
accent: string;
|
accent: string;
|
||||||
accentDim: string;
|
accentDim: string;
|
||||||
palette: PaletteKey;
|
palette: PaletteKey;
|
||||||
setPalette: (p: PaletteKey) => void;
|
setPalette: (p: PaletteKey) => void;
|
||||||
fontSize: FontSizeKey;
|
fontSize: FontSizeKey;
|
||||||
setFontSize: (s: FontSizeKey) => void;
|
setFontSize: (s: FontSizeKey) => void;
|
||||||
boldLabels: boolean;
|
boldLabels: boolean;
|
||||||
setBoldLabels: (b: boolean) => void;
|
setBoldLabels: (b: boolean) => void;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
mapOrientation: MapOrientation;
|
||||||
|
setMapOrientation: (o: MapOrientation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAP_ORIENTATIONS: MapOrientation[] = ['north', 'compass', 'course'];
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeValue>({
|
const ThemeContext = createContext<ThemeValue>({
|
||||||
...PALETTES.default,
|
...PALETTES.default,
|
||||||
palette: 'default',
|
palette: 'default',
|
||||||
setPalette: () => {},
|
setPalette: () => {},
|
||||||
fontSize: 'medium',
|
fontSize: 'medium',
|
||||||
setFontSize: () => {},
|
setFontSize: () => {},
|
||||||
boldLabels: false,
|
boldLabels: false,
|
||||||
setBoldLabels: () => {},
|
setBoldLabels: () => {},
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
mapOrientation: 'north',
|
||||||
|
setMapOrientation: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [palette, setPaletteState] = useState<PaletteKey>('default');
|
const [palette, setPaletteState] = useState<PaletteKey>('default');
|
||||||
const [fontSize, setFontSizeState] = useState<FontSizeKey>('medium');
|
const [fontSize, setFontSizeState] = useState<FontSizeKey>('medium');
|
||||||
const [boldLabels, setBoldLabelsState] = useState(false);
|
const [boldLabels, setBoldLabelsState] = useState(false);
|
||||||
|
const [mapOrientation, setMapOrientationState] = useState<MapOrientation>('north');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const [p, f, b] = await Promise.all([
|
const [p, f, b, m] = await Promise.all([
|
||||||
AsyncStorage.getItem('themePalette'),
|
AsyncStorage.getItem('themePalette'),
|
||||||
AsyncStorage.getItem('themeFontSize'),
|
AsyncStorage.getItem('themeFontSize'),
|
||||||
AsyncStorage.getItem('themeBoldLabels'),
|
AsyncStorage.getItem('themeBoldLabels'),
|
||||||
|
AsyncStorage.getItem('mapOrientation'),
|
||||||
]);
|
]);
|
||||||
if (p && p in PALETTES) setPaletteState(p as PaletteKey);
|
if (p && p in PALETTES) setPaletteState(p as PaletteKey);
|
||||||
if (f && f in FONT_SCALE) setFontSizeState(f as FontSizeKey);
|
if (f && f in FONT_SCALE) setFontSizeState(f as FontSizeKey);
|
||||||
if (b !== null) setBoldLabelsState(b === 'true');
|
if (b !== null) setBoldLabelsState(b === 'true');
|
||||||
|
if (m && MAP_ORIENTATIONS.includes(m as MapOrientation))
|
||||||
|
setMapOrientationState(m as MapOrientation);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function setPalette(p: PaletteKey) {
|
function setPalette(p: PaletteKey) { setPaletteState(p); AsyncStorage.setItem('themePalette', p); }
|
||||||
setPaletteState(p);
|
function setFontSize(f: FontSizeKey) { setFontSizeState(f); AsyncStorage.setItem('themeFontSize', f); }
|
||||||
AsyncStorage.setItem('themePalette', p);
|
function setBoldLabels(b: boolean) { setBoldLabelsState(b); AsyncStorage.setItem('themeBoldLabels', String(b)); }
|
||||||
}
|
function setMapOrientation(o: MapOrientation) { setMapOrientationState(o); AsyncStorage.setItem('mapOrientation', o); }
|
||||||
function setFontSize(f: FontSizeKey) {
|
|
||||||
setFontSizeState(f);
|
|
||||||
AsyncStorage.setItem('themeFontSize', f);
|
|
||||||
}
|
|
||||||
function setBoldLabels(b: boolean) {
|
|
||||||
setBoldLabelsState(b);
|
|
||||||
AsyncStorage.setItem('themeBoldLabels', String(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{
|
<ThemeContext.Provider value={{
|
||||||
@@ -64,6 +68,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fontSize, setFontSize,
|
fontSize, setFontSize,
|
||||||
boldLabels, setBoldLabels,
|
boldLabels, setBoldLabels,
|
||||||
scale: FONT_SCALE[fontSize],
|
scale: FONT_SCALE[fontSize],
|
||||||
|
mapOrientation, setMapOrientation,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|||||||
|
|
||||||
export function RecordingScreen() {
|
export function RecordingScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
const { accent, accentDim, scale, boldLabels } = useTheme();
|
const { accent, accentDim, scale, boldLabels, mapOrientation } = useTheme();
|
||||||
|
|
||||||
|
// Map orientation setting → MapLibre trackUserLocation value
|
||||||
|
const trackUserLocation = mapOrientation === 'north' ? 'default'
|
||||||
|
: mapOrientation === 'compass' ? 'heading'
|
||||||
|
: 'course';
|
||||||
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
|
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
|
||||||
const [stats, setStats] = useState(getStats());
|
const [stats, setStats] = useState(getStats());
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -106,7 +111,7 @@ export function RecordingScreen() {
|
|||||||
<View style={styles.mapArea}>
|
<View style={styles.mapArea}>
|
||||||
<Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
<Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
||||||
<Camera
|
<Camera
|
||||||
trackUserLocation={status === 'recording' ? 'course' : 'default'}
|
trackUserLocation={trackUserLocation}
|
||||||
initialViewState={{ zoom: 14 }}
|
initialViewState={{ zoom: 14 }}
|
||||||
/>
|
/>
|
||||||
<UserLocation>
|
<UserLocation>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { login, logout, loadAuthState } from '../services/auth';
|
import { login, logout, loadAuthState } from '../services/auth';
|
||||||
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
|
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useTheme, type MapOrientation } from '../ThemeContext';
|
||||||
|
|
||||||
type Tab = 'ui' | 'app' | 'sync';
|
type Tab = 'ui' | 'app' | 'sync';
|
||||||
|
|
||||||
@@ -93,8 +93,14 @@ function UITab() {
|
|||||||
|
|
||||||
// ─── App tab ─────────────────────────────────────────────────────────────────
|
// ─── App tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAP_ORIENTATION_OPTIONS: { key: MapOrientation; label: string; sub: string }[] = [
|
||||||
|
{ key: 'north', label: 'North up', sub: 'Map always points north' },
|
||||||
|
{ key: 'compass', label: 'Compass', sub: 'Rotates with device heading' },
|
||||||
|
{ key: 'course', label: 'Course up', sub: 'Rotates to direction of travel' },
|
||||||
|
];
|
||||||
|
|
||||||
function AppTab() {
|
function AppTab() {
|
||||||
const { accent } = useTheme();
|
const { accent, accentDim, mapOrientation, setMapOrientation } = useTheme();
|
||||||
const [kmNotifications, setKmNotifications] = useState(true);
|
const [kmNotifications, setKmNotifications] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,6 +116,24 @@ function AppTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.sectionTitle}>Map orientation</Text>
|
||||||
|
{MAP_ORIENTATION_OPTIONS.map(({ key, label, sub }) => {
|
||||||
|
const active = mapOrientation === key;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={key}
|
||||||
|
style={[styles.row, active && { borderColor: accent, backgroundColor: accentDim }]}
|
||||||
|
onPress={() => setMapOrientation(key)}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.rowLabel, active && { color: accent }]}>{label}</Text>
|
||||||
|
<Text style={styles.rowSub}>{sub}</Text>
|
||||||
|
</View>
|
||||||
|
{active && <Text style={{ color: accent, fontSize: 16 }}>✓</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Notifications</Text>
|
<Text style={styles.sectionTitle}>Notifications</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View>
|
<View>
|
||||||
|
|||||||
Reference in New Issue
Block a user