From 5f12b2857d0361fae3841e27825b12f76ec7612b Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 3 Jun 2026 09:03:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20section=203=20=E2=80=94=20km=20mileston?= =?UTF-8?q?e=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- App.tsx | 17 +++++++++++++ CLAUDE.md | 6 ++--- build_and_install.sh | 0 src/services/gps.ts | 57 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 build_and_install.sh diff --git a/App.tsx b/App.tsx index 38cebdd..43586f7 100644 --- a/App.tsx +++ b/App.tsx @@ -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 ( diff --git a/CLAUDE.md b/CLAUDE.md index 6c0ced5..4c75f68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (1–2 hours) diff --git a/build_and_install.sh b/build_and_install.sh new file mode 100644 index 0000000..e69de29 diff --git a/src/services/gps.ts b/src/services/gps.ts index 726ef9c..71d0c0a 100644 --- a/src/services/gps.ts +++ b/src/services/gps.ts @@ -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 { + 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 { const { status: fg } = await Location.requestForegroundPermissionsAsync(); if (fg !== 'granted') return false; @@ -31,6 +63,11 @@ export async function requestLocationPermissions(): Promise { } export async function startGpsRecording(): Promise { + // 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 { const isRunning = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK); if (isRunning) await Location.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK); } + +export async function requestNotificationPermissions(): Promise { + 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;