feat: section 3 — km milestone notifications

- Foreground notification handler in App.tsx (iOS shows banners while active)
- requestNotificationPermissions() called on app mount
- GPS task tracks running distance per recording session (module-level state)
- Fires immediate notification at each km crossed, gated on kmNotifications setting
This commit is contained in:
Davide Scaini
2026-06-03 09:03:55 +02:00
parent 2378d31f0b
commit 5f12b2857d
4 changed files with 75 additions and 5 deletions
+17
View File
@@ -1,8 +1,25 @@
import { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { AppNavigator } from './src/navigation/AppNavigator';
import { requestNotificationPermissions } from './src/services/gps';
// Show notifications even when the app is in the foreground (iOS suppresses by default)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export default function App() {
useEffect(() => {
requestNotificationPermissions();
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" />
+3 -3
View File
@@ -112,10 +112,10 @@ Items below are what remains before v1 is shippable.
- [x] **Cadence CSC parsing** — stateful parsing in `parseCscMeasurement()`: tracks previous crank revolution count + event time (uint16, 1/1024 s), computes RPM from delta with uint16 rollover handling
- [x] **BLE persistence**`savePairedDevice` / `loadPairedDevices` / `removePairedDevice` in `ble.ts` via AsyncStorage; SensorPairingScreen shows saved sensors at top, auto-attempts reconnect on mount, Forget button removes a device
### 3 — Km notifications (half day)
### 3 — Km notifications
- [ ] **Permission request**call `Notifications.requestPermissionsAsync()` on first launch
- [ ] **Milestone tracker**track last notified km in the GPS background task (or store); fire a notification each time `distanceMeters` crosses a new km boundary, gated on the `kmNotifications` setting from AsyncStorage
- [x] **Permission request**`requestNotificationPermissions()` called in `App.tsx` on mount; foreground notification handler set at module level so iOS shows banners while app is active
- [x] **Milestone tracker**module-level `runningDistanceMeters` / `lastNotifiedKm` / `prevPoint` in `gps.ts`, reset on each `startGpsRecording()`; each incoming location adds haversine distance and fires a notification when a new km is crossed, gated on the `kmNotifications` AsyncStorage setting
### 4 — Android battery optimization prompt (12 hours)
View File
+55 -2
View File
@@ -1,9 +1,16 @@
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';
import * as Notifications from 'expo-notifications';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRecordingStore } from '../store/recording';
const BACKGROUND_LOCATION_TASK = 'background-location-task';
// Module-level km milestone state — reset each recording
let runningDistanceMeters = 0;
let lastNotifiedKm = 0;
let prevPoint: { lat: number; lon: number } | null = null;
TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) => {
if (error) return;
const locations: Location.LocationObject[] = data?.locations ?? [];
@@ -11,7 +18,7 @@ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) =>
if (status !== 'recording') return;
for (const loc of locations) {
addTrackPoint({
const point = {
lat: loc.coords.latitude,
lon: loc.coords.longitude,
ele: loc.coords.altitude ?? 0,
@@ -19,10 +26,35 @@ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) =>
hr: ble.hr,
power: ble.power,
cad: ble.cadence,
});
};
addTrackPoint(point);
if (prevPoint) {
runningDistanceMeters += haversineMeters(prevPoint, point);
await maybeNotifyKm(runningDistanceMeters);
}
prevPoint = point;
}
});
async function maybeNotifyKm(distanceMeters: number): Promise<void> {
const kmSetting = await AsyncStorage.getItem('kmNotifications');
if (kmSetting === 'false') return;
const crossedKm = Math.floor(distanceMeters / 1000);
if (crossedKm <= lastNotifiedKm) return;
lastNotifiedKm = crossedKm;
await Notifications.scheduleNotificationAsync({
content: {
title: `${crossedKm} km`,
body: `You've covered ${crossedKm} km!`,
sound: true,
},
trigger: null,
});
}
export async function requestLocationPermissions(): Promise<boolean> {
const { status: fg } = await Location.requestForegroundPermissionsAsync();
if (fg !== 'granted') return false;
@@ -31,6 +63,11 @@ export async function requestLocationPermissions(): Promise<boolean> {
}
export async function startGpsRecording(): Promise<void> {
// Reset km tracking for this recording session
runningDistanceMeters = 0;
lastNotifiedKm = 0;
prevPoint = null;
await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
accuracy: Location.Accuracy.BestForNavigation,
timeInterval: 1000,
@@ -47,3 +84,19 @@ export async function stopGpsRecording(): Promise<void> {
const isRunning = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
if (isRunning) await Location.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
}
export async function requestNotificationPermissions(): Promise<void> {
await Notifications.requestPermissionsAsync();
}
function haversineMeters(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
const R = 6371000;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const sinLat = Math.sin(dLat / 2);
const sinLon = Math.sin(dLon / 2);
const c = sinLat * sinLat + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinLon * sinLon;
return R * 2 * Math.atan2(Math.sqrt(c), Math.sqrt(1 - c));
}
const toRad = (deg: number) => (deg * Math.PI) / 180;