Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd70da8c71 |
10 changed files with 331 additions and 49 deletions
|
|
@ -12,8 +12,8 @@ android {
|
||||||
applicationId 'com.danvics.calorieai'
|
applicationId 'com.danvics.calorieai'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 10
|
versionCode 11
|
||||||
versionName '1.10'
|
versionName '1.11'
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'org.jetbrains.kotlin.android'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.danvics.calorieai"
|
namespace = "com.danvics.calorieai"
|
||||||
|
|
@ -7,8 +8,8 @@ android {
|
||||||
applicationId "com.danvics.calorieai"
|
applicationId "com.danvics.calorieai"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10
|
versionCode 11
|
||||||
versionName "1.10"
|
versionName "1.11"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '21'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
|
@ -35,6 +43,8 @@ dependencies {
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
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')
|
implementation project(':capacitor-android')
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,17 @@
|
||||||
|
|
||||||
</activity>
|
</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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|
@ -41,4 +52,7 @@
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<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="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>
|
</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;
|
package com.danvics.calorieai;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
import com.getcapacitor.BridgeActivity;
|
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 {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
classpath 'com.google.gms:google-services:4.4.4'
|
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
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
ext {
|
ext {
|
||||||
minSdkVersion = 24
|
minSdkVersion = 26
|
||||||
compileSdkVersion = 36
|
compileSdkVersion = 36
|
||||||
targetSdkVersion = 36
|
targetSdkVersion = 36
|
||||||
androidxActivityVersion = '1.11.0'
|
androidxActivityVersion = '1.11.0'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
import AppShell from './AppShell.svelte';
|
import AppShell from './AppShell.svelte';
|
||||||
import DashboardPage from './DashboardPage.svelte';
|
import DashboardPage from './DashboardPage.svelte';
|
||||||
import AnalyticsPage from './AnalyticsPage.svelte';
|
import AnalyticsPage from './AnalyticsPage.svelte';
|
||||||
|
|
@ -10,6 +11,8 @@
|
||||||
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
|
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
|
||||||
import { calculateCaloriePlan, planSummary } from './calorie.js';
|
import { calculateCaloriePlan, planSummary } from './calorie.js';
|
||||||
|
|
||||||
|
const HealthConnect = registerPlugin('HealthConnect');
|
||||||
|
|
||||||
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
|
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
|
||||||
let username = 'admin';
|
let username = 'admin';
|
||||||
let password = '';
|
let password = '';
|
||||||
|
|
@ -36,6 +39,7 @@
|
||||||
let modelStatus = '';
|
let modelStatus = '';
|
||||||
let modelError = false;
|
let modelError = false;
|
||||||
let modelSearching = false;
|
let modelSearching = false;
|
||||||
|
let modelActionId = '';
|
||||||
let settings = { ...defaultSettings };
|
let settings = { ...defaultSettings };
|
||||||
let currentPassword = '';
|
let currentPassword = '';
|
||||||
let newPassword = '';
|
let newPassword = '';
|
||||||
|
|
@ -44,6 +48,9 @@
|
||||||
let accountVerified = false;
|
let accountVerified = false;
|
||||||
let accountStatus = '';
|
let accountStatus = '';
|
||||||
let accountError = false;
|
let accountError = false;
|
||||||
|
let healthConnectStatus = 'Health Connect not synced.';
|
||||||
|
let healthConnectError = false;
|
||||||
|
let healthConnectSyncing = false;
|
||||||
let passwordStatus = '';
|
let passwordStatus = '';
|
||||||
let passwordError = false;
|
let passwordError = false;
|
||||||
let resetPasswordValue = '';
|
let resetPasswordValue = '';
|
||||||
|
|
@ -278,6 +285,30 @@
|
||||||
} catch {}
|
} 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) {
|
function applyPlanConfig(config) {
|
||||||
plans = config.plans || [];
|
plans = config.plans || [];
|
||||||
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
||||||
|
|
@ -375,43 +406,63 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addModel(model) {
|
async function addModel(model) {
|
||||||
const response = await fetch('/api/models/add', {
|
modelActionId = model.id;
|
||||||
method: 'POST',
|
modelStatus = `Adding ${model.name || model.id}...`;
|
||||||
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;
|
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) {
|
async function removeModel(modelId) {
|
||||||
const response = await fetch('/api/models/remove', {
|
modelActionId = modelId;
|
||||||
method: 'POST',
|
modelStatus = 'Removing model...';
|
||||||
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;
|
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() {
|
async function changePassword() {
|
||||||
|
|
@ -1054,10 +1105,14 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
||||||
{modelStatus}
|
{modelStatus}
|
||||||
{modelError}
|
{modelError}
|
||||||
{modelSearching}
|
{modelSearching}
|
||||||
|
{modelActionId}
|
||||||
bind:accountEmail
|
bind:accountEmail
|
||||||
{accountVerified}
|
{accountVerified}
|
||||||
{accountStatus}
|
{accountStatus}
|
||||||
{accountError}
|
{accountError}
|
||||||
|
{healthConnectStatus}
|
||||||
|
{healthConnectError}
|
||||||
|
{healthConnectSyncing}
|
||||||
{passwordStatus}
|
{passwordStatus}
|
||||||
{passwordError}
|
{passwordError}
|
||||||
{resetPasswordValue}
|
{resetPasswordValue}
|
||||||
|
|
@ -1071,6 +1126,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
|
||||||
{removeModel}
|
{removeModel}
|
||||||
{saveAccountEmail}
|
{saveAccountEmail}
|
||||||
{sendVerificationEmail}
|
{sendVerificationEmail}
|
||||||
|
{syncHealthConnect}
|
||||||
{changePassword}
|
{changePassword}
|
||||||
{resetPassword}
|
{resetPassword}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@
|
||||||
export let modelStatus;
|
export let modelStatus;
|
||||||
export let modelError;
|
export let modelError;
|
||||||
export let modelSearching;
|
export let modelSearching;
|
||||||
|
export let modelActionId;
|
||||||
export let accountEmail;
|
export let accountEmail;
|
||||||
export let accountVerified;
|
export let accountVerified;
|
||||||
export let accountStatus;
|
export let accountStatus;
|
||||||
export let accountError;
|
export let accountError;
|
||||||
|
export let healthConnectStatus;
|
||||||
|
export let healthConnectError;
|
||||||
|
export let healthConnectSyncing;
|
||||||
export let passwordStatus;
|
export let passwordStatus;
|
||||||
export let passwordError;
|
export let passwordError;
|
||||||
export let resetPasswordValue;
|
export let resetPasswordValue;
|
||||||
|
|
@ -25,6 +29,7 @@
|
||||||
export let removeModel;
|
export let removeModel;
|
||||||
export let saveAccountEmail;
|
export let saveAccountEmail;
|
||||||
export let sendVerificationEmail;
|
export let sendVerificationEmail;
|
||||||
|
export let syncHealthConnect;
|
||||||
export let changePassword;
|
export let changePassword;
|
||||||
export let resetPassword;
|
export let resetPassword;
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -65,7 +70,7 @@
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
<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="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">
|
<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>
|
</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>
|
<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="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">
|
<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>
|
</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">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 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>
|
||||||
|
<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>
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,17 @@ const png = Buffer.from(
|
||||||
);
|
);
|
||||||
|
|
||||||
async function mockModels(page) {
|
async function mockModels(page) {
|
||||||
|
let models = [
|
||||||
|
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
|
||||||
|
];
|
||||||
|
|
||||||
await page.route('**/api/models', async (route) => {
|
await page.route('**/api/models', async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
defaultModel: 'claude-haiku-4.5',
|
defaultModel: 'claude-haiku-4.5',
|
||||||
models: [
|
models,
|
||||||
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -35,19 +37,28 @@ async function mockModels(page) {
|
||||||
|
|
||||||
await page.route('**/api/models/add', async (route) => {
|
await page.route('**/api/models/add', async (route) => {
|
||||||
const body = route.request().postDataJSON();
|
const body = route.request().postDataJSON();
|
||||||
|
models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }];
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
defaultModel: 'claude-haiku-4.5',
|
defaultModel: 'claude-haiku-4.5',
|
||||||
models: [
|
models,
|
||||||
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
|
|
||||||
{ id: body.id, name: body.name || body.id },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
async function mockPlans(page) {
|
||||||
|
|
@ -254,11 +265,14 @@ test.describe('authenticated app', () => {
|
||||||
await page.locator('#modelSearch').fill('gemini');
|
await page.locator('#modelSearch').fill('gemini');
|
||||||
await page.getByRole('button', { name: 'Search' }).click();
|
await page.getByRole('button', { name: 'Search' }).click();
|
||||||
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
|
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('#visionModel').selectOption('gemini-2.5-flash');
|
||||||
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
|
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
|
||||||
await page.getByRole('button', { name: 'Save selected models' }).click();
|
await page.getByRole('button', { name: 'Save selected models' }).click();
|
||||||
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
|
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.getByRole('button', { name: 'Log meal' }).click();
|
||||||
await page.locator('#mealDate').fill('2026-05-18');
|
await page.locator('#mealDate').fill('2026-05-18');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue