>? = null
+ private var pendingSyncCall: PluginCall? = null
+
+ override fun load() {
+ permissionLauncher = activity.registerForActivityResult(
+ PermissionController.createRequestPermissionResultContract()
+ ) { granted ->
+ val call = pendingSyncCall ?: return@registerForActivityResult
+ pendingSyncCall = null
+ if (granted.containsAll(permissions)) readAndResolve(call) else call.reject("Health Connect permission was not granted.")
+ }
+ }
+
+ @PluginMethod
+ fun sync(call: PluginCall) {
+ when (HealthConnectClient.getSdkStatus(context)) {
+ HealthConnectClient.SDK_AVAILABLE -> ensurePermissionsThenSync(call)
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> call.reject("Install or update Health Connect, then try again.")
+ else -> call.reject("Health Connect is not available on this device.")
+ }
+ }
+
+ private fun ensurePermissionsThenSync(call: PluginCall) {
+ scope.launch {
+ runCatching {
+ val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
+ if (granted.containsAll(permissions)) {
+ readActivities(call)
+ } else {
+ pendingSyncCall = call
+ permissionLauncher?.launch(permissions) ?: call.reject("Health Connect permissions are not ready.")
+ }
+ }.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
+ }
+ }
+
+ private fun readAndResolve(call: PluginCall) {
+ scope.launch {
+ runCatching { readActivities(call) }
+ .onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
+ }
+ }
+
+ private suspend fun readActivities(call: PluginCall) {
+ val days = (call.getInt("days") ?: 14).coerceIn(1, 90).toLong()
+ val client = HealthConnectClient.getOrCreate(context)
+ val zone = ZoneId.systemDefault()
+ val today = LocalDate.now(zone)
+ val syncedAt = Instant.now().toString()
+ val activities = JSArray()
+
+ for (offset in 0 until days) {
+ val date = today.minusDays(offset)
+ val start = date.atStartOfDay(zone).toInstant()
+ val end = date.plusDays(1).atStartOfDay(zone).toInstant()
+ val aggregate = client.aggregate(
+ AggregateRequest(
+ metrics = setOf(StepsRecord.COUNT_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end),
+ )
+ )
+ val steps = aggregate[StepsRecord.COUNT_TOTAL] ?: 0L
+ val calories = aggregate[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
+ if (steps > 0L || calories > 0.0) {
+ activities.put(
+ activityJson(
+ id = "health-connect-day-$date",
+ sourceName = "Health Connect",
+ type = "Daily activity",
+ title = "Daily activity",
+ startAt = start.toString(),
+ endAt = end.toString(),
+ calories = calories,
+ steps = steps,
+ syncedAt = syncedAt,
+ )
+ )
+ }
+ }
+
+ val rangeStart = today.minusDays(days - 1).atStartOfDay(zone).toInstant()
+ val rangeEnd = today.plusDays(1).atStartOfDay(zone).toInstant()
+ client.readRecords(
+ ReadRecordsRequest(
+ recordType = ExerciseSessionRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
+ pageSize = 200,
+ )
+ ).records.forEach { session ->
+ activities.put(
+ activityJson(
+ id = "health-connect-session-${session.metadata.id}",
+ sourceName = sourceName(session.metadata.dataOrigin),
+ type = "Exercise ${session.exerciseType}",
+ title = session.title ?: "Exercise session",
+ startAt = session.startTime.toString(),
+ endAt = session.endTime.toString(),
+ syncedAt = syncedAt,
+ )
+ )
+ }
+
+ call.resolve(JSObject().put("activities", activities))
+ }
+
+ private fun activityJson(
+ id: String,
+ sourceName: String,
+ type: String,
+ title: String,
+ startAt: String,
+ endAt: String,
+ calories: Double = 0.0,
+ steps: Long = 0L,
+ syncedAt: String,
+ ): JSObject = JSObject()
+ .put("id", id)
+ .put("source", "health_connect")
+ .put("sourceName", sourceName)
+ .put("type", type)
+ .put("title", title)
+ .put("startAt", startAt)
+ .put("endAt", endAt)
+ .put("calories", calories)
+ .put("steps", steps)
+ .put("distanceMeters", 0)
+ .put("syncedAt", syncedAt)
+
+ private fun sourceName(origin: DataOrigin): String = origin.packageName.ifBlank { "Health Connect" }
+
+ override fun handleOnDestroy() {
+ super.handleOnDestroy()
+ scope.cancel()
+ }
+}
diff --git a/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java b/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java
index 7042e89..d7c5e10 100644
--- a/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java
+++ b/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java
@@ -1,5 +1,12 @@
package com.danvics.calorieai;
+import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
-public class MainActivity extends BridgeActivity {}
+public class MainActivity extends BridgeActivity {
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ registerPlugin(HealthConnectPlugin.class);
+ super.onCreate(savedInstanceState);
+ }
+}
diff --git a/web/android/build.gradle b/web/android/build.gradle
index f8f0e43..29dba9e 100644
--- a/web/android/build.gradle
+++ b/web/android/build.gradle
@@ -9,6 +9,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
diff --git a/web/android/variables.gradle b/web/android/variables.gradle
index ee4ba41..a0aa104 100644
--- a/web/android/variables.gradle
+++ b/web/android/variables.gradle
@@ -1,5 +1,5 @@
ext {
- minSdkVersion = 24
+ minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
@@ -13,4 +13,4 @@ ext {
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
-}
\ No newline at end of file
+}
diff --git a/web/src/App.svelte b/web/src/App.svelte
index ff2e430..741e35a 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -1,5 +1,6 @@
@@ -65,7 +70,7 @@
Saved models
- {#each models as model}
{model.name} {model.id} removeModel(model.id)}>Remove
{/each}
+ {#each models as model}
{model.name} {model.id} removeModel(model.id)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Removing...' : 'Remove'}
{/each}
@@ -73,7 +78,7 @@
Search provider models
event.key === 'Enter' && searchModels()}>{modelSearching ? 'Searching...' : 'Search'}
- {#each discoveredModels as model}
addModel(model)}>+ {model.name} {model.id}
{/each}
+ {#each discoveredModels as model}
{model.name} {model.id} addModel(model)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Adding...' : 'Add'}
{/each}
@@ -90,6 +95,10 @@
2. Health Connect In Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.
3. Calorie AI Android Open Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.
+
+
{healthConnectSyncing ? 'Syncing...' : 'Sync Health Connect'}
+
{healthConnectStatus}
+
Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.
diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js
index 6f2306b..c8f09d5 100644
--- a/web/tests/app.spec.js
+++ b/web/tests/app.spec.js
@@ -6,15 +6,17 @@ const png = Buffer.from(
);
async function mockModels(page) {
+ let models = [
+ { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
+ ];
+
await page.route('**/api/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
defaultModel: 'claude-haiku-4.5',
- models: [
- { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
- ],
+ models,
}),
});
});
@@ -35,19 +37,28 @@ async function mockModels(page) {
await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON();
+ models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
defaultModel: 'claude-haiku-4.5',
- models: [
- { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
- { id: body.id, name: body.name || body.id },
- ],
+ models,
}),
});
});
+
+ await page.route('**/api/models/remove', async (route) => {
+ const body = route.request().postDataJSON();
+ models = models.filter(model => model.id !== body.id);
+ if (!models.length) models = [{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }];
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ok: true, defaultModel: models[0].id, models }),
+ });
+ });
}
async function mockPlans(page) {
@@ -254,11 +265,14 @@ test.describe('authenticated app', () => {
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
- await page.getByRole('button', { name: '+' }).last().click();
+ await page.getByRole('button', { name: 'Add' }).last().click();
+ await expect(page.locator('#modelStatus')).toContainText('Added Gemini 2.5 Flash.');
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
+ await page.getByRole('button', { name: 'Remove' }).last().click();
+ await expect(page.locator('#modelStatus')).toContainText('Model removed.');
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#mealDate').fill('2026-05-18');