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:
@@ -1,8 +1,25 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { AppNavigator } from './src/navigation/AppNavigator';
|
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() {
|
export default function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
requestNotificationPermissions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
|
|||||||
@@ -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] **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
|
- [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
|
- [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
|
||||||
- [ ] **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] **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)
|
### 4 — Android battery optimization prompt (1–2 hours)
|
||||||
|
|
||||||
|
|||||||
+55
-2
@@ -1,9 +1,16 @@
|
|||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
import * as TaskManager from 'expo-task-manager';
|
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';
|
import { useRecordingStore } from '../store/recording';
|
||||||
|
|
||||||
const BACKGROUND_LOCATION_TASK = 'background-location-task';
|
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) => {
|
TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) => {
|
||||||
if (error) return;
|
if (error) return;
|
||||||
const locations: Location.LocationObject[] = data?.locations ?? [];
|
const locations: Location.LocationObject[] = data?.locations ?? [];
|
||||||
@@ -11,7 +18,7 @@ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) =>
|
|||||||
if (status !== 'recording') return;
|
if (status !== 'recording') return;
|
||||||
|
|
||||||
for (const loc of locations) {
|
for (const loc of locations) {
|
||||||
addTrackPoint({
|
const point = {
|
||||||
lat: loc.coords.latitude,
|
lat: loc.coords.latitude,
|
||||||
lon: loc.coords.longitude,
|
lon: loc.coords.longitude,
|
||||||
ele: loc.coords.altitude ?? 0,
|
ele: loc.coords.altitude ?? 0,
|
||||||
@@ -19,10 +26,35 @@ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) =>
|
|||||||
hr: ble.hr,
|
hr: ble.hr,
|
||||||
power: ble.power,
|
power: ble.power,
|
||||||
cad: ble.cadence,
|
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> {
|
export async function requestLocationPermissions(): Promise<boolean> {
|
||||||
const { status: fg } = await Location.requestForegroundPermissionsAsync();
|
const { status: fg } = await Location.requestForegroundPermissionsAsync();
|
||||||
if (fg !== 'granted') return false;
|
if (fg !== 'granted') return false;
|
||||||
@@ -31,6 +63,11 @@ export async function requestLocationPermissions(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startGpsRecording(): Promise<void> {
|
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, {
|
await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
|
||||||
accuracy: Location.Accuracy.BestForNavigation,
|
accuracy: Location.Accuracy.BestForNavigation,
|
||||||
timeInterval: 1000,
|
timeInterval: 1000,
|
||||||
@@ -47,3 +84,19 @@ export async function stopGpsRecording(): Promise<void> {
|
|||||||
const isRunning = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
|
const isRunning = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
|
||||||
if (isRunning) await Location.stopLocationUpdatesAsync(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;
|
||||||
|
|||||||
Reference in New Issue
Block a user