diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index 3afd7d5..c993a5a 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -36,4 +36,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: calorie-ai-debug-apk - path: app/build/outputs/apk/debug/app-debug.apk + path: app/build/outputs/apk/debug/*.apk diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 1b9f65a..91be084 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -32,7 +32,7 @@ jobs: run: | gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug mkdir -p dist - cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk" + cp app/build/outputs/apk/debug/*.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk" - name: Publish Forgejo release asset env: diff --git a/app/build.gradle b/app/build.gradle index 9ffe1e6..6482a6e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,14 +12,20 @@ android { applicationId 'com.danvics.calorieai' minSdk 26 targetSdk 35 - versionCode 1 - versionName '1.0' + versionCode 2 + versionName '1.1' } buildFeatures { compose true } + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { + outputFileName = "calorie-ai-${variant.versionName}.apk" + } + } + compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 @@ -38,6 +44,7 @@ dependencies { implementation platform('androidx.compose:compose-bom:2025.05.01') implementation 'androidx.activity:activity-compose:1.10.1' implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-core' implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da1c1ca..fbad768 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,10 +5,13 @@ + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/com/danvics/calorieai/MainActivity.kt b/app/src/main/java/com/danvics/calorieai/MainActivity.kt index 5eca19d..1e5cca9 100644 --- a/app/src/main/java/com/danvics/calorieai/MainActivity.kt +++ b/app/src/main/java/com/danvics/calorieai/MainActivity.kt @@ -8,13 +8,17 @@ import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext -import com.danvics.calorieai.ai.AiClient import com.danvics.calorieai.ai.ImagePayload +import com.danvics.calorieai.ai.NutritionParser import com.danvics.calorieai.data.* import com.danvics.calorieai.ui.CalorieAiApp +import com.danvics.calorieai.ui.CalorieTheme +import com.danvics.calorieai.ui.ConnectScreen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -23,91 +27,285 @@ import java.util.UUID class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val repo = AppRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE)) - val ai = AiClient() - setContent { AppRoot(repo, ai) } + val repo = ApiRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE)) + setContent { AppRoot(repo) } } } @Composable -private fun AppRoot(repo: AppRepository, ai: AiClient) { +private fun AppRoot(repo: ApiRepository) { val context = LocalContext.current val scope = rememberCoroutineScope() - var entries by remember { mutableStateOf(repo.entries()) } - var trash by remember { mutableStateOf(repo.trash()) } - var settings by remember { mutableStateOf(repo.settings()) } + val parser = remember { NutritionParser() } + + var connected by remember { mutableStateOf(repo.isConnected()) } + var connectError by remember { mutableStateOf("") } + var connecting by remember { mutableStateOf(false) } + + var appState by remember { mutableStateOf(AppState()) } + var editing by remember { mutableStateOf(null) } var selectedImage by remember { mutableStateOf(null) } var status by remember { mutableStateOf("") } var busy by remember { mutableStateOf(false) } - var editing by remember { mutableStateOf(null) } + var planStatus by remember { mutableStateOf("") } + var planBusy by remember { mutableStateOf(false) } + var syncing by remember { mutableStateOf(false) } val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { input -> - val bytes = input.readBytes() - ImagePayload.fromBytes(uri.lastPathSegment ?: "meal image", context.contentResolver.getType(uri) ?: "image/jpeg", bytes) + if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { stream -> + ImagePayload.fromBytes(uri.lastPathSegment ?: "image", context.contentResolver.getType(uri) ?: "image/jpeg", stream.readBytes()) } } val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> - if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera meal photo", bitmap) + if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera photo", bitmap) } - fun persistEntries(next: List) { entries = next; repo.saveEntries(next) } - fun persistTrash(next: List) { trash = next; repo.saveTrash(next) } - fun persistSettings(next: AppSettings) { settings = next; repo.saveSettings(next) } + fun handleUnauth() { connected = false; appState = AppState() } + + fun syncState() { + syncing = true + scope.launch(Dispatchers.IO) { + runCatching { repo.getAppState() } + .onSuccess { state -> withContext(Dispatchers.Main) { appState = state; syncing = false } } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth(); syncing = false } } + } + } + + LaunchedEffect(connected) { + if (connected) syncState() + } + + if (!connected) { + CalorieTheme { + ConnectScreen(error = connectError, connecting = connecting) { url, user, pass -> + connecting = true + connectError = "" + scope.launch(Dispatchers.IO) { + runCatching { repo.login(url, user, pass) } + .onSuccess { cookie -> + repo.saveConfig(url, cookie) + withContext(Dispatchers.Main) { connecting = false; connected = true } + } + .onFailure { e -> + withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" } + } + } + } + } + return + } CalorieAiApp( - entries = entries, - trash = trash, - settings = settings, + appState = appState, + serverUrl = repo.localConfig().serverUrl, status = status, busy = busy, editing = editing, selectedImageName = selectedImage?.name.orEmpty(), + planStatus = planStatus, + planBusy = planBusy, + syncing = syncing, + onSync = { syncState() }, onPickImage = { imagePicker.launch("image/*") }, onTakePhoto = { camera.launch(null) }, - onCancelEdit = { editing = null; status = "" }, + onCancelEdit = { editing = null; selectedImage = null; status = "" }, onSaveManualEdit = { updated -> - persistEntries(entries.map { if (it.id == updated.id) updated else it }) - editing = null - status = "Meal updated." + scope.launch(Dispatchers.IO) { + runCatching { repo.upsertEntry(updated) } + .onSuccess { (entries, trash) -> + withContext(Dispatchers.Main) { + appState = appState.copy(entries = entries, trash = trash) + editing = null + status = "Meal updated." + } + } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() else status = "Save failed: ${e.message}" } } + } }, onAnalyze = { draft -> - if (!ai.isConfigured(settings)) { status = "Set API base URL and nutrition model in Settings first."; return@CalorieAiApp } + if (appState.settings.taskModel.isBlank()) { status = "No AI model configured on server."; return@CalorieAiApp } busy = true status = "Analyzing meal..." - scope.launch { + scope.launch(Dispatchers.IO) { runCatching { - withContext(Dispatchers.IO) { ai.estimateMeal(settings, draft.description, draft.measure, selectedImage) } - }.onSuccess { estimate -> + val visionEstimate = if (selectedImage != null && appState.settings.visionModel.isNotBlank()) { + repo.chat(buildVisionBody(appState.settings.visionModel, draft.description, draft.measure, selectedImage!!)) + } else "" + val nutritionJson = repo.chat(buildNutritionBody(appState.settings.taskModel, draft.description, draft.measure, visionEstimate)) + val estimate = parser.parse(nutritionJson) val meal = draft.copy( id = editing?.id ?: UUID.randomUUID().toString(), date = draft.date.ifBlank { LocalDate.now().toString() }, time = draft.time.ifBlank { LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) }, imageIncluded = selectedImage != null, imageName = selectedImage?.name.orEmpty(), + visionEstimate = visionEstimate, estimate = estimate ) - persistEntries(if (editing == null) entries + meal else entries.map { if (it.id == meal.id) meal else it }) - editing = meal - status = "Saved. You can edit values or analyze again." - }.onFailure { status = "Analysis failed: ${it.message}" } - busy = false + repo.upsertEntry(meal) to meal + }.onSuccess { (pair, meal) -> + val (entries, trash) = pair + withContext(Dispatchers.Main) { + appState = appState.copy(entries = entries, trash = trash) + editing = meal + selectedImage = null + status = "Saved. ${meal.estimate.mealName} · ${meal.estimate.calories} kcal" + busy = false + } + }.onFailure { e -> + withContext(Dispatchers.Main) { + if (e is UnauthorizedException) handleUnauth() + else status = "Analysis failed: ${e.message}" + busy = false + } + } } }, onEdit = { editing = it }, onMoveToTrash = { ids -> - val moving = entries.filter { it.id in ids }.map { it.copy(trashedAt = LocalDate.now().toString()) } - persistEntries(entries.filterNot { it.id in ids }) - persistTrash(moving + trash) - }, - onRestore = { id -> - trash.find { it.id == id }?.let { found -> - persistTrash(trash.filterNot { it.id == id }) - persistEntries(listOf(found.copy(trashedAt = "")) + entries) + scope.launch(Dispatchers.IO) { + runCatching { repo.deleteEntries(ids.toList()) } + .onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } } } }, - onSettingsChange = ::persistSettings, - onSearchModels = { query -> withContext(Dispatchers.IO) { ai.searchModels(settings, query) } }, - onGeneratePlan = { withContext(Dispatchers.IO) { ai.weightPlan(settings) } } + onClearTrash = { + scope.launch(Dispatchers.IO) { + runCatching { repo.clearTrash() } + .onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } } + } + }, + onRestore = { id -> + scope.launch(Dispatchers.IO) { + runCatching { repo.restoreEntry(id) } + .onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } } + } + }, + onSettingsChange = { updated -> + scope.launch(Dispatchers.IO) { + runCatching { repo.saveSettings(updated) } + .onSuccess { saved -> withContext(Dispatchers.Main) { appState = appState.copy(settings = saved) } } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } } + } + }, + onGeneratePlan = { + val settings = appState.settings + if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) { + planStatus = "Enter height and current weight in Settings first." + return@CalorieAiApp + } + planBusy = true + planStatus = "Generating plan..." + scope.launch(Dispatchers.IO) { + runCatching { + 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(settings.taskModel, settings, recentMeals)) + val plan = Plan( + id = UUID.randomUUID().toString(), + title = "Plan ${appState.plans.plans.size + 1}", + version = appState.plans.plans.size + 1, + content = content, + createdAt = java.time.Instant.now().toString() + ) + repo.savePlan(plan) + }.onSuccess { plans -> + withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan generated."; planBusy = false } + }.onFailure { e -> + withContext(Dispatchers.Main) { + if (e is UnauthorizedException) handleUnauth() + else planStatus = "Error: ${e.message}" + planBusy = false + } + } + } + }, + onTunePlan = { planId, note -> + val currentPlan = appState.plans.plans.find { it.id == planId } ?: return@CalorieAiApp + planBusy = true + planStatus = "Updating plan..." + scope.launch(Dispatchers.IO) { + runCatching { + 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 version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2 + val updated = Plan( + id = UUID.randomUUID().toString(), + title = "Updated plan $version", + version = version, + content = content, + createdAt = java.time.Instant.now().toString(), + previousPlanId = planId + ) + repo.savePlan(updated) + }.onSuccess { plans -> + withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan updated."; planBusy = false } + }.onFailure { e -> + withContext(Dispatchers.Main) { + if (e is UnauthorizedException) handleUnauth() + else planStatus = "Error: ${e.message}" + planBusy = false + } + } + } + }, + onSelectPlan = { id -> + scope.launch(Dispatchers.IO) { + runCatching { repo.selectPlan(id) } + .onSuccess { plans -> withContext(Dispatchers.Main) { appState = appState.copy(plans = plans) } } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } } + } + }, + onDeletePlan = { id -> + scope.launch(Dispatchers.IO) { + runCatching { repo.deletePlan(id) } + .onSuccess { plans -> withContext(Dispatchers.Main) { appState = appState.copy(plans = plans) } } + .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } } + } + }, + onDisconnect = { + repo.disconnect() + connected = false + appState = AppState() + editing = null + status = "" + } ) } + +private fun buildVisionBody(model: String, description: String, measure: String, image: ImagePayload): JSONObject = + JSONObject().put("model", model).put("temperature", 0.15) + .put("messages", JSONArray().put(JSONObject().put("role", "user").put("content", + JSONArray() + .put(JSONObject().put("type", "text").put("text", "Analyze this meal photo. Estimate foods, portions, calories, protein, carbs, fat, fruit servings, vegetable servings. Description: $description. Portion: $measure")) + .put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri))) + ))) + +private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject = + JSONObject().put("model", model).put("temperature", 0.1) + .put("messages", JSONArray() + .put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap in markdown.")) + .put(JSONObject().put("role", "user").put("content", + "Estimate nutrition for one meal. Return JSON with keys: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integers for grams and calories.\n\nDescription: $description\nPortion: $measure\nImage estimate: $visionEstimate" + ))) + +private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject = + JSONObject().put("model", model).put("temperature", 0.2) + .put("messages", JSONArray() + .put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-loss 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.")) + .put(JSONObject().put("role", "user").put("content", + "Create a personalized weight-loss plan: height ${settings.heightCm} cm, weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, weekly review steps, safety notes. Recent meals: $recentMeals" + ))) + +private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: 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" + ))) diff --git a/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt b/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt index c6a1dd4..79705cb 100644 --- a/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt +++ b/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt @@ -1,90 +1,8 @@ package com.danvics.calorieai.ai -import android.util.Base64 import android.graphics.Bitmap -import com.danvics.calorieai.data.AppSettings -import com.danvics.calorieai.data.ModelOption -import com.danvics.calorieai.data.NutritionEstimate -import org.json.JSONArray -import org.json.JSONObject -import java.io.InputStream +import android.util.Base64 import java.io.ByteArrayOutputStream -import java.io.OutputStream -import java.net.HttpURLConnection -import java.net.URL - -class AiClient(private val parser: NutritionParser = NutritionParser()) { - fun isConfigured(settings: AppSettings) = settings.apiBaseUrl.isNotBlank() && settings.nutritionModel.isNotBlank() - - fun estimateMeal(settings: AppSettings, description: String, measure: String, image: ImagePayload?): NutritionEstimate { - val vision = if (image != null && settings.visionModel.isNotBlank()) visionEstimate(settings, description, measure, image) else "" - val prompt = "Estimate nutrition for one meal. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\n\nDescription: $description\nPortion: $measure\nImage estimate: $vision" - val body = JSONObject() - .put("model", settings.nutritionModel) - .put("temperature", 0.1) - .put("messages", JSONArray() - .put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap the response in markdown.")) - .put(JSONObject().put("role", "user").put("content", prompt))) - return parser.parse(chat(settings, body)) - } - - fun weightPlan(settings: AppSettings): String { - val prompt = "Create a safe personalized weight-loss plan. Height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, and safety notes." - val body = JSONObject() - .put("model", settings.nutritionModel) - .put("temperature", 0.2) - .put("messages", JSONArray() - .put(JSONObject().put("role", "system").put("content", "Give concise nutrition guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals.")) - .put(JSONObject().put("role", "user").put("content", prompt))) - return chat(settings, body) - } - - fun searchModels(settings: AppSettings, query: String): List { - val base = settings.apiBaseUrl.trimEnd('/').removeSuffix("/v1") - val response = runCatching { request(settings, "$base/model/info", null, "GET") }.getOrElse { - request(settings, settings.apiBaseUrl.trimEnd('/') + "/models", null, "GET") - } - val root = JSONObject(response) - val rows = root.optJSONArray("data") ?: JSONArray() - val q = query.trim().lowercase() - return (0 until rows.length()).mapNotNull { rows.optJSONObject(it) }.mapNotNull { item -> - val id = item.optString("model_name", item.optString("id")) - if (id.isBlank()) null else ModelOption(id, id) - }.distinctBy { it.id }.filter { q.isBlank() || it.id.lowercase().contains(q) }.take(100) - } - - private fun visionEstimate(settings: AppSettings, description: String, measure: String, image: ImagePayload): String { - val content = JSONArray() - .put(JSONObject().put("type", "text").put("text", "Analyze this meal photo for nutrition tracking. Estimate foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. Description: $description; portion: $measure")) - .put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri))) - val body = JSONObject().put("model", settings.visionModel).put("temperature", 0.15) - .put("messages", JSONArray().put(JSONObject().put("role", "user").put("content", content))) - return chat(settings, body) - } - - private fun chat(settings: AppSettings, body: JSONObject): String { - val response = request(settings, settings.apiBaseUrl.trimEnd('/') + "/chat/completions", body.toString(), "POST") - return JSONObject(response).getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim() - } - - private fun request(settings: AppSettings, url: String, body: String?, method: String): String { - val connection = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = method - connectTimeout = 20_000 - readTimeout = 90_000 - setRequestProperty("Content-Type", "application/json") - if (settings.apiKey.isNotBlank()) setRequestProperty("Authorization", "Bearer ${settings.apiKey}") - } - if (body != null) { - connection.doOutput = true - connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } - } - val code = connection.responseCode - val text = (if (code in 200..299) connection.inputStream else connection.errorStream).readTextSafely() - if (code !in 200..299) error("HTTP $code: $text") - return text - } -} data class ImagePayload(val name: String, val dataUri: String) { companion object { @@ -100,5 +18,3 @@ data class ImagePayload(val name: String, val dataUri: String) { } } } - -private fun InputStream?.readTextSafely(): String = this?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: "" diff --git a/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt b/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt index 762ace4..9c7a2eb 100644 --- a/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt +++ b/app/src/main/java/com/danvics/calorieai/data/AppRepository.kt @@ -3,64 +3,195 @@ package com.danvics.calorieai.data import android.content.SharedPreferences import org.json.JSONArray import org.json.JSONObject +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL -class AppRepository(private val prefs: SharedPreferences) { - fun entries(): List = readEntries("entries") - fun trash(): List = readEntries("trash") +class UnauthorizedException : Exception("Session expired. Please reconnect.") - fun saveEntries(entries: List) = writeEntries("entries", entries) - fun saveTrash(entries: List) = writeEntries("trash", entries) +class ApiRepository(private val prefs: SharedPreferences) { + private var config = loadConfig() - fun settings(): AppSettings = AppSettings( - apiBaseUrl = prefs.getString("api_base_url", "") ?: "", - apiKey = prefs.getString("api_key", "") ?: "", - visionModel = prefs.getString("vision_model", "gpt-4o-mini") ?: "gpt-4o-mini", - nutritionModel = prefs.getString("nutrition_model", prefs.getString("task_model", "gpt-4o-mini")) ?: "gpt-4o-mini", - heightCm = prefs.getString("height_cm", "") ?: "", - weightKg = prefs.getString("weight_kg", "") ?: "", - targetWeightKg = prefs.getString("target_weight_kg", "") ?: "", - activityLevel = prefs.getString("activity_level", "Moderate") ?: "Moderate", - pace = prefs.getString("pace", "Steady") ?: "Steady", - models = readModels() + fun localConfig() = config + fun isConnected() = config.isConnected() + + fun login(serverUrl: String, username: String, password: String): String { + val base = serverUrl.trimEnd('/') + val body = JSONObject().put("username", username).put("password", password).toString() + val conn = openConn("$base/api/login", "POST", null).apply { + doOutput = true + outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + val code = conn.responseCode + val text = conn.readStream(code) + if (code !in 200..299) error(runCatching { JSONObject(text).optString("error", "Login failed") }.getOrElse { "Login failed ($code)" }) + return conn.headerFields["Set-Cookie"] + ?.firstOrNull { it.startsWith("calorie_ai_session=") } + ?.substringBefore(';')?.trim() + ?: error("No session cookie received from server") + } + + fun saveConfig(serverUrl: String, cookie: String) { + config = LocalConfig(serverUrl, cookie) + prefs.edit().putString("server_url", serverUrl).putString("session_cookie", cookie).apply() + } + + fun disconnect() { + config = LocalConfig() + prefs.edit().remove("server_url").remove("session_cookie").apply() + } + + fun getAppState(): AppState { + val entries = JSONObject(get("/api/entries")) + 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")), + plans = plans, + settings = settings, + models = models + ) + } + + fun upsertEntry(entry: MealEntry): Pair, List> { + val root = JSONObject(post("/api/entries", entry.toJson().toString())) + return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash")) + } + + fun deleteEntries(ids: List): Pair, List> { + val body = JSONObject().put("ids", JSONArray(ids)).toString() + val root = JSONObject(post("/api/entries/delete", body)) + return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash")) + } + + fun restoreEntry(id: String): Pair, List> { + val root = JSONObject(post("/api/entries/restore", JSONObject().put("id", id).toString())) + return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash")) + } + + fun getSettings(): ServerSettings = parseSettings(JSONObject(get("/api/settings"))) + + fun saveSettings(settings: ServerSettings): ServerSettings = + parseSettings(JSONObject(post("/api/settings", JSONObject() + .put("heightCm", settings.heightCm) + .put("weightKg", settings.weightKg) + .put("targetWeightKg", settings.targetWeightKg) + .put("calorieTarget", settings.calorieTarget) + .put("activityLevel", settings.activityLevel) + .put("pace", settings.pace) + .toString()))) + + fun getPlans(): PlansState = parsePlans(JSONObject(get("/api/plans"))) + + fun savePlan(plan: Plan): PlansState = + parsePlans(JSONObject(post("/api/plans", JSONObject() + .put("id", plan.id) + .put("title", plan.title) + .put("version", plan.version) + .put("content", plan.content) + .put("createdAt", plan.createdAt) + .put("previousPlanId", plan.previousPlanId) + .toString()))) + + fun selectPlan(id: String): PlansState = + parsePlans(JSONObject(post("/api/plans/select", JSONObject().put("id", id).toString()))) + + fun deletePlan(id: String): PlansState = + parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString()))) + + 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() + } + + private fun get(path: String) = request(path, null, "GET") + private fun post(path: String, body: String) = request(path, body, "POST") + + private fun request(path: String, body: String?, method: String): String { + val conn = openConn(config.serverUrl.trimEnd('/') + path, method, config.sessionCookie) + if (body != null) { + conn.doOutput = true + conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + val code = conn.responseCode + if (code == 401) throw UnauthorizedException() + val text = conn.readStream(code) + if (code !in 200..299) error("HTTP $code: $text") + return text + } + + private fun openConn(url: String, method: String, cookie: String?) = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 20_000 + readTimeout = 90_000 + setRequestProperty("Content-Type", "application/json") + if (!cookie.isNullOrBlank()) setRequestProperty("Cookie", cookie) + } + + private fun loadConfig() = LocalConfig( + serverUrl = prefs.getString("server_url", "") ?: "", + sessionCookie = prefs.getString("session_cookie", "") ?: "" ) - fun saveSettings(settings: AppSettings) { - prefs.edit() - .putString("api_base_url", settings.apiBaseUrl) - .putString("api_key", settings.apiKey) - .putString("vision_model", settings.visionModel) - .putString("nutrition_model", settings.nutritionModel) - .putString("height_cm", settings.heightCm) - .putString("weight_kg", settings.weightKg) - .putString("target_weight_kg", settings.targetWeightKg) - .putString("activity_level", settings.activityLevel) - .putString("pace", settings.pace) - .putString("models", JSONArray(settings.models.map { JSONObject().put("id", it.id).put("name", it.name) }).toString()) - .apply() + private fun parseEntries(arr: JSONArray?): List = + (0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() } + + fun clearTrash(): Pair, List> { + val root = JSONObject(post("/api/entries/clear-trash", "{}")) + return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash")) } - private fun readEntries(key: String): List = runCatching { - val array = JSONArray(prefs.getString(key, "[]")) - (0 until array.length()).mapNotNull { index -> array.optJSONObject(index)?.toMealEntry() } - }.getOrDefault(emptyList()) + private fun parseSettings(json: JSONObject) = ServerSettings( + visionModel = json.optString("visionModel"), + taskModel = json.optString("taskModel"), + heightCm = json.optString("heightCm"), + weightKg = json.optString("weightKg"), + targetWeightKg = json.optString("targetWeightKg"), + calorieTarget = json.optString("calorieTarget"), + activityLevel = json.optString("activityLevel", "Moderate").ifBlank { "Moderate" }, + pace = json.optString("pace", "Steady").ifBlank { "Steady" } + ) - private fun writeEntries(key: String, entries: List) { - prefs.edit().putString(key, JSONArray(entries.map { it.toJson() }).toString()).apply() + private fun parsePlans(json: JSONObject): PlansState { + val arr = json.optJSONArray("plans") ?: JSONArray() + val plans = (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { obj -> + Plan( + id = obj.optString("id"), + title = obj.optString("title", "Plan"), + version = obj.optInt("version", 1), + content = obj.optString("content"), + createdAt = obj.optString("createdAt"), + previousPlanId = obj.optString("previousPlanId") + ) + } + return PlansState( + plans = plans, + selectedPlanId = json.optString("selectedPlanId").ifBlank { plans.firstOrNull()?.id ?: "" } + ) } - private fun readModels(): List = runCatching { - val array = JSONArray(prefs.getString("models", "[]")) - (0 until array.length()).mapNotNull { array.optJSONObject(it) }.map { ModelOption(it.optString("id"), it.optString("name", it.optString("id"))) } - }.getOrDefault(emptyList()).ifEmpty { listOf(ModelOption("gpt-4o-mini")) } + private fun parseModels(json: JSONObject): List { + val arr = json.optJSONArray("models") ?: return emptyList() + return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) } + .map { obj -> ModelOption(obj.optString("id"), obj.optString("name", obj.optString("id"))) } + } } +private fun HttpURLConnection.readStream(code: Int): String = + (if (code in 200..299) inputStream else errorStream ?: inputStream) + .bufferedReader(Charsets.UTF_8).use { it.readText() } + fun MealEntry.toJson(): JSONObject = JSONObject() .put("id", id).put("date", date).put("time", time).put("mealType", mealType) .put("description", description).put("measure", measure).put("imageIncluded", imageIncluded) .put("imageName", imageName).put("visionEstimate", visionEstimate).put("trashedAt", trashedAt) .put("mealName", estimate.mealName).put("calories", estimate.calories) - .put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams).put("fatGrams", estimate.fatGrams) - .put("fruitServings", estimate.fruitServings).put("vegetableServings", estimate.vegetableServings) + .put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams) + .put("fatGrams", estimate.fatGrams).put("fruitServings", estimate.fruitServings) + .put("vegetableServings", estimate.vegetableServings) .put("foodGroups", estimate.foodGroups).put("notes", estimate.notes).put("rawAi", estimate.raw) fun JSONObject.toMealEntry(): MealEntry = MealEntry( @@ -75,8 +206,10 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry( visionEstimate = optString("visionEstimate"), trashedAt = optString("trashedAt"), estimate = NutritionEstimate( - mealName = optString("mealName", "Meal"), calories = optInt("calories"), proteinGrams = optInt("proteinGrams"), - carbsGrams = optInt("carbsGrams"), fatGrams = optInt("fatGrams"), fruitServings = optDouble("fruitServings"), - vegetableServings = optDouble("vegetableServings"), foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi") + mealName = optString("mealName", "Meal").ifBlank { "Meal" }, + calories = optInt("calories"), proteinGrams = optInt("proteinGrams"), + carbsGrams = optInt("carbsGrams"), fatGrams = optInt("fatGrams"), + fruitServings = optDouble("fruitServings"), vegetableServings = optDouble("vegetableServings"), + foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi") ) ) diff --git a/app/src/main/java/com/danvics/calorieai/data/Models.kt b/app/src/main/java/com/danvics/calorieai/data/Models.kt index 50ec62e..4fd2bdc 100644 --- a/app/src/main/java/com/danvics/calorieai/data/Models.kt +++ b/app/src/main/java/com/danvics/calorieai/data/Models.kt @@ -41,15 +41,39 @@ data class DayTotals( data class ModelOption(val id: String, val name: String = id) -data class AppSettings( - val apiBaseUrl: String = "", - val apiKey: String = "", - val visionModel: String = "gpt-4o-mini", - val nutritionModel: String = "gpt-4o-mini", +data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") { + fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank() +} + +data class Plan( + val id: String, + val title: String, + val version: Int = 1, + val content: String, + val createdAt: String, + val previousPlanId: String = "" +) + +data class PlansState( + val plans: List = emptyList(), + val selectedPlanId: String = "" +) + +data class ServerSettings( + val visionModel: String = "", + val taskModel: String = "", val heightCm: String = "", val weightKg: String = "", val targetWeightKg: String = "", + val calorieTarget: String = "", val activityLevel: String = "Moderate", - val pace: String = "Steady", - val models: List = listOf(ModelOption("gpt-4o-mini")) + val pace: String = "Steady" +) + +data class AppState( + val entries: List = emptyList(), + val trash: List = emptyList(), + val plans: PlansState = PlansState(), + val settings: ServerSettings = ServerSettings(), + val models: List = emptyList() ) diff --git a/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt b/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt index e039fd0..3e95c7a 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt @@ -3,23 +3,36 @@ package com.danvics.calorieai.ui import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import com.danvics.calorieai.data.* -private enum class Screen(val label: String) { Dashboard("Dashboard"), Log("Log"), Diary("Diary"), Trash("Trash"), Settings("Settings") } +private enum class Screen(val label: String, val icon: ImageVector) { + Dashboard("Dashboard", Icons.Default.Home), + Log("Log meal", Icons.Default.Add), + Diary("Diary", Icons.Default.Edit), + Plans("Plans", Icons.Default.Star), + Settings("Settings", Icons.Default.Settings), +} +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalorieAiApp( - entries: List, - trash: List, - settings: AppSettings, + appState: AppState, + serverUrl: String, status: String, busy: Boolean, editing: MealEntry?, selectedImageName: String, + planStatus: String, + planBusy: Boolean, + syncing: Boolean, + onSync: () -> Unit, onPickImage: () -> Unit, onTakePhoto: () -> Unit, onCancelEdit: () -> Unit, @@ -27,25 +40,62 @@ fun CalorieAiApp( onAnalyze: (MealEntry) -> Unit, onEdit: (MealEntry) -> Unit, onMoveToTrash: (Set) -> Unit, + onClearTrash: () -> Unit, onRestore: (String) -> Unit, - onSettingsChange: (AppSettings) -> Unit, - onSearchModels: suspend (String) -> List, - onGeneratePlan: suspend () -> String + onSettingsChange: (ServerSettings) -> Unit, + onGeneratePlan: () -> Unit, + onTunePlan: (String, String) -> Unit, + onSelectPlan: (String) -> Unit, + onDeletePlan: (String) -> Unit, + onDisconnect: () -> Unit ) { CalorieTheme { var screen by remember { mutableStateOf(Screen.Dashboard) } Scaffold( + topBar = { + TopAppBar( + title = { Text(screen.label, style = MaterialTheme.typography.titleLarge) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + actions = { + if (syncing) { + Box(Modifier.padding(end = 16.dp)) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } else { + IconButton(onClick = onSync) { + Icon(Icons.Default.Refresh, contentDescription = "Sync") + } + } + } + ) + }, bottomBar = { - NavigationBar { Screen.entries.forEach { item -> NavigationBarItem(selected = screen == item, onClick = { screen = item }, label = { Text(item.label) }, icon = {}) } } + NavigationBar { + Screen.entries.forEach { item -> + NavigationBarItem( + selected = screen == item, + onClick = { screen = item }, + label = { Text(item.label, style = MaterialTheme.typography.labelSmall) }, + icon = { Icon(item.icon, contentDescription = item.label) } + ) + } + } } ) { padding -> Box(Modifier.padding(padding).fillMaxSize()) { when (screen) { - Screen.Dashboard -> DashboardScreen(entries, onLog = { screen = Screen.Log }, onSettings = { screen = Screen.Settings }) + Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log }) Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onTakePhoto, onAnalyze, onSaveManualEdit, onCancelEdit) - Screen.Diary -> DiaryScreen(entries, onEdit = { onEdit(it); screen = Screen.Log }, onMoveToTrash = onMoveToTrash) - Screen.Trash -> TrashScreen(trash, onRestore) - Screen.Settings -> SettingsScreen(settings, onSettingsChange, onSearchModels, onGeneratePlan) + Screen.Diary -> DiaryScreen( + entries = appState.entries, + trash = appState.trash, + onEdit = { onEdit(it); screen = Screen.Log }, + onMoveToTrash = onMoveToTrash, + onRestore = onRestore, + onClearTrash = onClearTrash + ) + Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan) + Screen.Settings -> SettingsScreen(appState.settings, serverUrl, onSettingsChange, onDisconnect) } } } @@ -56,5 +106,3 @@ fun CalorieAiApp( fun Page(content: @Composable ColumnScope.() -> Unit) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = content) } - -private fun blankMeal() = MealEntry("", "", "", "Breakfast", "", "", false, "", "", NutritionEstimate()) diff --git a/app/src/main/java/com/danvics/calorieai/ui/Components.kt b/app/src/main/java/com/danvics/calorieai/ui/Components.kt index aa5f724..671fb26 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/Components.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/Components.kt @@ -2,8 +2,9 @@ package com.danvics.calorieai.ui import androidx.compose.foundation.layout.* import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -11,7 +12,7 @@ import androidx.compose.ui.unit.dp fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant) content() } } @@ -20,20 +21,86 @@ fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composab @Composable fun StatTile(label: String, value: String, modifier: Modifier = Modifier) { ElevatedCard(modifier = modifier) { - Column(Modifier.padding(16.dp)) { - Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) - Text(value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black) } } } @Composable -fun Field(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = true) { +fun Field(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = true, placeholder: String = "") { OutlinedTextField( value = value, onValueChange = onValueChange, label = { Text(label) }, + placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) }) else null, singleLine = singleLine, modifier = modifier.fillMaxWidth() ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropdownField(label: String, options: List, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier.fillMaxWidth()) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable, true) + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem(text = { Text(option) }, onClick = { onValueChange(option); expanded = false }) + } + } + } +} + +@Composable +fun MacroBar(label: String, grams: Int, totalGrams: Int, color: Color) { + val pct = if (totalGrams > 0) grams.toFloat() / totalGrams else 0f + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) + Text("${grams}g · ${(pct * 100).toInt()}%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + LinearProgressIndicator( + progress = { pct }, + modifier = Modifier.fillMaxWidth().height(8.dp), + color = color, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } +} + +@Composable +fun CalorieGoalBar(consumed: Int, target: Int) { + val fraction = (consumed.toFloat() / target).coerceIn(0f, 1f) + val barColor = when { + fraction >= 1f -> MaterialTheme.colorScheme.error + fraction >= 0.85f -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("$consumed kcal consumed", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) + Text("goal: $target kcal", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + LinearProgressIndicator( + progress = { fraction }, + modifier = Modifier.fillMaxWidth().height(8.dp), + color = barColor, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + Text( + if (fraction >= 1f) "Daily goal reached" else "${target - consumed} kcal remaining", + style = MaterialTheme.typography.labelSmall, + color = if (fraction >= 1f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/src/main/java/com/danvics/calorieai/ui/ConnectScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/ConnectScreen.kt new file mode 100644 index 0000000..9556fa2 --- /dev/null +++ b/app/src/main/java/com/danvics/calorieai/ui/ConnectScreen.kt @@ -0,0 +1,66 @@ +package com.danvics.calorieai.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun ConnectScreen(error: String, connecting: Boolean, onConnect: (String, String, String) -> Unit) { + var serverUrl by remember { mutableStateOf("") } + var username by remember { mutableStateOf("admin") } + var password by remember { mutableStateOf("") } + + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Calorie AI", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black) + Text( + "Connect to your self-hosted server", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(32.dp)) + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("http://192.168.1.10:3000") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Username") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + Spacer(Modifier.height(20.dp)) + Button( + enabled = !connecting && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(), + onClick = { onConnect(serverUrl.trim(), username.trim(), password) }, + modifier = Modifier.fillMaxWidth() + ) { Text(if (connecting) "Connecting..." else "Connect") } + if (error.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt index 997850d..f4de8e5 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt @@ -8,27 +8,70 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.danvics.calorieai.data.MealEntry import com.danvics.calorieai.data.MealStats +import com.danvics.calorieai.data.ServerSettings import java.time.LocalDate @Composable -fun DashboardScreen(entries: List, onLog: () -> Unit, onSettings: () -> Unit) = Page { +fun DashboardScreen(entries: List, settings: ServerSettings, onLog: () -> Unit) = Page { val today = MealStats.totalsForDate(entries, LocalDate.now().toString()) - Text("Calorie AI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black) + 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 + val calorieTarget = settings.calorieTarget.toIntOrNull() ?: 0 + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Today", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f), fontWeight = FontWeight.Bold) + Text("${today.calories} kcal", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onPrimary) + Text("P ${today.protein}g · C ${today.carbs}g · F ${today.fat}g", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.85f)) + Text("Fruit ${"%.1f".format(today.fruit)} · Veg ${"%.1f".format(today.vegetables)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - StatTile("Today", "${today.calories} kcal", Modifier.weight(1f)) - StatTile("Meals", today.meals.toString(), Modifier.weight(1f)) + StatTile("Meals today", today.meals.toString(), Modifier.weight(1f)) + StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f)) } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - StatTile("Protein", "${today.protein}g", Modifier.weight(1f)) - StatTile("Produce", "%.1f".format(today.fruit + today.vegetables), Modifier.weight(1f)) + + if (calorieTarget > 0) { + SectionCard("Daily calorie goal") { + CalorieGoalBar(today.calories, calorieTarget) + } } - SectionCard("Quick actions") { - Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log a meal") } - OutlinedButton(onClick = onSettings, modifier = Modifier.fillMaxWidth()) { Text("Settings") } + + if (macroTotal > 0) { + SectionCard("Macros") { + MacroBar("Protein", today.protein, macroTotal, MaterialTheme.colorScheme.primary) + MacroBar("Carbs", today.carbs, macroTotal, MaterialTheme.colorScheme.tertiary) + MacroBar("Fat", today.fat, macroTotal, MaterialTheme.colorScheme.secondary) + } } - SectionCard("Last 7 days") { - MealStats.lastSevenDays(entries).forEach { day -> - Text("${day.label}: ${day.calories} kcal, produce ${"%.1f".format(day.fruit + day.vegetables)}") + + val todayEntries = entries.filter { it.date == LocalDate.now().toString() }.sortedBy { it.time } + if (todayEntries.isNotEmpty()) { + SectionCard("Today's meals") { + todayEntries.forEach { meal -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(meal.estimate.mealName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + Text("${meal.estimate.calories} kcal", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } else { + Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log your first meal today") } + } + + if (week.any { it.calories > 0 }) { + SectionCard("Last 7 days") { + week.reversed().forEach { day -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(day.label, style = MaterialTheme.typography.bodyMedium) + Text(if (day.calories > 0) "${day.calories} kcal" else "—", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } } } } diff --git a/app/src/main/java/com/danvics/calorieai/ui/DiaryScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/DiaryScreen.kt index a6b574b..947be54 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/DiaryScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/DiaryScreen.kt @@ -3,51 +3,153 @@ package com.danvics.calorieai.ui import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.danvics.calorieai.data.MealEntry @Composable -fun DiaryScreen(entries: List, onEdit: (MealEntry) -> Unit, onMoveToTrash: (Set) -> Unit) = Page { +fun DiaryScreen( + entries: List, + trash: List, + onEdit: (MealEntry) -> Unit, + onMoveToTrash: (Set) -> Unit, + onRestore: (String) -> Unit, + onClearTrash: () -> Unit +) { + var activeTab by remember { mutableStateOf(0) } + + Column(Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = activeTab) { + Tab(selected = activeTab == 0, onClick = { activeTab = 0 }, text = { Text("Meals") }) + Tab( + selected = activeTab == 1, + onClick = { activeTab = 1 }, + text = { Text(if (trash.isEmpty()) "Trash" else "Trash (${trash.size})") } + ) + } + when (activeTab) { + 0 -> EntriesContent(entries, onEdit, onMoveToTrash) + 1 -> TrashContent(trash, onRestore, onClearTrash) + } + } +} + +@Composable +private fun EntriesContent( + entries: List, + onEdit: (MealEntry) -> Unit, + onMoveToTrash: (Set) -> Unit +) = Page { var query by remember { mutableStateOf("") } var selected by remember { mutableStateOf(setOf()) } - val rows = entries.filter { query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups).any { value -> value.contains(query, true) } } - Text("Diary", style = MaterialTheme.typography.headlineMedium) - Field("Search meals", query, { query = it }) - Button(enabled = selected.isNotEmpty(), onClick = { onMoveToTrash(selected); selected = emptySet() }, modifier = Modifier.fillMaxWidth()) { Text("Move selected to trash") } + val rows = entries.filter { + query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups, it.mealType) + .any { value -> value.contains(query, ignoreCase = true) } + } + + Field("Search meals", query, { query = it }, placeholder = "Search by name, type, or food groups...") + + if (selected.isNotEmpty()) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "${selected.size} selected", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.CenterVertically).weight(1f) + ) + Button( + onClick = { onMoveToTrash(selected); selected = emptySet() }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { Text("Move to trash") } + } + } + rows.groupBy { it.date }.toSortedMap(reverseOrder()).forEach { (date, dayRows) -> SectionCard(date) { - OutlinedButton(onClick = { onMoveToTrash(dayRows.map { it.id }.toSet()) }, modifier = Modifier.fillMaxWidth()) { Text("Move day to trash") } - dayRows.forEach { meal -> - ElevatedCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Checkbox(checked = meal.id in selected, onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id }) - Column(Modifier.weight(1f)) { - Text(meal.estimate.mealName, fontWeight = FontWeight.Bold) - Text("${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal") - } - } - Text("P ${meal.estimate.proteinGrams}g · C ${meal.estimate.carbsGrams}g · F ${meal.estimate.fatGrams}g") - if (meal.description.isNotBlank()) Text(meal.description) - OutlinedButton(onClick = { onEdit(meal) }) { Text("Edit") } + dayRows.sortedByDescending { it.time }.forEach { meal -> + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = meal.id in selected, + onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id } + ) + Column(Modifier.weight(1f)) { + Text(meal.estimate.mealName, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium) + Text( + "${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "P ${meal.estimate.proteinGrams}g · C ${meal.estimate.carbsGrams}g · F ${meal.estimate.fatGrams}g", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + TextButton(onClick = { onEdit(meal) }) { Text("Edit") } } } } } - if (rows.isEmpty()) Text("No meals match the current filters.") + + if (rows.isEmpty()) { + Text( + if (query.isBlank()) "No meals logged yet." else "No meals match your search.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } @Composable -fun TrashScreen(trash: List, onRestore: (String) -> Unit) = Page { - Text("Trash", style = MaterialTheme.typography.headlineMedium) - if (trash.isEmpty()) Text("Trash is empty.") +private fun TrashContent( + trash: List, + onRestore: (String) -> Unit, + onClearTrash: () -> Unit +) = Page { + var showConfirm by remember { mutableStateOf(false) } + + if (showConfirm) { + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text("Clear trash?") }, + text = { Text("This will permanently delete all ${trash.size} items. This cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { onClearTrash(); showConfirm = false }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { Text("Clear all") } + }, + dismissButton = { + TextButton(onClick = { showConfirm = false }) { Text("Cancel") } + } + ) + } + + if (trash.isEmpty()) { + Text("Trash is empty.", color = MaterialTheme.colorScheme.onSurfaceVariant) + return@Page + } + + Button( + onClick = { showConfirm = true }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth() + ) { Text("Clear all trash (${trash.size})") } + trash.forEach { meal -> - SectionCard(meal.estimate.mealName) { - Text("${meal.date} ${meal.time} · ${meal.estimate.calories} kcal") - OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") } + Card(modifier = Modifier.fillMaxWidth()) { + Row(Modifier.padding(16.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(meal.estimate.mealName, fontWeight = FontWeight.SemiBold) + Text( + "${meal.date} · ${meal.mealType} · ${meal.estimate.calories} kcal", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") } + } } } } diff --git a/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt index efe2c9c..d32df2d 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt @@ -11,6 +11,8 @@ import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other") + @Composable fun LogMealScreen( editing: MealEntry?, @@ -30,43 +32,62 @@ fun LogMealScreen( var measure by remember(editing?.id) { mutableStateOf(editing?.measure ?: "") } var estimate by remember(editing?.id) { mutableStateOf(editing?.estimate ?: NutritionEstimate()) } - Text(if (editing == null) "Log meal" else "Edit meal", style = MaterialTheme.typography.headlineMedium) SectionCard("Meal details") { - Field("Date", date, { date = it }) - Field("Time", time, { time = it }) - Field("Meal type", mealType, { mealType = it }) - Field("Description", description, { description = it }, singleLine = false) - Field("Portion or measure", measure, { measure = it }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field("Date", date, { date = it }, modifier = Modifier.weight(1f), placeholder = "YYYY-MM-DD") + Field("Time", time, { time = it }, modifier = Modifier.weight(1f), placeholder = "HH:MM") + } + DropdownField("Meal type", MEAL_TYPES, mealType, { mealType = it }) + Field("Description", description, { description = it }, singleLine = false, placeholder = "e.g. grilled salmon, rice, broccoli") + Field("Portion or measure", measure, { measure = it }, placeholder = "e.g. one plate, 450g") Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onTakePhoto, modifier = Modifier.weight(1f)) { Text("Take photo") } OutlinedButton(onClick = onPickImage, modifier = Modifier.weight(1f)) { Text("Choose image") } } - if (selectedImageName.isNotBlank()) Text("Selected: $selectedImageName") + if (selectedImageName.isNotBlank()) { + Text("Image: $selectedImageName", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } Button( enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()), - onClick = { onAnalyze(draft(editing, date, time, mealType, description, measure, estimate)) }, + onClick = { onAnalyze(buildDraft(editing, date, time, mealType, description, measure, estimate)) }, modifier = Modifier.fillMaxWidth() ) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") } - if (status.isNotBlank()) Text(status, color = MaterialTheme.colorScheme.primary) + if (status.isNotBlank()) { + Text( + status, + style = MaterialTheme.typography.bodySmall, + color = if (status.startsWith("Analysis failed") || status.startsWith("Set API")) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + } } + if (editing != null) { - SectionCard("Editable estimate") { + SectionCard("Edit estimate") { Field("Meal name", estimate.mealName, { estimate = estimate.copy(mealName = it) }) - Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) }) - Field("Protein grams", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) }) - Field("Carbs grams", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) }) - Field("Fat grams", estimate.fatGrams.toString(), { estimate = estimate.copy(fatGrams = it.toIntOrNull() ?: 0) }) - Field("Fruit servings", estimate.fruitServings.toString(), { estimate = estimate.copy(fruitServings = it.toDoubleOrNull() ?: 0.0) }) - Field("Vegetable servings", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f)) + Field("Protein g", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field("Carbs g", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f)) + Field("Fat g", estimate.fatGrams.toString(), { estimate = estimate.copy(fatGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field("Fruit srv", estimate.fruitServings.toString(), { estimate = estimate.copy(fruitServings = it.toDoubleOrNull() ?: 0.0) }, modifier = Modifier.weight(1f)) + Field("Veg srv", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) }, modifier = Modifier.weight(1f)) + } Field("Food groups", estimate.foodGroups, { estimate = estimate.copy(foodGroups = it) }) Field("Notes", estimate.notes, { estimate = estimate.copy(notes = it) }, singleLine = false) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { onSaveManualEdit(draft(editing, date, time, mealType, description, measure, estimate)) }) { Text("Save edits") } - OutlinedButton(onClick = onCancelEdit) { Text("Cancel") } + Button( + onClick = { onSaveManualEdit(buildDraft(editing, date, time, mealType, description, measure, estimate)) }, + modifier = Modifier.weight(1f) + ) { Text("Save edits") } + OutlinedButton(onClick = onCancelEdit, modifier = Modifier.weight(1f)) { Text("Cancel") } } } } } -private fun draft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) = +private fun buildDraft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) = MealEntry(editing?.id.orEmpty(), date, time, mealType, description, measure, editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate) diff --git a/app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt new file mode 100644 index 0000000..670daee --- /dev/null +++ b/app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt @@ -0,0 +1,102 @@ +package com.danvics.calorieai.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.danvics.calorieai.data.PlansState +import com.danvics.calorieai.data.ServerSettings + +@Composable +fun PlansScreen( + settings: ServerSettings, + plansState: PlansState, + planStatus: String, + planBusy: Boolean, + onGenerate: () -> Unit, + onTune: (planId: String, note: String) -> Unit, + onSelect: (String) -> Unit, + onDelete: (String) -> Unit +) = Page { + val selectedPlan = plansState.plans.find { it.id == plansState.selectedPlanId } ?: plansState.plans.firstOrNull() + var tuneNote by remember { mutableStateOf("") } + + SectionCard("Generate plan") { + Text( + "Your plan is built from your profile and recent meals, then updated as you log and progress.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + enabled = !planBusy && settings.heightCm.isNotBlank() && settings.weightKg.isNotBlank(), + onClick = onGenerate, + modifier = Modifier.fillMaxWidth() + ) { Text(if (planBusy) "Generating..." else "Generate new plan") } + if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) { + Text( + "Fill in your height and weight in Settings first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + if (planStatus.isNotBlank()) { + Text(planStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } + } + + if (selectedPlan != null) { + SectionCard("${selectedPlan.title}") { + Text( + "Version ${selectedPlan.version} · ${selectedPlan.createdAt.take(10)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Text(selectedPlan.content, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(12.dp)) + } + Field("Update notes", tuneNote, { tuneNote = it }, singleLine = false, placeholder = "e.g. walked 7k steps daily, felt hungry at night, lost 0.5 kg") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = !planBusy && tuneNote.isNotBlank(), + onClick = { onTune(selectedPlan.id, tuneNote); tuneNote = "" }, + modifier = Modifier.weight(1f) + ) { Text(if (planBusy) "Updating..." else "Update plan") } + OutlinedButton( + onClick = { onDelete(selectedPlan.id) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { Text("Delete") } + } + } + } else if (!planBusy) { + SectionCard("No plan yet") { + Text("Generate your first plan to get started.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + if (plansState.plans.size > 1) { + SectionCard("Plan history") { + plansState.plans.forEach { plan -> + OutlinedButton( + onClick = { onSelect(plan.id) }, + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.fillMaxWidth()) { + Text( + plan.title, + fontWeight = if (plan.id == plansState.selectedPlanId) FontWeight.Bold else FontWeight.Normal, + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Version ${plan.version} · ${plan.createdAt.take(10)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt index 1171610..2909902 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt @@ -5,49 +5,43 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.danvics.calorieai.data.AppSettings -import com.danvics.calorieai.data.ModelOption -import kotlinx.coroutines.launch +import com.danvics.calorieai.data.ServerSettings + +private val ACTIVITY_LEVELS = listOf("Low", "Moderate", "High") +private val PACE_OPTIONS = listOf("Gentle", "Steady", "Aggressive") @Composable fun SettingsScreen( - settings: AppSettings, - onSettingsChange: (AppSettings) -> Unit, - onSearchModels: suspend (String) -> List, - onGeneratePlan: suspend () -> String + settings: ServerSettings, + serverUrl: String, + onSettingsChange: (ServerSettings) -> Unit, + onDisconnect: () -> Unit ) = Page { - val scope = rememberCoroutineScope() var draft by remember(settings) { mutableStateOf(settings) } - var modelQuery by remember { mutableStateOf("") } - var discovered by remember { mutableStateOf(emptyList()) } - var plan by remember { mutableStateOf("") } var status by remember { mutableStateOf("") } - Text("Settings", style = MaterialTheme.typography.headlineMedium) - SectionCard("AI connection") { - Field("API base URL", draft.apiBaseUrl, { draft = draft.copy(apiBaseUrl = it) }) - Field("API key", draft.apiKey, { draft = draft.copy(apiKey = it) }) - Field("Image model", draft.visionModel, { draft = draft.copy(visionModel = it) }) - Field("Nutrition and advice model", draft.nutritionModel, { draft = draft.copy(nutritionModel = it) }) - Button(onClick = { onSettingsChange(draft); status = "Settings saved." }, modifier = Modifier.fillMaxWidth()) { Text("Save settings") } - if (status.isNotBlank()) Text(status) - } - SectionCard("Model search") { - Field("Search current provider models", modelQuery, { modelQuery = it }) - Button(onClick = { scope.launch { discovered = onSearchModels(modelQuery) } }, modifier = Modifier.fillMaxWidth()) { Text("Search") } - Column(Modifier.heightIn(max = 260.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - discovered.forEach { model -> - OutlinedButton(onClick = { draft = draft.copy(models = (draft.models + model).distinctBy { it.id }, nutritionModel = model.id) }, modifier = Modifier.fillMaxWidth()) { Text("+ ${model.name}") } - } + SectionCard("Profile") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field("Height (cm)", draft.heightCm, { draft = draft.copy(heightCm = it) }, modifier = Modifier.weight(1f), placeholder = "175") + Field("Weight (kg)", draft.weightKg, { draft = draft.copy(weightKg = it) }, modifier = Modifier.weight(1f), placeholder = "80") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Field("Target weight (kg)", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) }, modifier = Modifier.weight(1f), placeholder = "70") + Field("Daily kcal goal", draft.calorieTarget, { draft = draft.copy(calorieTarget = it) }, modifier = Modifier.weight(1f), placeholder = "2000") + } + DropdownField("Activity level", ACTIVITY_LEVELS, draft.activityLevel, { draft = draft.copy(activityLevel = it) }) + DropdownField("Pace", PACE_OPTIONS, draft.pace, { draft = draft.copy(pace = it) }) + Button(onClick = { onSettingsChange(draft); status = "Profile saved." }, modifier = Modifier.fillMaxWidth()) { Text("Save profile") } + if (status.isNotBlank()) Text(status, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) } - SectionCard("Weight-loss plan") { - Field("Height cm", draft.heightCm, { draft = draft.copy(heightCm = it) }) - Field("Current weight kg", draft.weightKg, { draft = draft.copy(weightKg = it) }) - Field("Target weight kg", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) }) - Field("Activity", draft.activityLevel, { draft = draft.copy(activityLevel = it) }) - Field("Pace", draft.pace, { draft = draft.copy(pace = it) }) - Button(onClick = { onSettingsChange(draft); scope.launch { plan = onGeneratePlan() } }, modifier = Modifier.fillMaxWidth()) { Text("Generate plan") } - if (plan.isNotBlank()) Text(plan) + + 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) + OutlinedButton( + onClick = onDisconnect, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { Text("Disconnect") } } } diff --git a/app/src/main/java/com/danvics/calorieai/ui/Theme.kt b/app/src/main/java/com/danvics/calorieai/ui/Theme.kt index 6de2049..1937e24 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/Theme.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/Theme.kt @@ -1,19 +1,36 @@ package com.danvics.calorieai.ui +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -private val Colors = lightColorScheme( +private val LightColors = lightColorScheme( primary = Color(0xFF2563EB), secondary = Color(0xFF10B981), tertiary = Color(0xFFF59E0B), background = Color(0xFFF8FAFC), - surface = Color.White + surface = Color(0xFFFFFFFF), + surfaceVariant = Color(0xFFF1F5F9), + onSurfaceVariant = Color(0xFF64748B), +) + +private val DarkColors = darkColorScheme( + primary = Color(0xFF60A5FA), + secondary = Color(0xFF34D399), + tertiary = Color(0xFFFBBF24), + background = Color(0xFF0F172A), + surface = Color(0xFF1E293B), + surfaceVariant = Color(0xFF334155), + onSurfaceVariant = Color(0xFF94A3B8), ) @Composable fun CalorieTheme(content: @Composable () -> Unit) { - MaterialTheme(colorScheme = Colors, content = content) + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors, + content = content + ) } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..3b0d2be --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2bc4640 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..639dce3 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #2563EB + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f3bbbfa..cb6d172 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,10 +1,11 @@ diff --git a/web/server.js b/web/server.js index 4e168a5..e4ecf7a 100644 --- a/web/server.js +++ b/web/server.js @@ -12,6 +12,8 @@ const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data'); const authFile = path.join(dataDir, 'auth.json'); const modelsFile = path.join(dataDir, 'models.json'); const plansFile = path.join(dataDir, 'plans.json'); +const settingsFile = path.join(dataDir, 'settings.json'); +const entriesFile = path.join(dataDir, 'entries.json'); const sessionCookieName = 'calorie_ai_session'; const sessionSeconds = 60 * 60 * 24 * 7; let auth; @@ -275,6 +277,114 @@ async function deletePlanRoute(req, res) { send(res, 200, JSON.stringify(config)); } +function loadSettings() { + return readJsonFile(settingsFile, { + visionModel: '', + taskModel: '', + heightCm: '', + weightKg: '', + targetWeightKg: '', + calorieTarget: '', + activityLevel: 'Moderate', + pace: 'Steady', + }); +} + +function saveSettingsData(settings) { + writePrivateJson(settingsFile, { ...settings, updatedAt: new Date().toISOString() }); +} + +async function getSettings(req, res) { + send(res, 200, JSON.stringify(loadSettings())); +} + +async function updateSettings(req, res) { + try { + const payload = JSON.parse(await readBody(req) || '{}'); + const current = loadSettings(); + const allowed = ['visionModel', 'taskModel', 'heightCm', 'weightKg', 'targetWeightKg', 'calorieTarget', 'activityLevel', 'pace']; + const updated = { ...current }; + for (const key of allowed) if (payload[key] !== undefined) updated[key] = payload[key]; + saveSettingsData(updated); + send(res, 200, JSON.stringify(updated)); + } catch (error) { + send(res, 400, JSON.stringify({ error: error.message })); + } +} + +function loadEntriesData() { + const stored = readJsonFile(entriesFile, {}); + return { + entries: Array.isArray(stored.entries) ? stored.entries : [], + trash: Array.isArray(stored.trash) ? stored.trash : [], + }; +} + +function saveEntriesData(data) { + writePrivateJson(entriesFile, { ...data, updatedAt: new Date().toISOString() }); +} + +async function getEntriesRoute(req, res) { + send(res, 200, JSON.stringify(loadEntriesData())); +} + +async function upsertEntryRoute(req, res) { + try { + const payload = JSON.parse(await readBody(req) || '{}'); + if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Entry ID required' })); + const data = loadEntriesData(); + const idx = data.entries.findIndex(e => e.id === payload.id); + if (idx >= 0) data.entries[idx] = { ...data.entries[idx], ...payload }; + else data.entries = [...data.entries, payload]; + saveEntriesData(data); + send(res, 200, JSON.stringify(data)); + } catch (error) { + send(res, 400, JSON.stringify({ error: error.message })); + } +} + +async function deleteEntriesRoute(req, res) { + try { + const payload = JSON.parse(await readBody(req) || '{}'); + const ids = new Set(Array.isArray(payload.ids) ? payload.ids : [payload.id].filter(Boolean)); + const data = loadEntriesData(); + const moving = data.entries.filter(e => ids.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() })); + data.entries = data.entries.filter(e => !ids.has(e.id)); + data.trash = [...moving, ...data.trash]; + saveEntriesData(data); + send(res, 200, JSON.stringify(data)); + } catch (error) { + send(res, 400, JSON.stringify({ error: error.message })); + } +} + +async function restoreEntryRoute(req, res) { + try { + const payload = JSON.parse(await readBody(req) || '{}'); + const data = loadEntriesData(); + const found = data.trash.find(e => e.id === payload.id); + if (!found) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' })); + const { trashedAt, ...restored } = found; + data.entries = [restored, ...data.entries]; + data.trash = data.trash.filter(e => e.id !== payload.id); + saveEntriesData(data); + send(res, 200, JSON.stringify(data)); + } catch (error) { + send(res, 400, JSON.stringify({ error: error.message })); + } +} + +async function clearTrashRoute(req, res) { + try { + const data = loadEntriesData(); + data.trash = []; + saveEntriesData(data); + send(res, 200, JSON.stringify(data)); + } 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), @@ -595,35 +705,6 @@ function logout(req, res) { }); } -async function handleRequest(req, res) { - const url = req.url.split('?')[0]; - const authenticated = isAuthenticated(req); - - if (!authenticated && !publicRequest(req)) { - if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' })); - return redirect(res, '/login'); - } - - if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/'); - if (req.method === 'POST' && url === '/api/login') return login(req, res); - if (req.method === 'POST' && url === '/api/logout') return logout(req, res); - if (req.method === 'POST' && url === '/api/account') return updateAccount(req, res); - if (req.method === 'POST' && url === '/api/account/send-verification') return sendVerification(req, res); - if (req.method === 'POST' && url === '/api/account/verify') return verifyAccount(req, res); - if (req.method === 'POST' && url === '/api/password/change') return changePassword(req, res); - if (req.method === 'POST' && url === '/api/password/reset') return resetPassword(req, res); - if (req.method === 'POST' && url === '/api/password/request-reset') return requestPasswordReset(req, res); - if (req.method === 'POST' && url === '/api/password/confirm-reset') return confirmPasswordReset(req, res); - if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true })); - if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res); - if (req.method === 'POST' && url === '/api/models/search') return searchModels(req, res); - if (req.method === 'POST' && url === '/api/models/add') return addModel(req, res); - if (req.method === 'POST' && url === '/api/models/remove') return removeModel(req, res); - if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res); - if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); - send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8'); -} - async function start() { auth = await loadAuthConfig(); modelConfig = loadModelConfig(); @@ -658,6 +739,13 @@ async function start() { app.post('/api/plans', wrap(createPlan)); app.post('/api/plans/select', wrap(selectPlanRoute)); app.post('/api/plans/delete', wrap(deletePlanRoute)); + app.get('/api/settings', wrap(getSettings)); + app.post('/api/settings', wrap(updateSettings)); + app.get('/api/entries', wrap(getEntriesRoute)); + app.post('/api/entries', wrap(upsertEntryRoute)); + app.post('/api/entries/delete', wrap(deleteEntriesRoute)); + app.post('/api/entries/restore', wrap(restoreEntryRoute)); + app.post('/api/entries/clear-trash', wrap(clearTrashRoute)); app.post('/api/chat', wrap(proxyChat)); app.use((req, res) => { if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); diff --git a/web/src/App.svelte b/web/src/App.svelte index 84e1c83..85ece8d 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,5 +1,6 @@ + + diff --git a/web/src/PlansPage.svelte b/web/src/PlansPage.svelte index 15c6239..88019a1 100644 --- a/web/src/PlansPage.svelte +++ b/web/src/PlansPage.svelte @@ -14,19 +14,35 @@ export let deletePlan; $: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0]; + let faqOpen = null; + + const faqs = [ + { + q: 'How does the AI build my plan?', + a: 'It reads your height, weight, target, activity level, and pace, then looks at your most recent logged meals to understand your actual eating patterns. The result is a calorie range, protein target, and practical habits tailored to you.', + }, + { + q: 'How often should I update my plan?', + a: 'Update it whenever something changes — a new weigh-in, a shift in activity, or after a few weeks of logging. Use the fine-tune box to tell the AI what changed and it will revise the plan without starting from scratch.', + }, + { + q: 'Is this medical or clinical advice?', + a: 'No. This is an AI-generated nutrition estimate based on self-reported data. It is not a substitute for advice from a registered dietitian or doctor. If you have a medical condition, are pregnant, or have a history of disordered eating, consult a qualified health professional first.', + }, + { + q: 'Are my fine-tune notes stored?', + a: 'No. Notes you add in the fine-tune box are sent to the AI to update the plan and then discarded. Only the resulting plan text is saved.', + }, + { + q: 'Can I go back to an older plan?', + a: 'Yes. Every version is kept in Plan history. Click any entry there to make it the active plan and it will appear in the Current plan panel.', + }, + ]; const knownHeadings = new Set([ - 'plan', - 'personalized weight-loss plan', - 'your details', - 'daily calorie range', - 'protein target', - 'healthy habits', - 'weekly review steps', - 'safety notes', - 'getting started', - 'targets', - 'habits', + 'plan', 'personalized weight-loss plan', 'your details', 'daily calorie range', + 'protein target', 'healthy habits', 'weekly review steps', 'safety notes', + 'getting started', 'targets', 'habits', ]); function cleanLine(line) { @@ -61,7 +77,7 @@

Plans

-

Generate and fine-tune weight-loss plans from your profile, meals, and activities. Notes are used only for the update and are not stored.

+

Your personal weight-loss plan, updated as you log meals and progress. Powered by AI, built from your profile.

@@ -73,7 +89,10 @@ - + {#if !settings.heightCm || !settings.weightKg} +

Fill in height and current weight to generate a plan.

+ {/if} + {#if planStatus}

{planStatus}

{/if}
@@ -100,29 +119,51 @@ {/each}
- - + + + +

These notes are sent to the AI to update your plan and are not stored afterwards.

+
{:else} -
No plans yet. Enter your profile and generate your first plan.
+
No plans yet. Fill in your profile and click Generate.
{/if} + {#if plans.length > 1}

Plan history

- {#if plans.length} - {#each plans as plan} - + {/each} +
+
+ {/if} + +
+

Frequently asked questions

+
+ {#each faqs as faq, i} +
+ - {/each} - {:else} -

Old plans will appear here and remain available after updates.

- {/if} + {#if faqOpen === i} +

{faq.a}

+ {/if} +
+ {/each}
diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js index 00938ef..7981007 100644 --- a/web/tests/app.spec.js +++ b/web/tests/app.spec.js @@ -74,9 +74,55 @@ async function mockPlans(page) { }); } +async function mockEntries(page) { + let entries = []; + let trash = []; + await page.route('**/api/entries', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); + } + const entry = route.request().postDataJSON(); + const idx = entries.findIndex(e => e.id === entry.id); + if (idx >= 0) entries[idx] = { ...entries[idx], ...entry }; + else entries = [...entries, entry]; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); + }); + await page.route('**/api/entries/delete', async (route) => { + const { ids } = route.request().postDataJSON(); + const idSet = new Set(ids); + const moving = entries.filter(e => idSet.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() })); + entries = entries.filter(e => !idSet.has(e.id)); + trash = [...moving, ...trash]; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); + }); + await page.route('**/api/entries/restore', async (route) => { + const { id } = route.request().postDataJSON(); + const found = trash.find(e => e.id === id); + if (found) { + const { trashedAt, ...restored } = found; + entries = [restored, ...entries]; + trash = trash.filter(e => e.id !== id); + } + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) }); + }); +} + +async function mockSettings(page) { + let settings = {}; + await page.route('**/api/settings', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) }); + } + settings = { ...settings, ...route.request().postDataJSON() }; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) }); + }); +} + async function login(page) { await mockModels(page); await mockPlans(page); + await mockEntries(page); + await mockSettings(page); await page.goto('/'); await expect(page).toHaveURL(/\/login$/); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();