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.
- package/dist/index.d.mts +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +199 -0
- package/dist/index.mjs +160 -0
- package/package.json +35 -0
- package/readme.md +86 -0
- package/src/backgroundTask.ts +71 -0
- package/src/index.ts +6 -0
- package/src/types.ts +35 -0
- package/src/useGeoPermissions.ts +67 -0
- package/src/useGeoSync.ts +66 -0
- package/tsconfig.json +16 -0
- package/tsup.config.ts +9 -0
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
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
|
+
});
|