Add Health Connect activity sync
Some checks failed
Android CI / build (push) Successful in 1m43s
Web CI / test (push) Successful in 58s
Android Release / release-apk (push) Failing after 22s

This commit is contained in:
Daniel 2026-05-21 01:31:15 +02:00
parent fcef0d48db
commit f73b58a158
15 changed files with 469 additions and 20 deletions

View file

@ -8,8 +8,8 @@ Native Android and Dockerized web calorie tracker with meal images, AI nutrition
- Analyze meals through OpenAI-compatible chat completions.
- Uses a vision model for food image calorie and portion estimates.
- Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
- Stores daily meal entries locally on Android or in browser local storage.
- Stores generated web weight-loss plans on the server in `web/data/plans.json`.
- Stores meal entries, settings, plans, and synced activity in PostgreSQL.
- Imports Garmin Connect and gym app activity through Android Health Connect.
- Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake.
- Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name.
@ -48,6 +48,16 @@ Recommended Obtainium setup:
The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate.
## Garmin And Gym Activity
Calorie AI imports external activity through Android Health Connect:
- Garmin: enable Health Connect sync in Garmin Connect, then open Calorie AI on Android and use `Settings -> Sync Health Connect`.
- Gym machines: use the machine's companion app or gym app to write workouts to Health Connect, then sync from Calorie AI.
- Imported data includes daily steps, active calories, and exercise sessions when Health Connect exposes them.
Direct Garmin Health API sync requires Garmin partner approval and API credentials, so the supported path is Health Connect first.
## Web Frontend
The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
@ -65,7 +75,9 @@ Then open:
http://127.0.0.1:8095
```
The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
The Docker web stack includes PostgreSQL. Runtime data lives under `web/data/postgres`, and legacy JSON/SQLite data in `web/data` is migrated on startup.
The Docker web server creates credentials on first boot if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
## AI Endpoint
@ -80,6 +92,4 @@ Example base URLs:
- `https://api.openai.com/v1`
- An emulator host-loopback URL ending in `:11434/v1` for a local OpenAI-compatible service
Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch.
The web frontend stores AI settings in browser local storage after web login, while generated plan versions are stored server-side. The web login is separate from the Android admin PIN.
The web and Android clients use the same authenticated server APIs for entries, settings, plans, models, and synced activity.

View file

@ -47,6 +47,7 @@ dependencies {
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
debugImplementation 'androidx.compose.ui:ui-tooling'

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<application
android:allowBackup="true"

View file

@ -8,12 +8,15 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.health.connect.client.contracts.HealthPermissionsRequestContract
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import com.danvics.calorieai.ai.ImagePayload
import com.danvics.calorieai.ai.NutritionParser
import com.danvics.calorieai.data.*
import com.danvics.calorieai.integrations.HealthConnectAvailability
import com.danvics.calorieai.integrations.HealthConnectSync
import com.danvics.calorieai.ui.CalorieAiApp
import com.danvics.calorieai.ui.CalorieTheme
import com.danvics.calorieai.ui.ConnectScreen
@ -41,6 +44,7 @@ private fun AppRoot(repo: ApiRepository) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val parser = remember { NutritionParser() }
val healthConnect = remember { HealthConnectSync(context) }
val cameraImageFile = remember { File(context.cacheDir, "cam_capture.jpg") }
val cameraImageUri: Uri = remember {
@ -60,8 +64,33 @@ private fun AppRoot(repo: ApiRepository) {
var planStatus by remember { mutableStateOf("") }
var planBusy by remember { mutableStateOf(false) }
var syncing by remember { mutableStateOf(false) }
var healthConnectStatus by remember { mutableStateOf("Health Connect not synced.") }
var mealSaveCount by remember { mutableStateOf(0) }
fun performHealthConnectSync() {
healthConnectStatus = "Syncing Health Connect..."
scope.launch(Dispatchers.IO) {
runCatching {
val activities = healthConnect.readActivities()
repo.syncActivities(activities)
}.onSuccess { activities ->
withContext(Dispatchers.Main) {
appState = appState.copy(activities = activities)
healthConnectStatus = "Synced ${activities.size} Health Connect record${if (activities.size == 1) "" else "s"}."
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) {
connected = false
appState = AppState()
} else {
healthConnectStatus = "Health Connect sync failed: ${e.message}"
}
}
}
}
}
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
scope.launch(Dispatchers.IO) {
@ -98,8 +127,33 @@ private fun AppRoot(repo: ApiRepository) {
}
}
val healthPermissionLauncher = rememberLauncherForActivityResult(HealthPermissionsRequestContract()) { granted ->
if (granted.containsAll(healthConnect.permissions)) performHealthConnectSync()
else healthConnectStatus = "Health Connect permission was not granted."
}
fun handleUnauth() { connected = false; appState = AppState() }
fun syncHealthConnect() {
when (healthConnect.availability()) {
HealthConnectAvailability.Unavailable -> {
healthConnectStatus = "Health Connect is not available on this device."
}
HealthConnectAvailability.UpdateRequired -> {
healthConnectStatus = "Install or update Health Connect, then try again."
}
HealthConnectAvailability.Available -> {
scope.launch(Dispatchers.IO) {
val granted = runCatching { healthConnect.hasPermissions() }.getOrDefault(false)
withContext(Dispatchers.Main) {
if (granted) performHealthConnectSync()
else healthPermissionLauncher.launch(healthConnect.permissions)
}
}
}
}
}
fun syncState() {
syncing = true
scope.launch(Dispatchers.IO) {
@ -257,7 +311,8 @@ private fun AppRoot(repo: ApiRepository) {
val recentMeals = appState.entries.takeLast(10)
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
.ifBlank { "none logged yet" }
val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals))
val recentActivity = recentActivitySummary(appState.activities, 10)
val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals, recentActivity))
val plan = Plan(
id = UUID.randomUUID().toString(),
title = "Plan ${appState.plans.plans.size + 1}",
@ -286,7 +341,8 @@ private fun AppRoot(repo: ApiRepository) {
val recentMeals = appState.entries.takeLast(15)
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
.ifBlank { "none logged yet" }
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals))
val recentActivity = recentActivitySummary(appState.activities, 15)
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals, recentActivity))
val version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2
val updated = Plan(
id = UUID.randomUUID().toString(),
@ -322,6 +378,8 @@ private fun AppRoot(repo: ApiRepository) {
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
healthConnectStatus = healthConnectStatus,
onHealthConnectSync = { syncHealthConnect() },
onDisconnect = {
repo.disconnect()
connected = false
@ -361,6 +419,12 @@ private fun buildTodayHistory(entries: List<MealEntry>, date: String): String {
return "$rows\nTotal so far: ${totals[0]} kcal, P${totals[1]}g C${totals[2]}g F${totals[3]}g"
}
private fun recentActivitySummary(activities: List<ActivityRecord>, limit: Int): String =
activities.take(limit).joinToString("; ") {
val steps = if (it.steps > 0) ", ${it.steps} steps" else ""
"${it.startAt.take(10)} ${it.title}: ${it.calories.toInt()} active kcal$steps (${it.sourceName})"
}.ifBlank { "none synced yet" }
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String, todayHistory: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.1)
.put("messages", JSONArray()
@ -375,7 +439,7 @@ private fun buildNutritionBody(model: String, description: String, measure: Stri
"Estimate nutrition for this meal.\nDescription: $description\nPortion: $measure\nImage analysis: $visionEstimate\n\nUser's meals logged today:\n$todayHistory"
)))
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String, recentActivity: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.2)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-management guidance. Do not diagnose disease. Use plain text section titles. Do not use markdown symbols. Recommend medical review for pregnancy, eating disorders, chronic disease, or aggressive goals. Make it feel like a personalized plan built from onboarding answers, not a generic article."))
@ -395,14 +459,15 @@ private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals:
"Tracking style: ${settings.trackingStyle.ifBlank { "not set" }}\n" +
"Calorie-counting experience: ${settings.calorieCountingExperience.ifBlank { "not set" }}\n" +
"Fasting interest: ${settings.fastingInterest.ifBlank { "not set" }}\n" +
"Recent meals: $recentMeals\n\n" +
"Include: daily calorie target, maintenance calories, protein target, meal logging strategy, challenge-specific tactics, weekly review steps, and safety notes."
"Recent meals: $recentMeals\n" +
"Recent activity: $recentActivity\n\n" +
"Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes."
)))
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String): JSONObject =
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String, recentActivity: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.2)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Update this weight-loss plan using the notes and recent meals. Use plain text section titles. Do not use markdown symbols."))
.put(JSONObject().put("role", "user").put("content",
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals"
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals\n\nRecent activity:\n$recentActivity"
)))

View file

@ -43,12 +43,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun getAppState(): AppState {
val entries = JSONObject(get("/api/entries"))
val activities = JSONObject(get("/api/activities"))
val settings = parseSettings(JSONObject(get("/api/settings")))
val plans = parsePlans(JSONObject(get("/api/plans")))
val models = runCatching { parseModels(JSONObject(post("/api/models", "{}"))) }.getOrDefault(emptyList())
return AppState(
entries = parseEntries(entries.optJSONArray("entries")),
trash = parseEntries(entries.optJSONArray("trash")),
activities = parseActivities(activities.optJSONArray("activities")),
plans = plans,
settings = settings,
models = models
@ -109,6 +111,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun deletePlan(id: String): PlansState =
parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString())))
fun syncActivities(activities: List<ActivityRecord>): List<ActivityRecord> {
val body = JSONObject()
.put("source", "health_connect")
.put("activities", JSONArray(activities.map { it.toJson() }))
.toString()
return parseActivities(JSONObject(post("/api/activities/sync", body)).optJSONArray("activities"))
}
fun chat(body: JSONObject): String {
val response = JSONObject(post("/api/chat", JSONObject().put("body", body).toString()))
return response.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
@ -147,6 +157,9 @@ class ApiRepository(private val prefs: SharedPreferences) {
private fun parseEntries(arr: JSONArray?): List<MealEntry> =
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
private fun parseActivities(arr: JSONArray?): List<ActivityRecord> =
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toActivityRecord() }
fun clearTrash(): Pair<List<MealEntry>, List<MealEntry>> {
val root = JSONObject(post("/api/entries/clear-trash", "{}"))
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
@ -229,3 +242,30 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry(
foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
)
)
fun ActivityRecord.toJson(): JSONObject = JSONObject()
.put("id", id)
.put("source", source)
.put("sourceName", sourceName)
.put("type", type)
.put("title", title)
.put("startAt", startAt)
.put("endAt", endAt)
.put("calories", calories)
.put("steps", steps)
.put("distanceMeters", distanceMeters)
.put("syncedAt", syncedAt)
fun JSONObject.toActivityRecord(): ActivityRecord = ActivityRecord(
id = optString("id"),
source = optString("source", "external"),
sourceName = optString("sourceName", optString("source", "External")),
type = optString("type", "Activity"),
title = optString("title", optString("type", "Activity")),
startAt = optString("startAt"),
endAt = optString("endAt"),
calories = optDouble("calories"),
steps = optLong("steps"),
distanceMeters = optDouble("distanceMeters"),
syncedAt = optString("syncedAt")
)

View file

@ -41,6 +41,20 @@ data class DayTotals(
data class ModelOption(val id: String, val name: String = id)
data class ActivityRecord(
val id: String,
val source: String,
val sourceName: String,
val type: String,
val title: String,
val startAt: String,
val endAt: String,
val calories: Double = 0.0,
val steps: Long = 0,
val distanceMeters: Double = 0.0,
val syncedAt: String = ""
)
data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
}
@ -81,6 +95,7 @@ data class ServerSettings(
data class AppState(
val entries: List<MealEntry> = emptyList(),
val trash: List<MealEntry> = emptyList(),
val activities: List<ActivityRecord> = emptyList(),
val plans: PlansState = PlansState(),
val settings: ServerSettings = ServerSettings(),
val models: List<ModelOption> = emptyList()

View file

@ -0,0 +1,98 @@
package com.danvics.calorieai.integrations
import android.content.Context
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.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import com.danvics.calorieai.data.ActivityRecord
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
class HealthConnectSync(private val context: Context) {
val permissions = setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
)
fun availability(): HealthConnectAvailability = when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> HealthConnectAvailability.Available
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> HealthConnectAvailability.UpdateRequired
else -> HealthConnectAvailability.Unavailable
}
suspend fun hasPermissions(): Boolean {
if (availability() != HealthConnectAvailability.Available) return false
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
return granted.containsAll(permissions)
}
suspend fun readActivities(days: Long = 14): List<ActivityRecord> {
if (!hasPermissions()) return emptyList()
val client = HealthConnectClient.getOrCreate(context)
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val syncedAt = Instant.now().toString()
val daily = (0 until days).mapNotNull { offset ->
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) null else ActivityRecord(
id = "health-connect-day-${date}",
source = "health_connect",
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()
val sessions = client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
pageSize = 200,
)
).records.map { session ->
val source = session.metadata.dataOrigin.packageName
ActivityRecord(
id = "health-connect-session-${session.metadata.id}",
source = "health_connect",
sourceName = source.ifBlank { "Health Connect" },
type = "Exercise ${session.exerciseType}",
title = session.title ?: "Exercise session",
startAt = session.startTime.toString(),
endAt = session.endTime.toString(),
syncedAt = syncedAt,
)
}
return (daily + sessions).sortedByDescending { it.startAt }
}
}
enum class HealthConnectAvailability {
Available,
UpdateRequired,
Unavailable,
}

View file

@ -50,6 +50,8 @@ fun CalorieAiApp(
onTunePlan: (String, String) -> Unit,
onSelectPlan: (String) -> Unit,
onDeletePlan: (String) -> Unit,
healthConnectStatus: String,
onHealthConnectSync: () -> Unit,
onDisconnect: () -> Unit
) {
CalorieTheme {
@ -95,7 +97,7 @@ fun CalorieAiApp(
) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (screen) {
Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log })
Screen.Dashboard -> DashboardScreen(appState.entries, appState.activities, appState.settings, onLog = { screen = Screen.Log })
Screen.Log -> LogMealScreen(
editing = editing,
selectedImageName = selectedImageName,
@ -117,7 +119,7 @@ fun CalorieAiApp(
onClearTrash = onClearTrash
)
Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan)
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, onSettingsChange, onDisconnect)
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, healthConnectStatus, onSettingsChange, onHealthConnectSync, onDisconnect)
}
}
}

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.ActivityRecord
import com.danvics.calorieai.data.MealEntry
import com.danvics.calorieai.data.MealStats
import com.danvics.calorieai.data.ServerSettings
@ -13,8 +14,11 @@ import com.danvics.calorieai.data.calculateCalorieTarget
import java.time.LocalDate
@Composable
fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: () -> Unit) = Page {
fun DashboardScreen(entries: List<MealEntry>, activities: List<ActivityRecord>, settings: ServerSettings, onLog: () -> Unit) = Page {
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
val todayActivities = activities.filter { it.startAt.take(10) == LocalDate.now().toString() }
val activityCalories = todayActivities.sumOf { it.calories }.toInt()
val activitySteps = todayActivities.sumOf { it.steps }
val week = MealStats.lastSevenDays(entries)
val avgCalories = if (week.isEmpty()) 0 else week.sumOf { it.calories } / week.size
val macroTotal = today.protein + today.carbs + today.fat
@ -37,6 +41,11 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatTile("Activity", "$activityCalories kcal", Modifier.weight(1f))
StatTile("Steps", activitySteps.toString(), Modifier.weight(1f))
}
if (calorieTarget > 0) {
SectionCard("Daily calorie goal") {
CalorieGoalBar(today.calories, calorieTarget)
@ -77,4 +86,17 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
}
}
}
SectionCard("Synced activity") {
if (todayActivities.isEmpty()) {
Text("No activity synced yet. Connect Garmin or gym apps through Health Connect.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
todayActivities.take(5).forEach { activity ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(activity.title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text("${activity.calories.toInt()} kcal", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}

View file

@ -24,7 +24,9 @@ private val FASTING_INTEREST = listOf("Interested", "Tried it before", "Not inte
fun SettingsScreen(
settings: ServerSettings,
serverUrl: String,
healthConnectStatus: String,
onSettingsChange: (ServerSettings) -> Unit,
onHealthConnectSync: () -> Unit,
onDisconnect: () -> Unit
) = Page {
var draft by remember(settings) { mutableStateOf(settings) }
@ -72,6 +74,16 @@ fun SettingsScreen(
DropdownField("Fasting interest", FASTING_INTEREST, draft.fastingInterest, { draft = draft.copy(fastingInterest = it) })
}
SectionCard("Garmin and gym machines") {
Text(
"Sync Garmin Connect, supported gym equipment apps, and other wearables through Android Health Connect.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onHealthConnectSync, modifier = Modifier.fillMaxWidth()) { Text("Sync Health Connect") }
Text(healthConnectStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SectionCard("Server") {
Text("Connected to $serverUrl", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("AI models and API keys are configured on the server.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)

View file

@ -307,6 +307,20 @@ async function clearTrashRoute(req, res) {
}
}
async function getActivitiesRoute(req, res) {
send(res, 200, JSON.stringify(await store.getActivities()));
}
async function syncActivitiesRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const activities = Array.isArray(payload.activities) ? payload.activities : [];
send(res, 200, JSON.stringify(await store.upsertActivities(activities, payload.source || '')));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
function dedupeModels(models) {
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
id: String(model.id),
@ -672,6 +686,8 @@ async function start() {
app.post('/api/entries/delete', wrap(deleteEntriesRoute));
app.post('/api/entries/restore', wrap(restoreEntryRoute));
app.post('/api/entries/clear-trash', wrap(clearTrashRoute));
app.get('/api/activities', wrap(getActivitiesRoute));
app.post('/api/activities/sync', wrap(syncActivitiesRoute));
app.post('/api/chat', wrap(proxyChat));
app.use((req, res) => {
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);

View file

@ -146,6 +146,21 @@ async function createStore({
trashed_at TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
source_name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
start_at TEXT NOT NULL,
end_at TEXT NOT NULL DEFAULT '',
calories DOUBLE PRECISION NOT NULL DEFAULT 0,
steps BIGINT NOT NULL DEFAULT 0,
distance_meters DOUBLE PRECISION NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
async function getKey(key, fallback) {
@ -313,6 +328,87 @@ async function createStore({
return getEntries();
}
function normalizeActivity(activity, fallbackSource = '') {
const startAt = activity.startAt || activity.date || nowIso();
const source = String(activity.source || fallbackSource || 'external');
return {
id: String(activity.id || `${source}-${startAt}`),
source,
sourceName: String(activity.sourceName || activity.source_name || source),
type: String(activity.type || 'Activity'),
title: String(activity.title || activity.type || 'Activity'),
startAt: String(startAt),
endAt: String(activity.endAt || activity.end_at || startAt),
calories: Number(activity.calories || 0),
steps: Number(activity.steps || 0),
distanceMeters: Number(activity.distanceMeters || activity.distance_meters || 0),
metadata: activity.metadata && typeof activity.metadata === 'object' ? activity.metadata : {},
syncedAt: String(activity.syncedAt || nowIso()),
};
}
async function getActivities(limit = 500) {
const result = await pool.query(`
SELECT id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at
FROM activities
ORDER BY start_at DESC
LIMIT $1
`, [limit]);
return {
activities: result.rows.map(row => ({
id: row.id,
source: row.source,
sourceName: row.source_name,
type: row.type,
title: row.title,
startAt: row.start_at,
endAt: row.end_at,
calories: Number(row.calories || 0),
steps: Number(row.steps || 0),
distanceMeters: Number(row.distance_meters || 0),
metadata: row.metadata || {},
syncedAt: row.synced_at,
})),
};
}
async function upsertActivities(activities, source = '') {
for (const item of activities) {
const activity = normalizeActivity(item, source);
await pool.query(`
INSERT INTO activities (id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
ON CONFLICT(id) DO UPDATE SET
source = excluded.source,
source_name = excluded.source_name,
type = excluded.type,
title = excluded.title,
start_at = excluded.start_at,
end_at = excluded.end_at,
calories = excluded.calories,
steps = excluded.steps,
distance_meters = excluded.distance_meters,
metadata = excluded.metadata,
synced_at = excluded.synced_at,
updated_at = excluded.updated_at
`, [
activity.id,
activity.source,
activity.sourceName,
activity.type,
activity.title,
activity.startAt,
activity.endAt,
activity.calories,
activity.steps,
activity.distanceMeters,
JSON.stringify(activity.metadata),
activity.syncedAt,
]);
}
return getActivities();
}
await migrateLegacy();
return {
@ -333,6 +429,8 @@ async function createStore({
moveEntriesToTrash,
restoreEntry,
clearTrash,
getActivities,
upsertActivities,
};
}

View file

@ -63,6 +63,7 @@
let sidebarCollapsed = false;
let entries = [];
let activities = [];
let models = fallbackModels;
let discoveredModels = [];
let modelSearch = '';
@ -116,6 +117,11 @@
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
$: produceToday = today.fruit + today.vegetables;
$: todayEntries = entries.filter(e => e.date === todayKey()).sort((a, b) => a.time.localeCompare(b.time));
$: todayActivities = activities.filter(activity => String(activity.startAt || '').slice(0, 10) === todayKey());
$: activityToday = todayActivities.reduce((sum, activity) => ({
calories: sum.calories + Number(activity.calories || 0),
steps: sum.steps + Number(activity.steps || 0),
}), { calories: 0, steps: 0 });
$: macroTotal = Math.max(1, today.protein + today.carbs + today.fat);
$: calculatedCaloriePlan = calculateCaloriePlan(settings);
$: activeCalorieTarget = Number(settings.calorieTarget) || calculatedCaloriePlan?.goalCalories || 0;
@ -136,7 +142,7 @@
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadModels(), loadPlans()]);
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadActivitiesFromServer(), loadModels(), loadPlans()]);
await drawCharts();
});
@ -302,6 +308,13 @@
trash = data.trash || [];
}
async function loadActivitiesFromServer() {
try {
const response = await fetch('/api/activities');
if (response.ok) activities = (await response.json()).activities || [];
} catch {}
}
function applyPlanConfig(config) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
@ -547,6 +560,17 @@
return lines.join('\n');
}
function recentActivitySummary(limit = 10) {
const rows = activities.slice(0, limit).map(activity => {
const label = activity.title || activity.type || 'Activity';
const date = String(activity.startAt || '').slice(0, 10);
const calories = Math.round(Number(activity.calories || 0));
const steps = Number(activity.steps || 0);
return `${date} ${label}: ${calories} active kcal${steps ? `, ${steps} steps` : ''} (${activity.sourceName || activity.source || 'external'})`;
});
return rows.join('; ') || 'none synced yet';
}
async function requestMealEstimate(imageEstimate) {
const todayHistory = buildTodayHistory();
const userContent = [
@ -772,8 +796,9 @@ Tracking style: ${settings.trackingStyle || 'not set'}
Calorie-counting experience: ${settings.calorieCountingExperience || 'not set'}
Fasting interest: ${settings.fastingInterest || 'not set'}
Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.
Recent activity: ${recentActivitySummary()}.
Include: daily calorie target, maintenance calories, protein target, meal logging strategy, challenge-specific tactics, weekly review steps, and safety notes.` },
Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes.` },
],
});
const version = Math.max(0, ...plans.map(p => Number(p.version || 0))) + 1;
@ -806,7 +831,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
temperature: 0.2,
messages: [
{ role: 'system', content: 'Update the weight-loss plan using the provided notes and logged meals. Use plain text section titles and simple lines. Do not use markdown symbols. Do not mention chat history.' },
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}` },
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}\n\nRecent activity:\n${recentActivitySummary(15)}` },
],
});
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
@ -993,7 +1018,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
<main class="min-w-0 flex-1 p-4 lg:p-6">
{#if page === 'dashboard'}
<section class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
<div class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
@ -1006,6 +1031,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
</article>
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
<Stat label="Activity today" value={`${Math.round(activityToday.calories)} kcal`} note={`${activityToday.steps} steps`} />
</div>
<div class="grid gap-4 lg:grid-cols-2">
@ -1052,6 +1078,25 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
</div>
{/if}
</section>
<section class="card lg:col-span-2">
<h3 class="card-title">Synced activity</h3>
<div class="divide-y divide-slate-100">
{#each todayActivities.slice(0, 5) as activity}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{activity.title || activity.type || 'Activity'}</div>
<div class="text-xs text-slate-400">{activity.sourceName || activity.source || 'External'} · {String(activity.startAt || '').slice(11, 16) || 'All day'}</div>
</div>
<div class="ml-3 shrink-0 text-right text-sm font-bold text-slate-600">
{Math.round(Number(activity.calories || 0))} kcal
{#if activity.steps}<div class="text-xs font-medium text-slate-400">{activity.steps} steps</div>{/if}
</div>
</div>
{:else}
<div class="px-4 py-8 text-center text-sm text-slate-400">No activity synced yet. Connect Garmin or gym apps through Health Connect on Android.</div>
{/each}
</div>
</section>
</div>
</section>

View file

@ -78,5 +78,14 @@
</div>
</div>
</section>
<section class="card xl:col-span-2">
<h3 class="card-title">Wearables and gym machines</h3>
<div class="grid gap-3 p-4 text-sm leading-6 text-slate-600 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3"><strong class="block text-slate-900">Garmin</strong>Sync Garmin Connect to Android Health Connect, then tap Sync Health Connect in the Android app.</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3"><strong class="block text-slate-900">Gym machines</strong>Use the machine or gym app to write workouts to Health Connect. Calorie AI imports the workout sessions and daily active calories.</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3"><strong class="block text-slate-900">Planning</strong>Synced active calories and steps are saved on the server and included when generating or updating plans.</div>
</div>
</section>
</div>
</section>

View file

@ -107,6 +107,18 @@ async function mockEntries(page) {
});
}
async function mockActivities(page) {
let activities = [];
await page.route('**/api/activities', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
});
await page.route('**/api/activities/sync', async (route) => {
const body = route.request().postDataJSON();
activities = [...(body.activities || []), ...activities];
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
});
}
async function mockSettings(page) {
let settings = {};
await page.route('**/api/settings', async (route) => {
@ -122,6 +134,7 @@ async function login(page) {
await mockModels(page);
await mockPlans(page);
await mockEntries(page);
await mockActivities(page);
await mockSettings(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);