react-native-step-tracker-pro 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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/android/build.gradle +119 -0
  4. package/android/consumer-rules.pro +13 -0
  5. package/android/gradle.properties +3 -0
  6. package/android/src/main/AndroidManifest.xml +78 -0
  7. package/android/src/main/java/com/steptrackerpro/StepTrackerProModule.kt +598 -0
  8. package/android/src/main/java/com/steptrackerpro/StepTrackerProPackage.kt +26 -0
  9. package/android/src/main/java/com/steptrackerpro/core/DateKeys.kt +90 -0
  10. package/android/src/main/java/com/steptrackerpro/core/GoalTracker.kt +66 -0
  11. package/android/src/main/java/com/steptrackerpro/core/MetricsCalculator.kt +48 -0
  12. package/android/src/main/java/com/steptrackerpro/core/Models.kt +73 -0
  13. package/android/src/main/java/com/steptrackerpro/core/StepCounterEngine.kt +338 -0
  14. package/android/src/main/java/com/steptrackerpro/core/StepStateStore.kt +137 -0
  15. package/android/src/main/java/com/steptrackerpro/core/StepTrackerConfig.kt +184 -0
  16. package/android/src/main/java/com/steptrackerpro/core/StepTrackerCore.kt +280 -0
  17. package/android/src/main/java/com/steptrackerpro/db/Entities.kt +67 -0
  18. package/android/src/main/java/com/steptrackerpro/db/StepDao.kt +118 -0
  19. package/android/src/main/java/com/steptrackerpro/db/StepDatabase.kt +60 -0
  20. package/android/src/main/java/com/steptrackerpro/db/StepRepository.kt +146 -0
  21. package/android/src/main/java/com/steptrackerpro/health/HealthConnectManager.kt +198 -0
  22. package/android/src/main/java/com/steptrackerpro/health/HealthPermissionActivity.kt +99 -0
  23. package/android/src/main/java/com/steptrackerpro/service/BootReceiver.kt +54 -0
  24. package/android/src/main/java/com/steptrackerpro/service/NotificationActionReceiver.kt +17 -0
  25. package/android/src/main/java/com/steptrackerpro/service/NotificationFactory.kt +170 -0
  26. package/android/src/main/java/com/steptrackerpro/service/ServiceCommands.kt +42 -0
  27. package/android/src/main/java/com/steptrackerpro/service/StepTrackerService.kt +409 -0
  28. package/android/src/main/java/com/steptrackerpro/sync/SyncScheduler.kt +102 -0
  29. package/android/src/main/java/com/steptrackerpro/sync/SyncWorkers.kt +175 -0
  30. package/android/src/main/java/com/steptrackerpro/util/BatteryOptimizationHelper.kt +94 -0
  31. package/android/src/main/java/com/steptrackerpro/util/Bridge.kt +149 -0
  32. package/android/src/main/java/com/steptrackerpro/util/PermissionHelper.kt +92 -0
  33. package/android/src/main/java/com/steptrackerpro/util/StepEventBus.kt +53 -0
  34. package/android/src/main/res/drawable/stp_ic_steps.xml +9 -0
  35. package/android/src/main/res/values/strings.xml +9 -0
  36. package/android/src/main/res/values/styles.xml +8 -0
  37. package/android/src/newarch/java/com/steptrackerpro/StepTrackerProSpec.kt +10 -0
  38. package/android/src/oldarch/java/com/steptrackerpro/StepTrackerProSpec.kt +60 -0
  39. package/docs/API.md +329 -0
  40. package/docs/ARCHITECTURE.md +203 -0
  41. package/docs/INSTALLATION.md +175 -0
  42. package/docs/PLAY_STORE_COMPLIANCE.md +122 -0
  43. package/docs/TESTING.md +312 -0
  44. package/docs/TROUBLESHOOTING.md +96 -0
  45. package/lib/commonjs/NativeStepTrackerPro.js +14 -0
  46. package/lib/commonjs/NativeStepTrackerPro.js.map +1 -0
  47. package/lib/commonjs/StepTracker.js +290 -0
  48. package/lib/commonjs/StepTracker.js.map +1 -0
  49. package/lib/commonjs/constants.js +30 -0
  50. package/lib/commonjs/constants.js.map +1 -0
  51. package/lib/commonjs/errors.js +24 -0
  52. package/lib/commonjs/errors.js.map +1 -0
  53. package/lib/commonjs/hooks/index.js +20 -0
  54. package/lib/commonjs/hooks/index.js.map +1 -0
  55. package/lib/commonjs/hooks/useStepStats.js +51 -0
  56. package/lib/commonjs/hooks/useStepStats.js.map +1 -0
  57. package/lib/commonjs/hooks/useStepTracker.js +134 -0
  58. package/lib/commonjs/hooks/useStepTracker.js.map +1 -0
  59. package/lib/commonjs/index.js +64 -0
  60. package/lib/commonjs/index.js.map +1 -0
  61. package/lib/commonjs/package.json +1 -0
  62. package/lib/commonjs/types.js +2 -0
  63. package/lib/commonjs/types.js.map +1 -0
  64. package/lib/module/NativeStepTrackerPro.js +12 -0
  65. package/lib/module/NativeStepTrackerPro.js.map +1 -0
  66. package/lib/module/StepTracker.js +284 -0
  67. package/lib/module/StepTracker.js.map +1 -0
  68. package/lib/module/constants.js +26 -0
  69. package/lib/module/constants.js.map +1 -0
  70. package/lib/module/errors.js +18 -0
  71. package/lib/module/errors.js.map +1 -0
  72. package/lib/module/hooks/index.js +5 -0
  73. package/lib/module/hooks/index.js.map +1 -0
  74. package/lib/module/hooks/useStepStats.js +46 -0
  75. package/lib/module/hooks/useStepStats.js.map +1 -0
  76. package/lib/module/hooks/useStepTracker.js +129 -0
  77. package/lib/module/hooks/useStepTracker.js.map +1 -0
  78. package/lib/module/index.js +7 -0
  79. package/lib/module/index.js.map +1 -0
  80. package/lib/module/package.json +1 -0
  81. package/lib/module/types.js +2 -0
  82. package/lib/module/types.js.map +1 -0
  83. package/lib/typescript/NativeStepTrackerPro.d.ts +50 -0
  84. package/lib/typescript/NativeStepTrackerPro.d.ts.map +1 -0
  85. package/lib/typescript/StepTracker.d.ts +76 -0
  86. package/lib/typescript/StepTracker.d.ts.map +1 -0
  87. package/lib/typescript/constants.d.ts +10 -0
  88. package/lib/typescript/constants.d.ts.map +1 -0
  89. package/lib/typescript/errors.d.ts +8 -0
  90. package/lib/typescript/errors.d.ts.map +1 -0
  91. package/lib/typescript/hooks/index.d.ts +5 -0
  92. package/lib/typescript/hooks/index.d.ts.map +1 -0
  93. package/lib/typescript/hooks/useStepStats.d.ts +11 -0
  94. package/lib/typescript/hooks/useStepStats.d.ts.map +1 -0
  95. package/lib/typescript/hooks/useStepTracker.d.ts +26 -0
  96. package/lib/typescript/hooks/useStepTracker.d.ts.map +1 -0
  97. package/lib/typescript/index.d.ts +7 -0
  98. package/lib/typescript/index.d.ts.map +1 -0
  99. package/lib/typescript/types.d.ts +183 -0
  100. package/lib/typescript/types.d.ts.map +1 -0
  101. package/package.json +79 -0
  102. package/src/NativeStepTrackerPro.ts +66 -0
  103. package/src/StepTracker.ts +410 -0
  104. package/src/constants.ts +43 -0
  105. package/src/errors.ts +31 -0
  106. package/src/hooks/index.ts +4 -0
  107. package/src/hooks/useStepStats.ts +55 -0
  108. package/src/hooks/useStepTracker.ts +167 -0
  109. package/src/index.ts +27 -0
  110. package/src/types.ts +203 -0
@@ -0,0 +1,598 @@
1
+ package com.steptrackerpro
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import com.facebook.react.bridge.LifecycleEventListener
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.ReactMethod
9
+ import com.facebook.react.bridge.ReadableMap
10
+ import com.facebook.react.modules.core.DeviceEventManagerModule
11
+ import com.facebook.react.modules.core.PermissionAwareActivity
12
+ import com.facebook.react.modules.core.PermissionListener
13
+ import com.steptrackerpro.core.DateKeys
14
+ import com.steptrackerpro.core.StepTrackerConfig
15
+ import com.steptrackerpro.core.StepTrackerCore
16
+ import com.steptrackerpro.core.TrackingState
17
+ import com.steptrackerpro.health.HealthConnectManager
18
+ import com.steptrackerpro.health.HealthPermissionActivity
19
+ import com.steptrackerpro.service.ServiceCommands
20
+ import com.steptrackerpro.sync.SyncScheduler
21
+ import com.steptrackerpro.util.Bridge
22
+ import com.steptrackerpro.util.optBoolean
23
+ import com.steptrackerpro.util.optDouble
24
+ import com.steptrackerpro.util.optInt
25
+ import com.steptrackerpro.util.optLong
26
+ import com.steptrackerpro.util.optString
27
+ import com.steptrackerpro.util.BatteryOptimizationHelper
28
+ import com.steptrackerpro.util.PermissionHelper
29
+ import com.steptrackerpro.util.StepEventBus
30
+ import kotlinx.coroutines.CoroutineScope
31
+ import kotlinx.coroutines.Dispatchers
32
+ import kotlinx.coroutines.SupervisorJob
33
+ import kotlinx.coroutines.cancel
34
+ import kotlinx.coroutines.launch
35
+ import java.time.Instant
36
+ import java.util.concurrent.atomic.AtomicBoolean
37
+
38
+ /**
39
+ * The JS-facing surface. Deliberately thin: it validates arguments, delegates
40
+ * to [StepTrackerCore] or the service, and converts results for the bridge.
41
+ * Nothing here is required for counting to continue.
42
+ */
43
+ class StepTrackerProModule(private val reactContext: ReactApplicationContext) :
44
+ StepTrackerProSpec(reactContext), LifecycleEventListener {
45
+
46
+ private val core = StepTrackerCore.get(reactContext)
47
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
48
+ private var listenerCount = 0
49
+
50
+ private val subscriber = StepEventBus.Subscriber { name, payload -> emit(name, payload) }
51
+
52
+ init {
53
+ StepEventBus.subscribe(subscriber)
54
+ reactContext.addLifecycleEventListener(this)
55
+ }
56
+
57
+ override fun getName(): String = NAME
58
+
59
+ override fun invalidate() {
60
+ StepEventBus.unsubscribe(subscriber)
61
+ reactContext.removeLifecycleEventListener(this)
62
+ scope.cancel()
63
+ super.invalidate()
64
+ }
65
+
66
+ override fun onHostResume() = Unit
67
+ override fun onHostPause() = Unit
68
+ override fun onHostDestroy() = Unit
69
+
70
+ // ---- events ----------------------------------------------------------
71
+
72
+ @ReactMethod
73
+ override fun addListener(eventName: String) {
74
+ listenerCount++
75
+ }
76
+
77
+ @ReactMethod
78
+ override fun removeListeners(count: Double) {
79
+ listenerCount = (listenerCount - count.toInt()).coerceAtLeast(0)
80
+ }
81
+
82
+ private fun emit(name: String, payload: Map<String, Any?>) {
83
+ if (!reactContext.hasActiveReactInstance()) return
84
+ runCatching {
85
+ reactContext
86
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
87
+ .emit("$EVENT_PREFIX$name", Bridge.map(payload))
88
+ }
89
+ }
90
+
91
+ // ---- lifecycle -------------------------------------------------------
92
+
93
+ @ReactMethod
94
+ override fun initialize(config: ReadableMap, promise: Promise) {
95
+ runSafely(promise) {
96
+ val merged = mergeConfig(core.config(), config)
97
+ core.updateConfig(merged)
98
+ core.engine.reconcile()
99
+ if (core.state.trackingState == TrackingState.RUNNING) {
100
+ // The service may have been killed while JS was gone. This has
101
+ // to be START: every other command starts the service without
102
+ // registering a sensor listener, which would leave the tracker
103
+ // reporting itself as running while counting nothing.
104
+ ServiceCommands.start(reactContext)
105
+ }
106
+ promise.resolve(Bridge.snapshot(core.engine.snapshot()))
107
+ }
108
+ }
109
+
110
+ @ReactMethod
111
+ override fun updateConfig(config: ReadableMap, promise: Promise) {
112
+ runSafely(promise) {
113
+ val merged = mergeConfig(core.config(), config)
114
+ core.updateConfig(merged)
115
+ sendIfTracking(ServiceCommands.ACTION_CONFIG_CHANGED)
116
+ promise.resolve(Bridge.map(configToMap(merged)))
117
+ }
118
+ }
119
+
120
+ @ReactMethod
121
+ override fun getConfig(promise: Promise) {
122
+ runSafely(promise) { promise.resolve(Bridge.map(configToMap(core.config()))) }
123
+ }
124
+
125
+ @ReactMethod
126
+ override fun startTracking(promise: Promise) {
127
+ runSafely(promise) {
128
+ if (!PermissionHelper.canStartTracking(reactContext)) {
129
+ promise.reject(
130
+ "E_PERMISSION_DENIED",
131
+ "ACTIVITY_RECOGNITION must be granted before tracking can start"
132
+ )
133
+ return@runSafely
134
+ }
135
+ val capabilities = PermissionHelper.capabilities(reactContext)
136
+ if (capabilities["supported"] != true) {
137
+ promise.reject("E_NO_SENSOR", "No step sensor on this device")
138
+ return@runSafely
139
+ }
140
+ ServiceCommands.start(reactContext)
141
+ promise.resolve(Bridge.snapshot(core.engine.snapshot()))
142
+ }
143
+ }
144
+
145
+ @ReactMethod
146
+ override fun pauseTracking(promise: Promise) {
147
+ runSafely(promise) {
148
+ if (!core.state.shouldAutoStart) {
149
+ promise.reject("E_NOT_TRACKING", "Tracking is not running")
150
+ return@runSafely
151
+ }
152
+ ServiceCommands.send(reactContext, ServiceCommands.ACTION_PAUSE)
153
+ promise.resolve(Bridge.snapshot(core.engine.snapshot()))
154
+ }
155
+ }
156
+
157
+ @ReactMethod
158
+ override fun resumeTracking(promise: Promise) {
159
+ runSafely(promise) {
160
+ if (!core.state.shouldAutoStart) {
161
+ promise.reject("E_NOT_TRACKING", "Tracking is not running")
162
+ return@runSafely
163
+ }
164
+ ServiceCommands.send(reactContext, ServiceCommands.ACTION_RESUME)
165
+ promise.resolve(Bridge.snapshot(core.engine.snapshot()))
166
+ }
167
+ }
168
+
169
+ @ReactMethod
170
+ override fun stopTracking(promise: Promise) {
171
+ runSafely(promise) {
172
+ ServiceCommands.send(reactContext, ServiceCommands.ACTION_STOP)
173
+ SyncScheduler.cancelAll(reactContext)
174
+ promise.resolve(Bridge.snapshot(core.engine.snapshot()))
175
+ }
176
+ }
177
+
178
+ @ReactMethod
179
+ override fun getTrackingState(promise: Promise) {
180
+ runSafely(promise) {
181
+ val snapshot = core.engine.snapshot()
182
+ promise.resolve(
183
+ Bridge.map(
184
+ mapOf(
185
+ "state" to snapshot.state.jsValue,
186
+ "source" to snapshot.source.jsValue
187
+ )
188
+ )
189
+ )
190
+ }
191
+ }
192
+
193
+ @ReactMethod
194
+ override fun isTracking(promise: Promise) {
195
+ runSafely(promise) {
196
+ promise.resolve(core.state.trackingState == TrackingState.RUNNING)
197
+ }
198
+ }
199
+
200
+ // ---- reads -----------------------------------------------------------
201
+
202
+ @ReactMethod
203
+ override fun getTodaySteps(promise: Promise) {
204
+ runSafely(promise) {
205
+ core.engine.reconcile()
206
+ promise.resolve(Bridge.snapshot(core.engine.snapshot()))
207
+ }
208
+ }
209
+
210
+ @ReactMethod
211
+ override fun getStepsForDate(date: String, promise: Promise) {
212
+ launchSafely(promise) { promise.resolve(Bridge.day(core.dayTotals(date))) }
213
+ }
214
+
215
+ @ReactMethod
216
+ override fun getYesterdaySteps(promise: Promise) {
217
+ launchSafely(promise) {
218
+ promise.resolve(Bridge.day(core.repository.getDay(DateKeys.yesterday())))
219
+ }
220
+ }
221
+
222
+ @ReactMethod
223
+ override fun getStatsForRange(startDate: String, endDate: String, promise: Promise) {
224
+ launchSafely(promise) {
225
+ promise.resolve(Bridge.stats(core.stats(startDate, endDate, null)))
226
+ }
227
+ }
228
+
229
+ @ReactMethod
230
+ override fun getWeeklyStats(options: ReadableMap, promise: Promise) {
231
+ launchSafely(promise) {
232
+ val (start, end) = window(options, 7) { offset -> DateKeys.calendarWeek(offset) }
233
+ promise.resolve(Bridge.stats(core.stats(start, end, core.config().weeklyGoal)))
234
+ }
235
+ }
236
+
237
+ @ReactMethod
238
+ override fun getMonthlyStats(options: ReadableMap, promise: Promise) {
239
+ launchSafely(promise) {
240
+ val (start, end) = window(options, 30) { offset -> DateKeys.calendarMonth(offset) }
241
+ promise.resolve(Bridge.stats(core.stats(start, end, core.config().monthlyGoal)))
242
+ }
243
+ }
244
+
245
+ @ReactMethod
246
+ override fun getYearlyStats(options: ReadableMap, promise: Promise) {
247
+ launchSafely(promise) {
248
+ val (start, end) = window(options, 365) { offset -> DateKeys.calendarYear(offset) }
249
+ promise.resolve(Bridge.stats(core.stats(start, end, null)))
250
+ }
251
+ }
252
+
253
+ @ReactMethod
254
+ override fun getHistory(startDate: String, endDate: String, promise: Promise) {
255
+ launchSafely(promise) {
256
+ val days = core.repository.getRange(startDate, endDate)
257
+ promise.resolve(Bridge.map(mapOf("records" to days.map { day ->
258
+ mapOf(
259
+ "date" to day.date,
260
+ "steps" to day.steps,
261
+ "distance" to day.distance,
262
+ "calories" to day.calories,
263
+ "synced" to day.synced,
264
+ "syncedRemote" to day.syncedRemote
265
+ )
266
+ })))
267
+ }
268
+ }
269
+
270
+ // ---- writes ----------------------------------------------------------
271
+
272
+ @ReactMethod
273
+ override fun resetToday(promise: Promise) {
274
+ launchSafely(promise) {
275
+ core.engine.resetToday()
276
+ core.goals.reset()
277
+ // Deliberate write: saveDay() refuses to lower a day, which would
278
+ // leave history and the live counter permanently disagreeing.
279
+ core.repository.overwriteDay(core.liveToday())
280
+ sendIfTracking(ServiceCommands.ACTION_REFRESH)
281
+ promise.resolve(true)
282
+ }
283
+ }
284
+
285
+ @ReactMethod
286
+ override fun clearHistory(promise: Promise) {
287
+ launchSafely(promise) {
288
+ core.repository.clear()
289
+ promise.resolve(true)
290
+ }
291
+ }
292
+
293
+ @ReactMethod
294
+ override fun pruneHistory(retentionDays: Double, promise: Promise) {
295
+ launchSafely(promise) {
296
+ promise.resolve(core.repository.prune(retentionDays.toInt()))
297
+ }
298
+ }
299
+
300
+ // ---- permissions -----------------------------------------------------
301
+
302
+ @ReactMethod
303
+ override fun checkPermissions(promise: Promise) {
304
+ runSafely(promise) { promise.resolve(Bridge.map(PermissionHelper.status(reactContext))) }
305
+ }
306
+
307
+ @ReactMethod
308
+ override fun requestPermissions(promise: Promise) {
309
+ val activity = currentActivity
310
+ if (activity !is PermissionAwareActivity) {
311
+ promise.reject("E_NO_ACTIVITY", "No foreground activity to attach the dialog to")
312
+ return
313
+ }
314
+ val required = PermissionHelper.required()
315
+ .filter { !PermissionHelper.isGranted(reactContext, it) }
316
+ if (required.isEmpty()) {
317
+ promise.resolve(Bridge.map(PermissionHelper.status(reactContext)))
318
+ return
319
+ }
320
+ val listener = PermissionListener { _, _, _ ->
321
+ promise.resolve(Bridge.map(PermissionHelper.status(reactContext)))
322
+ true
323
+ }
324
+ runCatching {
325
+ activity.requestPermissions(required.toTypedArray(), PERMISSION_REQUEST_CODE, listener)
326
+ }.onFailure { promise.reject("E_UNKNOWN", it.message, it) }
327
+ }
328
+
329
+ @ReactMethod
330
+ override fun getDeviceCapabilities(promise: Promise) {
331
+ runSafely(promise) {
332
+ promise.resolve(Bridge.map(PermissionHelper.capabilities(reactContext)))
333
+ }
334
+ }
335
+
336
+ @ReactMethod
337
+ override fun openAppSettings(promise: Promise) {
338
+ runSafely(promise) { promise.resolve(PermissionHelper.openAppSettings(reactContext)) }
339
+ }
340
+
341
+ // ---- battery ---------------------------------------------------------
342
+
343
+ @ReactMethod
344
+ override fun isBatteryOptimizationEnabled(promise: Promise) {
345
+ runSafely(promise) {
346
+ promise.resolve(BatteryOptimizationHelper.isOptimizationEnabled(reactContext))
347
+ }
348
+ }
349
+
350
+ @ReactMethod
351
+ override fun requestDisableBatteryOptimization(promise: Promise) {
352
+ runSafely(promise) {
353
+ val host = currentActivity ?: reactContext
354
+ promise.resolve(BatteryOptimizationHelper.requestExemption(host))
355
+ }
356
+ }
357
+
358
+ @ReactMethod
359
+ override fun openBatteryOptimizationSettings(promise: Promise) {
360
+ runSafely(promise) {
361
+ promise.resolve(BatteryOptimizationHelper.openSettings(currentActivity ?: reactContext))
362
+ }
363
+ }
364
+
365
+ @ReactMethod
366
+ override fun openManufacturerAutoStartSettings(promise: Promise) {
367
+ runSafely(promise) {
368
+ promise.resolve(
369
+ BatteryOptimizationHelper.openAutoStartSettings(currentActivity ?: reactContext)
370
+ )
371
+ }
372
+ }
373
+
374
+ // ---- health connect --------------------------------------------------
375
+
376
+ @ReactMethod
377
+ override fun getHealthConnectStatus(promise: Promise) {
378
+ launchSafely(promise) { promise.resolve(Bridge.map(core.healthConnect.status())) }
379
+ }
380
+
381
+ @ReactMethod
382
+ override fun requestHealthConnectPermissions(promise: Promise) {
383
+ val availability = core.healthConnect.availability()
384
+ if (availability != HealthConnectManager.Availability.AVAILABLE) {
385
+ promise.reject(
386
+ "E_HEALTH_CONNECT_UNAVAILABLE",
387
+ "Health Connect is not available on this device"
388
+ )
389
+ return
390
+ }
391
+ val activity: Activity? = currentActivity
392
+ if (activity == null) {
393
+ promise.reject("E_NO_ACTIVITY", "No foreground activity to launch the request from")
394
+ return
395
+ }
396
+ // The callback is registered only after the launch succeeds. Registering
397
+ // first left it armed when startActivity threw: the promise was rejected
398
+ // here and then resolved again by the next request's result, which
399
+ // React Native rejects as a second invocation of a one-shot callback.
400
+ val settled = AtomicBoolean(false)
401
+ HealthPermissionActivity.Callbacks.expect()
402
+ runCatching {
403
+ activity.startActivity(
404
+ Intent(activity, HealthPermissionActivity::class.java)
405
+ .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
406
+ )
407
+ }.onSuccess {
408
+ HealthPermissionActivity.Callbacks.await { _ ->
409
+ if (settled.compareAndSet(false, true)) {
410
+ scope.launch {
411
+ runCatching { promise.resolve(Bridge.map(core.healthConnect.status())) }
412
+ }
413
+ }
414
+ }
415
+ }.onFailure { error ->
416
+ if (settled.compareAndSet(false, true)) {
417
+ promise.reject("E_UNKNOWN", error.message, error)
418
+ }
419
+ }
420
+ }
421
+
422
+ @ReactMethod
423
+ override fun openHealthConnectSettings(promise: Promise) {
424
+ runSafely(promise) {
425
+ runCatching {
426
+ reactContext.startActivity(core.healthConnect.settingsIntent())
427
+ promise.resolve(true)
428
+ }.onFailure { promise.resolve(false) }
429
+ }
430
+ }
431
+
432
+ @ReactMethod
433
+ override fun readHealthConnectSteps(startIso: String, endIso: String, promise: Promise) {
434
+ launchSafely(promise) {
435
+ val days = core.healthConnect.readDailySteps(
436
+ Instant.parse(startIso),
437
+ Instant.parse(endIso)
438
+ )
439
+ promise.resolve(
440
+ Bridge.map(
441
+ mapOf(
442
+ "totalSteps" to days.sumOf { it.steps },
443
+ "records" to days.map { day ->
444
+ mapOf(
445
+ "date" to day.date,
446
+ "steps" to day.steps,
447
+ "distance" to day.distance,
448
+ "calories" to day.calories,
449
+ "synced" to true,
450
+ "syncedRemote" to false
451
+ )
452
+ }
453
+ )
454
+ )
455
+ )
456
+ }
457
+ }
458
+
459
+ @ReactMethod
460
+ override fun writeHealthConnectSteps(date: String, promise: Promise) {
461
+ launchSafely(promise) {
462
+ promise.resolve(core.healthConnect.writeDay(core.dayTotals(date)))
463
+ }
464
+ }
465
+
466
+ @ReactMethod
467
+ override fun syncWithHealthConnect(promise: Promise) {
468
+ launchSafely(promise) { promise.resolve(Bridge.map(core.syncHealthConnect())) }
469
+ }
470
+
471
+ // ---- sync ------------------------------------------------------------
472
+
473
+ @ReactMethod
474
+ override fun getPendingSyncCount(promise: Promise) {
475
+ launchSafely(promise) { promise.resolve(core.repository.countUnsynced()) }
476
+ }
477
+
478
+ @ReactMethod
479
+ override fun syncNow(promise: Promise) {
480
+ launchSafely(promise) {
481
+ val results = ArrayList<Map<String, Any?>>()
482
+ results.add(core.syncHealthConnect())
483
+ val config = core.config()
484
+ if (!config.remoteSyncUrl.isNullOrEmpty()) {
485
+ SyncScheduler.runNow(reactContext, config)
486
+ results.add(
487
+ mapOf(
488
+ "target" to "remote",
489
+ "syncedRecords" to 0,
490
+ "failedRecords" to 0,
491
+ "success" to true,
492
+ "error" to "Queued: listen for syncCompleted"
493
+ )
494
+ )
495
+ }
496
+ promise.resolve(Bridge.map(mapOf("results" to results)))
497
+ }
498
+ }
499
+
500
+ // ---- helpers ---------------------------------------------------------
501
+
502
+ /**
503
+ * Sends a service command only when tracking is actually on. Commands go out
504
+ * through startForegroundService(), which starts the service to deliver
505
+ * them, so sending one to a stopped tracker would leave a foreground service
506
+ * and its notification running for something the user turned off.
507
+ */
508
+ private fun sendIfTracking(action: String) {
509
+ if (core.state.shouldAutoStart) ServiceCommands.send(reactContext, action)
510
+ }
511
+
512
+ private inline fun runSafely(promise: Promise, block: () -> Unit) {
513
+ runCatching { block() }.onFailure { promise.reject("E_UNKNOWN", it.message, it) }
514
+ }
515
+
516
+ private fun launchSafely(promise: Promise, block: suspend () -> Unit) {
517
+ scope.launch {
518
+ runCatching { block() }.onFailure { promise.reject("E_UNKNOWN", it.message, it) }
519
+ }
520
+ }
521
+
522
+ private fun window(
523
+ options: ReadableMap?,
524
+ rollingDays: Int,
525
+ calendar: (Int) -> Pair<String, String>
526
+ ): Pair<String, String> {
527
+ val mode = options?.optString("mode", "calendar") ?: "calendar"
528
+ val offset = options?.optInt("offset", 0) ?: 0
529
+ return if (mode == "rolling") DateKeys.rolling(rollingDays) else calendar(offset)
530
+ }
531
+
532
+ private fun mergeConfig(current: StepTrackerConfig, patch: ReadableMap): StepTrackerConfig =
533
+ current.copy(
534
+ heightCm = patch.optDouble("height", current.heightCm),
535
+ weightKg = patch.optDouble("weight", current.weightKg),
536
+ strideLengthM = patch.optDouble("strideLength", current.strideLengthM),
537
+ sex = patch.optString("sex", current.sex) ?: current.sex,
538
+ dailyGoal = patch.optInt("dailyGoal", current.dailyGoal),
539
+ weeklyGoal = patch.optInt("weeklyGoal", current.weeklyGoal),
540
+ monthlyGoal = patch.optInt("monthlyGoal", current.monthlyGoal),
541
+ calorieCoefficient = patch.optDouble(
542
+ "calorieCoefficient", current.calorieCoefficient
543
+ ),
544
+ historyRetentionDays = patch.optInt(
545
+ "historyRetentionDays", current.historyRetentionDays
546
+ ),
547
+ notificationTitle = patch.optString("notificationTitle", current.notificationTitle),
548
+ notificationText = patch.optString("notificationText", current.notificationText),
549
+ notificationIcon = patch.optString("notificationIcon", current.notificationIcon),
550
+ notificationChannelName = patch.optString(
551
+ "notificationChannelName", current.notificationChannelName
552
+ ),
553
+ notificationActions = patch.optBoolean(
554
+ "notificationActions", current.notificationActions
555
+ ),
556
+ notificationThrottleMs = patch.optLong(
557
+ "notificationThrottleMs", current.notificationThrottleMs
558
+ ),
559
+ eventThrottleMs = patch.optLong("eventThrottleMs", current.eventThrottleMs),
560
+ persistEveryNSteps = patch.optInt("persistEveryNSteps", current.persistEveryNSteps),
561
+ healthConnectEnabled = patch.optBoolean(
562
+ "healthConnectEnabled", current.healthConnectEnabled
563
+ ),
564
+ healthConnectSyncIntervalMinutes = patch.optInt(
565
+ "healthConnectSyncIntervalMinutes", current.healthConnectSyncIntervalMinutes
566
+ ),
567
+ remoteSyncUrl = patch.optString("remoteSyncUrl", current.remoteSyncUrl),
568
+ remoteSyncHeaders = if (patch.hasKey("remoteSyncHeaders")) {
569
+ Bridge.stringMap(patch.getMap("remoteSyncHeaders"))
570
+ } else {
571
+ current.remoteSyncHeaders
572
+ },
573
+ autoStartOnBoot = patch.optBoolean("autoStartOnBoot", current.autoStartOnBoot)
574
+ )
575
+
576
+ private fun configToMap(config: StepTrackerConfig): Map<String, Any?> = mapOf(
577
+ "height" to config.heightCm,
578
+ "weight" to config.weightKg,
579
+ "strideLength" to config.strideLengthM,
580
+ "sex" to config.sex,
581
+ "dailyGoal" to config.dailyGoal,
582
+ "weeklyGoal" to config.weeklyGoal,
583
+ "monthlyGoal" to config.monthlyGoal,
584
+ "calorieCoefficient" to config.calorieCoefficient,
585
+ "historyRetentionDays" to config.historyRetentionDays,
586
+ "notificationActions" to config.notificationActions,
587
+ "healthConnectEnabled" to config.healthConnectEnabled,
588
+ "healthConnectSyncIntervalMinutes" to config.healthConnectSyncIntervalMinutes,
589
+ "remoteSyncUrl" to config.remoteSyncUrl,
590
+ "autoStartOnBoot" to config.autoStartOnBoot
591
+ )
592
+
593
+ companion object {
594
+ const val NAME = "StepTrackerPro"
595
+ const val EVENT_PREFIX = "StepTrackerPro:"
596
+ private const val PERMISSION_REQUEST_CODE = 7731
597
+ }
598
+ }
@@ -0,0 +1,26 @@
1
+ package com.steptrackerpro
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ class StepTrackerProPackage : BaseReactPackage() {
10
+
11
+ override fun getModule(name: String, context: ReactApplicationContext): NativeModule? =
12
+ if (name == StepTrackerProModule.NAME) StepTrackerProModule(context) else null
13
+
14
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
15
+ mapOf(
16
+ StepTrackerProModule.NAME to ReactModuleInfo(
17
+ StepTrackerProModule.NAME,
18
+ StepTrackerProModule.NAME,
19
+ false, // canOverrideExistingModule
20
+ false, // needsEagerInit
21
+ false, // isCxxModule
22
+ BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
23
+ )
24
+ )
25
+ }
26
+ }