use-geo-sync 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ interface GeoPoint {
2
+ id: string;
3
+ latitude: number;
4
+ longitude: number;
5
+ timestamp: number;
6
+ accuracy: number | null;
7
+ speed: number | null;
8
+ }
9
+ interface SyncConfig {
10
+ batchSize?: number;
11
+ syncEndpoint?: string;
12
+ syncIntervalMs?: number;
13
+ retryLimit?: number;
14
+ }
15
+ type PermissionState = 'idle' | 'requesting' | 'granted_foreground' | 'granted_background' | 'denied';
16
+ interface UseGeoSyncResult {
17
+ permissionState: PermissionState;
18
+ requestPermissions: () => Promise<void>;
19
+ startTracking: () => Promise<void>;
20
+ stopTracking: () => Promise<void>;
21
+ isTracking: boolean;
22
+ queueLength: number;
23
+ }
24
+
25
+ declare function useGeoPermissions(): {
26
+ permissionState: PermissionState;
27
+ requestPermissions: () => Promise<void>;
28
+ checkPermissions: () => Promise<void>;
29
+ };
30
+
31
+ declare function useGeoSync(config?: SyncConfig): UseGeoSyncResult;
32
+
33
+ declare const BACKGROUND_LOCATION_TASK = "BACKGROUND_LOCATION_TASK";
34
+
35
+ export { BACKGROUND_LOCATION_TASK, type GeoPoint, type PermissionState, type SyncConfig, type UseGeoSyncResult, useGeoPermissions, useGeoSync };
@@ -0,0 +1,35 @@
1
+ interface GeoPoint {
2
+ id: string;
3
+ latitude: number;
4
+ longitude: number;
5
+ timestamp: number;
6
+ accuracy: number | null;
7
+ speed: number | null;
8
+ }
9
+ interface SyncConfig {
10
+ batchSize?: number;
11
+ syncEndpoint?: string;
12
+ syncIntervalMs?: number;
13
+ retryLimit?: number;
14
+ }
15
+ type PermissionState = 'idle' | 'requesting' | 'granted_foreground' | 'granted_background' | 'denied';
16
+ interface UseGeoSyncResult {
17
+ permissionState: PermissionState;
18
+ requestPermissions: () => Promise<void>;
19
+ startTracking: () => Promise<void>;
20
+ stopTracking: () => Promise<void>;
21
+ isTracking: boolean;
22
+ queueLength: number;
23
+ }
24
+
25
+ declare function useGeoPermissions(): {
26
+ permissionState: PermissionState;
27
+ requestPermissions: () => Promise<void>;
28
+ checkPermissions: () => Promise<void>;
29
+ };
30
+
31
+ declare function useGeoSync(config?: SyncConfig): UseGeoSyncResult;
32
+
33
+ declare const BACKGROUND_LOCATION_TASK = "BACKGROUND_LOCATION_TASK";
34
+
35
+ export { BACKGROUND_LOCATION_TASK, type GeoPoint, type PermissionState, type SyncConfig, type UseGeoSyncResult, useGeoPermissions, useGeoSync };
package/dist/index.js ADDED
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ BACKGROUND_LOCATION_TASK: () => BACKGROUND_LOCATION_TASK,
34
+ useGeoPermissions: () => useGeoPermissions,
35
+ useGeoSync: () => useGeoSync
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/useGeoPermissions.ts
40
+ var import_react = require("react");
41
+ var Location = __toESM(require("expo-location"));
42
+ function useGeoPermissions() {
43
+ const [permissionState, setPermissionState] = (0, import_react.useState)("idle");
44
+ const checkPermissions = (0, import_react.useCallback)(async () => {
45
+ try {
46
+ const { status: fgStatus } = await Location.getForegroundPermissionsAsync();
47
+ if (fgStatus === "granted") {
48
+ const { status: bgStatus } = await Location.getBackgroundPermissionsAsync();
49
+ setPermissionState(bgStatus === "granted" ? "granted_background" : "granted_foreground");
50
+ } else if (fgStatus === "denied") {
51
+ setPermissionState("denied");
52
+ } else {
53
+ setPermissionState("idle");
54
+ }
55
+ } catch (error) {
56
+ console.error("[use-geo-sync] Error checking permissions:", error);
57
+ setPermissionState("denied");
58
+ }
59
+ }, []);
60
+ (0, import_react.useEffect)(() => {
61
+ checkPermissions();
62
+ }, [checkPermissions]);
63
+ const requestPermissions = (0, import_react.useCallback)(async () => {
64
+ setPermissionState("requesting");
65
+ try {
66
+ const { status: fgStatus } = await Location.requestForegroundPermissionsAsync();
67
+ if (fgStatus !== "granted") {
68
+ setPermissionState("denied");
69
+ return;
70
+ }
71
+ const { status: bgStatus } = await Location.requestBackgroundPermissionsAsync();
72
+ if (bgStatus === "granted") {
73
+ setPermissionState("granted_background");
74
+ } else {
75
+ setPermissionState("granted_foreground");
76
+ }
77
+ } catch (error) {
78
+ console.error("[use-geo-sync] Failed to request location permissions:", error);
79
+ setPermissionState("denied");
80
+ }
81
+ }, []);
82
+ return {
83
+ permissionState,
84
+ requestPermissions,
85
+ checkPermissions
86
+ };
87
+ }
88
+
89
+ // src/useGeoSync.ts
90
+ var import_react2 = require("react");
91
+ var Location2 = __toESM(require("expo-location"));
92
+
93
+ // src/backgroundTask.ts
94
+ var TaskManager = __toESM(require("expo-task-manager"));
95
+ var BACKGROUND_LOCATION_TASK = "BACKGROUND_LOCATION_TASK";
96
+ var locationQueue = [];
97
+ var activeConfig = { batchSize: 20 };
98
+ var setTaskConfig = (config) => {
99
+ activeConfig = { ...activeConfig, ...config };
100
+ };
101
+ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }) => {
102
+ if (error) {
103
+ console.error("[use-geo-sync] Background task error:", error.message);
104
+ return;
105
+ }
106
+ if (data) {
107
+ const { locations } = data;
108
+ const newPoints = locations.map((loc) => ({
109
+ id: `${loc.timestamp}-${Math.random().toString(36).substr(2, 9)}`,
110
+ latitude: loc.coords.latitude,
111
+ longitude: loc.coords.longitude,
112
+ timestamp: loc.timestamp,
113
+ accuracy: loc.coords.accuracy,
114
+ speed: loc.coords.speed
115
+ }));
116
+ locationQueue.push(...newPoints);
117
+ console.log(`[use-geo-sync] \u{1F4CD} Caught ${newPoints.length} points. Queue size: ${locationQueue.length}`);
118
+ const batchLimit = activeConfig.batchSize || 20;
119
+ if (activeConfig.syncEndpoint && locationQueue.length >= batchLimit) {
120
+ console.log(`[use-geo-sync] \u{1F680} Queue hit limit (${batchLimit}). Syncing to backend...`);
121
+ try {
122
+ const response = await fetch(activeConfig.syncEndpoint, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ locations: locationQueue })
126
+ });
127
+ if (response.ok) {
128
+ console.log("[use-geo-sync] \u2705 Sync successful! Clearing queue.");
129
+ locationQueue = [];
130
+ } else {
131
+ console.warn(`[use-geo-sync] \u26A0\uFE0F Sync failed with status: ${response.status}. Keeping data in queue.`);
132
+ }
133
+ } catch (networkError) {
134
+ console.warn("[use-geo-sync] \u{1F50C} Network offline. Points saved in queue for next sync.");
135
+ }
136
+ }
137
+ }
138
+ });
139
+
140
+ // src/useGeoSync.ts
141
+ function useGeoSync(config) {
142
+ const { permissionState, requestPermissions } = useGeoPermissions();
143
+ const [isTracking, setIsTracking] = (0, import_react2.useState)(false);
144
+ const [queueLength, setQueueLength] = (0, import_react2.useState)(locationQueue.length);
145
+ (0, import_react2.useEffect)(() => {
146
+ if (config) {
147
+ setTaskConfig(config);
148
+ }
149
+ }, [config]);
150
+ const startTracking = (0, import_react2.useCallback)(async () => {
151
+ if (permissionState !== "granted_background" && permissionState !== "granted_foreground") {
152
+ console.warn("[use-geo-sync] Cannot start tracking. Permissions not granted.");
153
+ return;
154
+ }
155
+ try {
156
+ await Location2.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
157
+ // Balanced accuracy saves battery compared to Highest
158
+ accuracy: Location2.Accuracy.Balanced,
159
+ timeInterval: config?.syncIntervalMs || 1e4,
160
+ // Ping every 10 seconds
161
+ distanceInterval: 10,
162
+ // Or ping every 10 meters
163
+ showsBackgroundLocationIndicator: true,
164
+ foregroundService: {
165
+ notificationTitle: "Location Sync Active",
166
+ notificationBody: "Tracking your route in the background."
167
+ }
168
+ });
169
+ setIsTracking(true);
170
+ } catch (error) {
171
+ console.error("[use-geo-sync] Failed to start background tracking:", error);
172
+ }
173
+ }, [permissionState, config]);
174
+ const stopTracking = (0, import_react2.useCallback)(async () => {
175
+ try {
176
+ const hasTask = await Location2.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
177
+ if (hasTask) {
178
+ await Location2.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
179
+ }
180
+ setIsTracking(false);
181
+ } catch (error) {
182
+ console.error("[use-geo-sync] Failed to stop tracking:", error);
183
+ }
184
+ }, []);
185
+ return {
186
+ permissionState,
187
+ requestPermissions,
188
+ startTracking,
189
+ stopTracking,
190
+ isTracking,
191
+ queueLength
192
+ };
193
+ }
194
+ // Annotate the CommonJS export names for ESM import in node:
195
+ 0 && (module.exports = {
196
+ BACKGROUND_LOCATION_TASK,
197
+ useGeoPermissions,
198
+ useGeoSync
199
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,160 @@
1
+ // src/useGeoPermissions.ts
2
+ import { useState, useCallback, useEffect } from "react";
3
+ import * as Location from "expo-location";
4
+ function useGeoPermissions() {
5
+ const [permissionState, setPermissionState] = useState("idle");
6
+ const checkPermissions = useCallback(async () => {
7
+ try {
8
+ const { status: fgStatus } = await Location.getForegroundPermissionsAsync();
9
+ if (fgStatus === "granted") {
10
+ const { status: bgStatus } = await Location.getBackgroundPermissionsAsync();
11
+ setPermissionState(bgStatus === "granted" ? "granted_background" : "granted_foreground");
12
+ } else if (fgStatus === "denied") {
13
+ setPermissionState("denied");
14
+ } else {
15
+ setPermissionState("idle");
16
+ }
17
+ } catch (error) {
18
+ console.error("[use-geo-sync] Error checking permissions:", error);
19
+ setPermissionState("denied");
20
+ }
21
+ }, []);
22
+ useEffect(() => {
23
+ checkPermissions();
24
+ }, [checkPermissions]);
25
+ const requestPermissions = useCallback(async () => {
26
+ setPermissionState("requesting");
27
+ try {
28
+ const { status: fgStatus } = await Location.requestForegroundPermissionsAsync();
29
+ if (fgStatus !== "granted") {
30
+ setPermissionState("denied");
31
+ return;
32
+ }
33
+ const { status: bgStatus } = await Location.requestBackgroundPermissionsAsync();
34
+ if (bgStatus === "granted") {
35
+ setPermissionState("granted_background");
36
+ } else {
37
+ setPermissionState("granted_foreground");
38
+ }
39
+ } catch (error) {
40
+ console.error("[use-geo-sync] Failed to request location permissions:", error);
41
+ setPermissionState("denied");
42
+ }
43
+ }, []);
44
+ return {
45
+ permissionState,
46
+ requestPermissions,
47
+ checkPermissions
48
+ };
49
+ }
50
+
51
+ // src/useGeoSync.ts
52
+ import { useState as useState2, useCallback as useCallback2, useEffect as useEffect2 } from "react";
53
+ import * as Location2 from "expo-location";
54
+
55
+ // src/backgroundTask.ts
56
+ import * as TaskManager from "expo-task-manager";
57
+ var BACKGROUND_LOCATION_TASK = "BACKGROUND_LOCATION_TASK";
58
+ var locationQueue = [];
59
+ var activeConfig = { batchSize: 20 };
60
+ var setTaskConfig = (config) => {
61
+ activeConfig = { ...activeConfig, ...config };
62
+ };
63
+ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }) => {
64
+ if (error) {
65
+ console.error("[use-geo-sync] Background task error:", error.message);
66
+ return;
67
+ }
68
+ if (data) {
69
+ const { locations } = data;
70
+ const newPoints = locations.map((loc) => ({
71
+ id: `${loc.timestamp}-${Math.random().toString(36).substr(2, 9)}`,
72
+ latitude: loc.coords.latitude,
73
+ longitude: loc.coords.longitude,
74
+ timestamp: loc.timestamp,
75
+ accuracy: loc.coords.accuracy,
76
+ speed: loc.coords.speed
77
+ }));
78
+ locationQueue.push(...newPoints);
79
+ console.log(`[use-geo-sync] \u{1F4CD} Caught ${newPoints.length} points. Queue size: ${locationQueue.length}`);
80
+ const batchLimit = activeConfig.batchSize || 20;
81
+ if (activeConfig.syncEndpoint && locationQueue.length >= batchLimit) {
82
+ console.log(`[use-geo-sync] \u{1F680} Queue hit limit (${batchLimit}). Syncing to backend...`);
83
+ try {
84
+ const response = await fetch(activeConfig.syncEndpoint, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({ locations: locationQueue })
88
+ });
89
+ if (response.ok) {
90
+ console.log("[use-geo-sync] \u2705 Sync successful! Clearing queue.");
91
+ locationQueue = [];
92
+ } else {
93
+ console.warn(`[use-geo-sync] \u26A0\uFE0F Sync failed with status: ${response.status}. Keeping data in queue.`);
94
+ }
95
+ } catch (networkError) {
96
+ console.warn("[use-geo-sync] \u{1F50C} Network offline. Points saved in queue for next sync.");
97
+ }
98
+ }
99
+ }
100
+ });
101
+
102
+ // src/useGeoSync.ts
103
+ function useGeoSync(config) {
104
+ const { permissionState, requestPermissions } = useGeoPermissions();
105
+ const [isTracking, setIsTracking] = useState2(false);
106
+ const [queueLength, setQueueLength] = useState2(locationQueue.length);
107
+ useEffect2(() => {
108
+ if (config) {
109
+ setTaskConfig(config);
110
+ }
111
+ }, [config]);
112
+ const startTracking = useCallback2(async () => {
113
+ if (permissionState !== "granted_background" && permissionState !== "granted_foreground") {
114
+ console.warn("[use-geo-sync] Cannot start tracking. Permissions not granted.");
115
+ return;
116
+ }
117
+ try {
118
+ await Location2.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
119
+ // Balanced accuracy saves battery compared to Highest
120
+ accuracy: Location2.Accuracy.Balanced,
121
+ timeInterval: config?.syncIntervalMs || 1e4,
122
+ // Ping every 10 seconds
123
+ distanceInterval: 10,
124
+ // Or ping every 10 meters
125
+ showsBackgroundLocationIndicator: true,
126
+ foregroundService: {
127
+ notificationTitle: "Location Sync Active",
128
+ notificationBody: "Tracking your route in the background."
129
+ }
130
+ });
131
+ setIsTracking(true);
132
+ } catch (error) {
133
+ console.error("[use-geo-sync] Failed to start background tracking:", error);
134
+ }
135
+ }, [permissionState, config]);
136
+ const stopTracking = useCallback2(async () => {
137
+ try {
138
+ const hasTask = await Location2.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
139
+ if (hasTask) {
140
+ await Location2.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
141
+ }
142
+ setIsTracking(false);
143
+ } catch (error) {
144
+ console.error("[use-geo-sync] Failed to stop tracking:", error);
145
+ }
146
+ }, []);
147
+ return {
148
+ permissionState,
149
+ requestPermissions,
150
+ startTracking,
151
+ stopTracking,
152
+ isTracking,
153
+ queueLength
154
+ };
155
+ }
156
+ export {
157
+ BACKGROUND_LOCATION_TASK,
158
+ useGeoPermissions,
159
+ useGeoSync
160
+ };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "use-geo-sync",
3
+ "version": "1.0.0",
4
+ "description": "A React Native/Expo headless toolkit for background location sync and queuing.",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsup",
10
+ "dev": "tsup --watch",
11
+ "clean": "rm -rf dist"
12
+ },
13
+ "keywords": [
14
+ "react-native",
15
+ "expo",
16
+ "location",
17
+ "background-sync",
18
+ "geolocation"
19
+ ],
20
+ "author": "Your Name",
21
+ "license": "MIT",
22
+ "peerDependencies": {
23
+ "expo-location": "^16.0.0",
24
+ "expo-task-manager": "^11.0.0",
25
+ "react": ">=18.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "^19.2.14",
29
+ "expo-location": "^55.1.6",
30
+ "expo-task-manager": "^55.0.12",
31
+ "react": "^19.2.4",
32
+ "tsup": "^8.5.1",
33
+ "typescript": "^6.0.2"
34
+ }
35
+ }
package/readme.md ADDED
@@ -0,0 +1,86 @@
1
+ Markdown# use-geo-sync 🌍
2
+
3
+ A plug-and-play, offline-first background location tracking and syncing toolkit for React Native and Expo.
4
+
5
+ Handling iOS/Android background permissions, app state, and network drops is a massive headache. `use-geo-sync` abstracts all that boilerplate into a single, strictly-typed React Hook with a built-in offline queue.
6
+
7
+ ## ✨ Features
8
+ - **📱 Bulletproof Permissions:** Automatically handles the strict Foreground -> Background permission cascade on both iOS and Android.
9
+ - **🔋 Battery Optimized:** Uses balanced accuracy and headless task management to save battery life.
10
+ - **📡 Offline-First Queuing:** If the network drops, coordinates are safely queued locally. When the connection returns, the queue is automatically batched and synced to your backend.
11
+ - **🛠 Zero-Config Architecture:** No complex Redux/Zustand setups required. It just works.
12
+
13
+ ## 📦 Installation
14
+
15
+ Install the package via npm or yarn:
16
+
17
+ ```bash
18
+ npm install use-geo-sync
19
+ ```
20
+ Important: This package relies on Expo modules under the hood. You must install the peer dependencies:
21
+
22
+ ```
23
+ npx expo install expo-location expo-task-manager
24
+ ```
25
+
26
+ # 🚀 Quick Start
27
+ Here is a complete example of how to implement background tracking in your app:
28
+ ```
29
+ import { useEffect } from 'react';
30
+ import { View, Text, Button, StyleSheet } from 'react-native';
31
+ import { useGeoSync } from 'use-geo-sync';
32
+
33
+ export default function TrackingScreen() {
34
+ const {
35
+ permissionState,
36
+ requestPermissions,
37
+ startTracking,
38
+ stopTracking,
39
+ isTracking,
40
+ queueLength
41
+ } = useGeoSync({
42
+ syncEndpoint: '[https://your-api.com/api/location-sync](https://your-api.com/api/location-sync)', // Your backend URL
43
+ batchSize: 20, // Auto-syncs to your backend after collecting 20 points
44
+ syncIntervalMs: 10000 // Pings GPS every 10 seconds
45
+ });
46
+
47
+ return (
48
+ <View style={styles.container}>
49
+ <Text style={styles.title}>📍 Fleet Tracker</Text>
50
+
51
+ <Text>Status: {isTracking ? 'Active 🟢' : 'Stopped 🔴'}</Text>
52
+ <Text>Permissions: {permissionState}</Text>
53
+ <Text>Unsynced Points in Queue: {queueLength}</Text>
54
+
55
+ {permissionState !== 'granted_background' && (
56
+ <Button title="Grant Background Permissions" onPress={requestPermissions} />
57
+ )}
58
+
59
+ <Button
60
+ title={isTracking ? "Stop Tracking" : "Start Tracking"}
61
+ onPress={isTracking ? stopTracking : startTracking}
62
+ color={isTracking ? "red" : "green"}
63
+ />
64
+ </View>
65
+ );
66
+ }
67
+
68
+ const styles = StyleSheet.create({
69
+ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 10 },
70
+ title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 }
71
+ });
72
+ ```
73
+
74
+ ## ⚙️ Configuration Options (SyncConfig)
75
+
76
+ When calling `useGeoSync(config)`, you can pass the following options:
77
+
78
+ | Property | Type | Default | Description |
79
+ | :--- | :--- | :--- | :--- |
80
+ | `syncEndpoint` | `string` | `undefined` | The backend API URL where the queued location batch will be sent via POST request. |
81
+ | `batchSize` | `number` | `20` | How many location points to collect before triggering a sync to the backend. |
82
+ | `syncIntervalMs` | `number` | `10000` | Time in milliseconds between GPS pings. |
83
+
84
+ ## 📜 License
85
+
86
+ MIT
@@ -0,0 +1,71 @@
1
+ // src/backgroundTask.ts
2
+
3
+ import * as TaskManager from 'expo-task-manager';
4
+ import * as Location from 'expo-location';
5
+ import { GeoPoint, SyncConfig } from './types';
6
+
7
+ export const BACKGROUND_LOCATION_TASK = 'BACKGROUND_LOCATION_TASK';
8
+
9
+ // In-memory queue
10
+ export let locationQueue: GeoPoint[] = [];
11
+
12
+ // We need a way to store the developer's config globally so the background task can read it
13
+ let activeConfig: SyncConfig = { batchSize: 20 };
14
+
15
+ // Function to update the config from the React Hook
16
+ export const setTaskConfig = (config: SyncConfig) => {
17
+ activeConfig = { ...activeConfig, ...config };
18
+ };
19
+
20
+ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }) => {
21
+ if (error) {
22
+ console.error('[use-geo-sync] Background task error:', error.message);
23
+ return;
24
+ }
25
+
26
+ if (data) {
27
+ const { locations } = data as { locations: Location.LocationObject[] };
28
+
29
+ // 1. Transform Expo's object
30
+ const newPoints: GeoPoint[] = locations.map(loc => ({
31
+ id: `${loc.timestamp}-${Math.random().toString(36).substr(2, 9)}`,
32
+ latitude: loc.coords.latitude,
33
+ longitude: loc.coords.longitude,
34
+ timestamp: loc.timestamp,
35
+ accuracy: loc.coords.accuracy,
36
+ speed: loc.coords.speed
37
+ }));
38
+
39
+ // 2. Add to Queue
40
+ locationQueue.push(...newPoints);
41
+ console.log(`[use-geo-sync] 📍 Caught ${newPoints.length} points. Queue size: ${locationQueue.length}`);
42
+
43
+ // 3. THE MAGIC: Check if we need to sync!
44
+ const batchLimit = activeConfig.batchSize || 20;
45
+
46
+ if (activeConfig.syncEndpoint && locationQueue.length >= batchLimit) {
47
+ console.log(`[use-geo-sync] 🚀 Queue hit limit (${batchLimit}). Syncing to backend...`);
48
+
49
+ try {
50
+ // Send the data to the developer's backend
51
+ const response = await fetch(activeConfig.syncEndpoint, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ locations: locationQueue }),
55
+ });
56
+
57
+ if (response.ok) {
58
+ console.log('[use-geo-sync] ✅ Sync successful! Clearing queue.');
59
+ // 4. Clear the queue ONLY if the network request succeeds
60
+ locationQueue = [];
61
+ } else {
62
+ console.warn(`[use-geo-sync] ⚠️ Sync failed with status: ${response.status}. Keeping data in queue.`);
63
+ }
64
+ } catch (networkError) {
65
+ // If they are driving through a tunnel and lose 5G, the fetch will fail.
66
+ // We catch the error and do NOT clear the queue. It will try again next time.
67
+ console.warn('[use-geo-sync] 🔌 Network offline. Points saved in queue for next sync.');
68
+ }
69
+ }
70
+ }
71
+ });
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // src/index.ts
2
+
3
+ export * from './types';
4
+ export { useGeoPermissions } from './useGeoPermissions';
5
+ export { useGeoSync } from './useGeoSync';
6
+ export { BACKGROUND_LOCATION_TASK } from './backgroundTask';
package/src/types.ts ADDED
@@ -0,0 +1,35 @@
1
+ // src/types.ts
2
+
3
+ import * as Location from 'expo-location';
4
+
5
+ export interface GeoPoint {
6
+ id: string; // Unique ID for the queue
7
+ latitude: number;
8
+ longitude: number;
9
+ timestamp: number;
10
+ accuracy: number | null;
11
+ speed: number | null;
12
+ }
13
+
14
+ export interface SyncConfig {
15
+ batchSize?: number; // How many points to hold before syncing (default: 20)
16
+ syncEndpoint?: string; // The backend URL to POST data to
17
+ syncIntervalMs?: number; // Time-based sync fallback
18
+ retryLimit?: number; // How many times to retry a failed sync
19
+ }
20
+
21
+ export type PermissionState =
22
+ | 'idle'
23
+ | 'requesting'
24
+ | 'granted_foreground'
25
+ | 'granted_background'
26
+ | 'denied';
27
+
28
+ export interface UseGeoSyncResult {
29
+ permissionState: PermissionState;
30
+ requestPermissions: () => Promise<void>;
31
+ startTracking: () => Promise<void>;
32
+ stopTracking: () => Promise<void>;
33
+ isTracking: boolean;
34
+ queueLength: number; // Number of unsynced points
35
+ }
@@ -0,0 +1,67 @@
1
+ // src/useGeoPermissions.ts
2
+
3
+ import { useState, useCallback, useEffect } from 'react';
4
+ import * as Location from 'expo-location';
5
+ import { PermissionState } from './types';
6
+
7
+ export function useGeoPermissions() {
8
+ const [permissionState, setPermissionState] = useState<PermissionState>('idle');
9
+
10
+ // Check the current status silently without triggering a system popup
11
+ const checkPermissions = useCallback(async () => {
12
+ try {
13
+ const { status: fgStatus } = await Location.getForegroundPermissionsAsync();
14
+
15
+ if (fgStatus === 'granted') {
16
+ const { status: bgStatus } = await Location.getBackgroundPermissionsAsync();
17
+ setPermissionState(bgStatus === 'granted' ? 'granted_background' : 'granted_foreground');
18
+ } else if (fgStatus === 'denied') {
19
+ setPermissionState('denied');
20
+ } else {
21
+ setPermissionState('idle');
22
+ }
23
+ } catch (error) {
24
+ console.error("[use-geo-sync] Error checking permissions:", error);
25
+ setPermissionState('denied');
26
+ }
27
+ }, []);
28
+
29
+ // Run the silent check on mount
30
+ useEffect(() => {
31
+ checkPermissions();
32
+ }, [checkPermissions]);
33
+
34
+ // The actual request function the developer will call
35
+ const requestPermissions = useCallback(async () => {
36
+ setPermissionState('requesting');
37
+
38
+ try {
39
+ // 1. MUST request foreground first
40
+ const { status: fgStatus } = await Location.requestForegroundPermissionsAsync();
41
+
42
+ if (fgStatus !== 'granted') {
43
+ setPermissionState('denied');
44
+ return; // Stop here if they say no
45
+ }
46
+
47
+ // 2. Now safe to request background
48
+ const { status: bgStatus } = await Location.requestBackgroundPermissionsAsync();
49
+
50
+ if (bgStatus === 'granted') {
51
+ setPermissionState('granted_background');
52
+ } else {
53
+ // They allowed app to use location while open, but not in background
54
+ setPermissionState('granted_foreground');
55
+ }
56
+ } catch (error) {
57
+ console.error('[use-geo-sync] Failed to request location permissions:', error);
58
+ setPermissionState('denied');
59
+ }
60
+ }, []);
61
+
62
+ return {
63
+ permissionState,
64
+ requestPermissions,
65
+ checkPermissions
66
+ };
67
+ }
@@ -0,0 +1,66 @@
1
+ // src/useGeoSync.ts
2
+
3
+ import { useState, useCallback, useEffect } from 'react';
4
+ import * as Location from 'expo-location';
5
+ import { BACKGROUND_LOCATION_TASK, locationQueue } from './backgroundTask';
6
+ import { SyncConfig, UseGeoSyncResult } from './types';
7
+ import { useGeoPermissions } from './useGeoPermissions';
8
+ import { setTaskConfig } from './backgroundTask'; // <-- add this to your imports
9
+ export function useGeoSync(config?: SyncConfig): UseGeoSyncResult {
10
+ // Bring in our custom permission hook
11
+ const { permissionState, requestPermissions } = useGeoPermissions();
12
+
13
+ const [isTracking, setIsTracking] = useState(false);
14
+ const [queueLength, setQueueLength] = useState(locationQueue.length);
15
+
16
+ useEffect(() => {
17
+ if (config) {
18
+ setTaskConfig(config);
19
+ }
20
+ }, [config]);
21
+
22
+ const startTracking = useCallback(async () => {
23
+ if (permissionState !== 'granted_background' && permissionState !== 'granted_foreground') {
24
+ console.warn('[use-geo-sync] Cannot start tracking. Permissions not granted.');
25
+ return;
26
+ }
27
+
28
+ try {
29
+ await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
30
+ // Balanced accuracy saves battery compared to Highest
31
+ accuracy: Location.Accuracy.Balanced,
32
+ timeInterval: config?.syncIntervalMs || 10000, // Ping every 10 seconds
33
+ distanceInterval: 10, // Or ping every 10 meters
34
+ showsBackgroundLocationIndicator: true,
35
+ foregroundService: {
36
+ notificationTitle: "Location Sync Active",
37
+ notificationBody: "Tracking your route in the background.",
38
+ }
39
+ });
40
+ setIsTracking(true);
41
+ } catch (error) {
42
+ console.error('[use-geo-sync] Failed to start background tracking:', error);
43
+ }
44
+ }, [permissionState, config]);
45
+
46
+ const stopTracking = useCallback(async () => {
47
+ try {
48
+ const hasTask = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
49
+ if (hasTask) {
50
+ await Location.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
51
+ }
52
+ setIsTracking(false);
53
+ } catch (error) {
54
+ console.error('[use-geo-sync] Failed to stop tracking:', error);
55
+ }
56
+ }, []);
57
+
58
+ return {
59
+ permissionState,
60
+ requestPermissions,
61
+ startTracking,
62
+ stopTracking,
63
+ isTracking,
64
+ queueLength
65
+ };
66
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "declaration": true,
11
+ "isolatedModules": true,
12
+ "ignoreDeprecations": "6.0"
13
+ },
14
+ "include": ["src"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['cjs', 'esm'],
6
+ dts: true, // Generates types
7
+ clean: true, // Cleans dist folder before build
8
+ external: ['react', 'expo-location', 'expo-task-manager'], // Don't bundle these!
9
+ });