Add Capacitor Health Connect sync
This commit is contained in:
parent
38d9f50c31
commit
dd70da8c71
10 changed files with 331 additions and 49 deletions
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId 'com.danvics.calorieai'
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 10
|
||||
versionName '1.10'
|
||||
versionCode 11
|
||||
versionName '1.11'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
|
||||
android {
|
||||
namespace = "com.danvics.calorieai"
|
||||
|
|
@ -7,8 +8,8 @@ android {
|
|||
applicationId "com.danvics.calorieai"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10
|
||||
versionName "1.10"
|
||||
versionCode 11
|
||||
versionName "1.11"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
@ -22,6 +23,13 @@ android {
|
|||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '21'
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
|
@ -35,6 +43,8 @@ dependencies {
|
|||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@
|
|||
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
|
@ -41,4 +52,7 @@
|
|||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="androidx.health.permission.READ_STEPS" />
|
||||
<uses-permission android:name="androidx.health.permission.READ_ACTIVE_CALORIES_BURNED" />
|
||||
<uses-permission android:name="androidx.health.permission.READ_EXERCISE" />
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
package com.danvics.calorieai
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
||||
import androidx.health.connect.client.records.ExerciseSessionRecord
|
||||
import androidx.health.connect.client.records.StepsRecord
|
||||
import androidx.health.connect.client.records.metadata.DataOrigin
|
||||
import androidx.health.connect.client.request.AggregateRequest
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import androidx.health.connect.client.PermissionController
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@CapacitorPlugin(name = "HealthConnect")
|
||||
class HealthConnectPlugin : Plugin() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val permissions = setOf(
|
||||
HealthPermission.getReadPermission(StepsRecord::class),
|
||||
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
|
||||
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
||||
)
|
||||
private var permissionLauncher: ActivityResultLauncher<Set<String>>? = 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
import AppShell from './AppShell.svelte';
|
||||
import DashboardPage from './DashboardPage.svelte';
|
||||
import AnalyticsPage from './AnalyticsPage.svelte';
|
||||
|
|
@ -10,6 +11,8 @@
|
|||
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
|
||||
import { calculateCaloriePlan, planSummary } from './calorie.js';
|
||||
|
||||
const HealthConnect = registerPlugin('HealthConnect');
|
||||
|
||||
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
|
||||
let username = 'admin';
|
||||
let password = '';
|
||||
|
|
@ -36,6 +39,7 @@
|
|||
let modelStatus = '';
|
||||
let modelError = false;
|
||||
let modelSearching = false;
|
||||
let modelActionId = '';
|
||||
let settings = { ...defaultSettings };
|
||||
let currentPassword = '';
|
||||
let newPassword = '';
|
||||
|
|
@ -44,6 +48,9 @@
|
|||
let accountVerified = false;
|
||||
let accountStatus = '';
|
||||
let accountError = false;
|
||||
let healthConnectStatus = 'Health Connect not synced.';
|
||||
let healthConnectError = false;
|
||||
let healthConnectSyncing = false;
|
||||
let passwordStatus = '';
|
||||
let passwordError = false;
|
||||
let resetPasswordValue = '';
|
||||
|
|
@ -278,6 +285,30 @@
|
|||
} catch {}
|
||||
}
|
||||
|
||||
async function syncHealthConnect() {
|
||||
healthConnectStatus = 'Syncing Health Connect...';
|
||||
healthConnectError = false;
|
||||
healthConnectSyncing = true;
|
||||
try {
|
||||
const nativeResult = await HealthConnect.sync({ days: 14 });
|
||||
const synced = Array.isArray(nativeResult.activities) ? nativeResult.activities : [];
|
||||
const response = await fetch('/api/activities/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'health_connect', activities: synced }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({ error: 'Health Connect server sync failed.' }));
|
||||
if (!response.ok) throw new Error(data.error || 'Health Connect server sync failed.');
|
||||
activities = data.activities || [];
|
||||
healthConnectStatus = `Synced ${synced.length} Health Connect record${synced.length === 1 ? '' : 's'}.`;
|
||||
} catch (error) {
|
||||
healthConnectStatus = error?.message || 'Health Connect sync failed.';
|
||||
healthConnectError = true;
|
||||
} finally {
|
||||
healthConnectSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyPlanConfig(config) {
|
||||
plans = config.plans || [];
|
||||
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
||||
|
|
@ -375,43 +406,63 @@
|
|||
}
|
||||
|
||||
async function addModel(model) {
|
||||
const response = await fetch('/api/models/add', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(model),
|
||||
});
|
||||
if (!response.ok) {
|
||||
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
|
||||
modelError = true;
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
models = data.models?.length ? data.models : models;
|
||||
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
|
||||
settings.taskModel = model.id;
|
||||
await saveSettings();
|
||||
modelStatus = `Added ${model.name || model.id}.`;
|
||||
modelActionId = model.id;
|
||||
modelStatus = `Adding ${model.name || model.id}...`;
|
||||
modelError = false;
|
||||
try {
|
||||
const response = await fetch('/api/models/add', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(model),
|
||||
});
|
||||
if (!response.ok) {
|
||||
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
|
||||
modelError = true;
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
models = data.models?.length ? data.models : models;
|
||||
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
|
||||
settings.taskModel = model.id;
|
||||
await saveSettings();
|
||||
modelStatus = `Added ${model.name || model.id}.`;
|
||||
modelError = false;
|
||||
} catch (error) {
|
||||
modelStatus = error?.message || 'Failed to add model.';
|
||||
modelError = true;
|
||||
} finally {
|
||||
modelActionId = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeModel(modelId) {
|
||||
const response = await fetch('/api/models/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: modelId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
|
||||
modelError = true;
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
models = data.models?.length ? data.models : fallbackModels;
|
||||
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
|
||||
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
|
||||
await saveSettings();
|
||||
modelStatus = 'Model removed.';
|
||||
modelActionId = modelId;
|
||||
modelStatus = 'Removing model...';
|
||||
modelError = false;
|
||||
try {
|
||||
const response = await fetch('/api/models/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: modelId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
|
||||
modelError = true;
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
models = data.models?.length ? data.models : fallbackModels;
|
||||
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
|
||||
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
|
||||
await saveSettings();
|
||||
modelStatus = 'Model removed.';
|
||||
modelError = false;
|
||||
} catch (error) {
|
||||
modelStatus = error?.message || 'Failed to remove model.';
|
||||
modelError = true;
|
||||
} finally {
|
||||
modelActionId = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
|
|
@ -1054,10 +1105,14 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
|||
{modelStatus}
|
||||
{modelError}
|
||||
{modelSearching}
|
||||
{modelActionId}
|
||||
bind:accountEmail
|
||||
{accountVerified}
|
||||
{accountStatus}
|
||||
{accountError}
|
||||
{healthConnectStatus}
|
||||
{healthConnectError}
|
||||
{healthConnectSyncing}
|
||||
{passwordStatus}
|
||||
{passwordError}
|
||||
{resetPasswordValue}
|
||||
|
|
@ -1071,6 +1126,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
|||
{removeModel}
|
||||
{saveAccountEmail}
|
||||
{sendVerificationEmail}
|
||||
{syncHealthConnect}
|
||||
{changePassword}
|
||||
{resetPassword}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@
|
|||
export let modelStatus;
|
||||
export let modelError;
|
||||
export let modelSearching;
|
||||
export let modelActionId;
|
||||
export let accountEmail;
|
||||
export let accountVerified;
|
||||
export let accountStatus;
|
||||
export let accountError;
|
||||
export let healthConnectStatus;
|
||||
export let healthConnectError;
|
||||
export let healthConnectSyncing;
|
||||
export let passwordStatus;
|
||||
export let passwordError;
|
||||
export let resetPasswordValue;
|
||||
|
|
@ -25,6 +29,7 @@
|
|||
export let removeModel;
|
||||
export let saveAccountEmail;
|
||||
export let sendVerificationEmail;
|
||||
export let syncHealthConnect;
|
||||
export let changePassword;
|
||||
export let resetPassword;
|
||||
</script>
|
||||
|
|
@ -65,7 +70,7 @@
|
|||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div>
|
||||
<div class="max-h-40 overflow-y-auto pr-1">
|
||||
{#each models as model}<div class="mb-2 flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-md bg-red-50 px-2 py-1 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => removeModel(model.id)}>Remove</button></div>{/each}
|
||||
{#each models as model}<div class="mb-2 grid gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-100 disabled:opacity-60" type="button" on:click={() => removeModel(model.id)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Removing...' : 'Remove'}</button></div>{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,7 +78,7 @@
|
|||
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
|
||||
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
|
||||
{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}
|
||||
{#each discoveredModels as model}<div class="mb-2 grid gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white hover:bg-blue-700 disabled:opacity-60 sm:order-first" type="button" on:click={() => addModel(model)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Adding...' : 'Add'}</button></div>{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -90,6 +95,10 @@
|
|||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">2. Health Connect</strong>In Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">3. Calorie AI Android</strong>Open Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-t border-slate-100 px-4 py-4 sm:flex-row sm:items-center">
|
||||
<button class="btn-primary sm:w-52" type="button" on:click={syncHealthConnect} disabled={healthConnectSyncing}>{healthConnectSyncing ? 'Syncing...' : 'Sync Health Connect'}</button>
|
||||
<p id="healthConnectStatus" class:text-red-600={healthConnectError} class="text-sm font-semibold text-green-700">{healthConnectStatus}</p>
|
||||
</div>
|
||||
<div class="border-t border-slate-100 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue