Provide context clues for user

This commit is contained in:
Chris Smith
2025-02-23 14:14:55 +01:00
parent 27395cc408
commit 5065174a07
9 changed files with 157 additions and 116 deletions

View File

@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
* User view text to be more consistent * User view text to be more consistent
* User meal entry form to allow input of additional metadata and context clues
### Removed ### Removed

View File

@@ -3,6 +3,7 @@
namespace common\components; namespace common\components;
use common\models\Meal; use common\models\Meal;
use common\models\MealForm;
use Exception; use Exception;
use Yii; use Yii;
use yii\helpers\FileHelper; use yii\helpers\FileHelper;
@@ -27,28 +28,19 @@ class GeminiApiComponent extends \yii\base\Component
'format' => Client::FORMAT_JSON 'format' => Client::FORMAT_JSON
], ],
]); ]);
} }
public function mealInquiry($filePath) public function mealInquiry(MealForm $model)
{ {
$data = [ $data = [
"contents" => [ "contents" => [
[
"role" => "user",
"parts" => [
[
"text" => "INSERT_INPUT_HERE"
]
]
],
[ [
"role" => "user", "role" => "user",
"parts" => [ "parts" => [
[ [
"inline_data" => [ "inline_data" => [
"data" => base64_encode(file_get_contents($filePath)), "data" => base64_encode(file_get_contents($model->filepath)),
"mimeType" => FileHelper::getMimeType($filePath) "mimeType" => FileHelper::getMimeType($model->filepath)
] ]
] ]
] ]
@@ -58,7 +50,10 @@ class GeminiApiComponent extends \yii\base\Component
"role" => "user", "role" => "user",
"parts" => [ "parts" => [
[ [
"text" => "Provide a caloric and macro estimate for pictures I provide to you. Try to be as accurate as possible and always calculate the everything you see in the picture. Proivde a 3 or 4 word `food_name`" "text" => "Provide a caloric and macro estimate for pictures I provide to you. Try to be as
accurate as possible and always calculate the everything you see in the picture. Provide a
3 or 7 word `food_name` with no special characters. If the user provides context pay attention
as it may contain details about the calories or specifics details about the picture."
] ]
] ]
], ],
@@ -102,6 +97,10 @@ class GeminiApiComponent extends \yii\base\Component
] ]
]; ];
$data['contents'][0]['parts'][] = [
'text' => !empty($model->context) ? 'Context: ' . $model->context : 'INSERT_TEXT_HERE'
];
$response = $this->client $response = $this->client
->post([$this->model, 'key' => $this->apiKey]) ->post([$this->model, 'key' => $this->apiKey])
->setData($data) ->setData($data)
@@ -114,6 +113,9 @@ class GeminiApiComponent extends \yii\base\Component
$meal = new Meal(); $meal = new Meal();
$gemini = json_decode($response->getContent(), true); $gemini = json_decode($response->getContent(), true);
$geminiMeal = json_decode($gemini['candidates'][0]['content']['parts'][0]['text'], true); $geminiMeal = json_decode($gemini['candidates'][0]['content']['parts'][0]['text'], true);
$meal->context = $model->context;
$meal->date = $model->date->format('Y-m-d H:i:s');
$meal->type = $model->type;
$meal->protein = $geminiMeal['protein']; $meal->protein = $geminiMeal['protein'];
$meal->calories = $geminiMeal['calories']; $meal->calories = $geminiMeal['calories'];
$meal->carbohydrates = $geminiMeal['carbohydrates']; $meal->carbohydrates = $geminiMeal['carbohydrates'];
@@ -122,8 +124,10 @@ class GeminiApiComponent extends \yii\base\Component
$meal->food_name = $geminiMeal['food_name']; $meal->food_name = $geminiMeal['food_name'];
// @TODO if moved a job queue then this must be an object otherwise the queue is NOT aware of the user // @TODO if moved a job queue then this must be an object otherwise the queue is NOT aware of the user
$meal->user_id = Yii::$app->user->id; $meal->user_id = Yii::$app->user->id;
$meal->file_name = $filePath; $meal->file_name = $model->filepath;
Yii::debug($meal); Yii::debug($meal);
$meal->validate();
$errors = $meal->getErrors();
$meal->save(); $meal->save();
// @TODO catch unidentified pictures? // @TODO catch unidentified pictures?

View File

@@ -10,7 +10,7 @@ $params = array_merge(
); );
return [ return [
'name' => $params['company_name'] . ' - ' . $params['product_name'], 'name' => $params['product_name'],
'aliases' => [ 'aliases' => [
'@bower' => '@vendor/bower-asset', '@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset', '@npm' => '@vendor/npm-asset',

View File

@@ -2,6 +2,7 @@
namespace common\models; namespace common\models;
use DateTime;
use Yii; use Yii;
use yii\behaviors\TimestampBehavior; use yii\behaviors\TimestampBehavior;
use yii\db\ActiveRecord; use yii\db\ActiveRecord;
@@ -13,6 +14,7 @@ use yii\web\UnauthorizedHttpException;
* @property int $id * @property int $id
* @property string $file_name * @property string $file_name
* @property string $context * @property string $context
* @property DateTime $date
* @property string $food_name * @property string $food_name
* @property string $type * @property string $type
* @property int $calories * @property int $calories
@@ -63,7 +65,6 @@ class Meal extends ActiveRecord
return [ return [
[['date', 'food_name', 'calories', 'protein', 'fat', 'carbohydrates', 'fiber', 'type'], 'required'], [['date', 'food_name', 'calories', 'protein', 'fat', 'carbohydrates', 'fiber', 'type'], 'required'],
[['user_id', 'calories', 'protein', 'fat', 'carbohydrates', 'fiber', 'created_at', 'updated_at'], 'integer'], [['user_id', 'calories', 'protein', 'fat', 'carbohydrates', 'fiber', 'created_at', 'updated_at'], 'integer'],
[['date'], 'date'],
[['type', 'context'], 'string'], [['type', 'context'], 'string'],
[['type'], 'in', 'range' => [self::BREAKFAST, self::LUNCH, self::DINNER, self::OTHER]], [['type'], 'in', 'range' => [self::BREAKFAST, self::LUNCH, self::DINNER, self::OTHER]],
[['file_name'], 'string', 'max' => 255], [['file_name'], 'string', 'max' => 255],

View File

@@ -2,6 +2,8 @@
namespace common\models; namespace common\models;
use DateInterval;
use DateTime;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Yii; use Yii;
use yii\base\Model; use yii\base\Model;
@@ -10,31 +12,85 @@ use yii\web\UploadedFile;
class MealForm extends Model class MealForm extends Model
{ {
public $context;
/** /**
* @var UploadedFile * @var UploadedFile
*/ */
public $picture; public $picture;
public string $filepath; public string $filepath;
public $date;
public int $day = 0;
public $type = Meal::OTHER; // type of meal - default to other
public function rules() { public function init()
{
// @todo get user timezone to determine - should depend on their location not their settings
$hour = (int) (new DateTime())->format('H');
if ($hour >= 6 && $hour < 11) { // Breakfast time
$this->type = Meal::BREAKFAST;
} elseif ($hour >= 11 && $hour < 15) { // Lunch time
$this->type = Meal::LUNCH;
} elseif ($hour >= 15 && $hour < 21) { // Dinner time
$this->type = Meal::DINNER;
}
}
public function rules()
{
return [ return [
[['picture'], 'file', 'skipOnEmpty' => false], [['picture'], 'image', 'skipOnEmpty' => false],
[['picture'], 'required'], [['picture', 'day', 'type'], 'required'],
[['type'], 'string'],
[['day'], 'integer'],
[['context'], 'string', 'length' => [0, 100]],
[['day'], 'in', 'range' => [0, -1, -2, -3, -4]],
[['day'], 'validateCreationDate'],
[['type'], 'in', 'range' => [Meal::BREAKFAST, Meal::LUNCH, Meal::DINNER, Meal::OTHER]],
]; ];
} }
public function newFileName() /**
* How many days to subtract depending on the user selection
*
* @param $attribute
* @param $params
* @param $validator
* @param $current
* @return void
* @throws \DateInvalidOperationException
* @throws \DateMalformedIntervalStringException
*/
public function validateCreationDate($attribute, $params, $validator, $current)
{
$this->date = new DateTime();
$this->date = $this->date->sub(new DateInterval('P' . abs($current) . 'D'));
}
public function newFileName(): void
{ {
$this->filepath = (string)'uploads/' . Yii::$app->user->id . '-' . Uuid::uuid4() . '.' . $this->picture->extension; $this->filepath = (string)'uploads/' . Yii::$app->user->id . '-' . Uuid::uuid4() . '.' . $this->picture->extension;
} }
public function upload() public function getTypeList(): array
{
return [
Meal::BREAKFAST => 'Breakfast',
Meal::LUNCH => 'Lunch',
Meal::DINNER => 'Dinner',
Meal::OTHER => '🤷'
];
}
public function upload(): bool
{ {
if ($this->validate()) { if ($this->validate()) {
$this->newFileName(); $this->newFileName();
$this->picture->saveAs('@frontend/web/'.$this->filepath); $this->picture->saveAs('@frontend/web/' . $this->filepath);
return true; return true;
} else { } else {
$errors = $this->getErrors();
return false; return false;
} }
} }

View File

@@ -24,14 +24,14 @@ $this->params['breadcrumbs'][] = $this->title;
<?= GridView::widget([ <?= GridView::widget([
'dataProvider' => $dataProvider, 'dataProvider' => $dataProvider,
'columns' => [ 'columns' => [
['class' => 'yii\grid\SerialColumn'],
'food_name', 'food_name',
'type',
'calories', 'calories',
'protein', 'protein',
'fat', 'fat',
'carbohydrates', 'carbohydrates',
'fiber', 'fiber',
'created_at:datetime', 'date:date',
[ [
'class' => ActionColumn::class, 'class' => ActionColumn::class,
'urlCreator' => function ($action, Meal $model, $key, $index, $column) { 'urlCreator' => function ($action, Meal $model, $key, $index, $column) {

View File

@@ -1,10 +1,11 @@
<?php <?php
use common\models\Meal;
use yii\helpers\Html; use yii\helpers\Html;
use yii\widgets\ActiveForm; use yii\widgets\ActiveForm;
/** @var yii\web\View $this */ /** @var yii\web\View $this */
/** @var common\models\Meal $model */ /** @var common\models\MealForm $model */
/** @var yii\widgets\ActiveForm $form */ /** @var yii\widgets\ActiveForm $form */
$emoji = [ $emoji = [
@@ -75,6 +76,8 @@ $this->registerJS(
}); });
" "
); );
$this->registerCssFile('@web/css/upload.css');
?> ?>
<div class="meal-form container mt-5"> <div class="meal-form container mt-5">
@@ -83,40 +86,43 @@ $this->registerJS(
<?php <?php
$form = ActiveForm::begin(['options' => ['enctype' => 'multipart/form-data']]); ?> $form = ActiveForm::begin(['options' => ['enctype' => 'multipart/form-data']]); ?>
<div class="mb-3">
<div class="input-group"> <?=$form->field($model, 'context')->textInput([
<input type="text" class="form-control" id="meal-description" placeholder="Add context (optional)" autofocus tabindex="1"> 'class' => 'form-control mb-3',
</div> 'placeholder' => 'Add context (optional)',
</div> 'autofocus',
])->label(false) ?>
<div id="gesture-area" class="gesture-area"> <div id="gesture-area" class="gesture-area">
Tap to take a picture or Long Press to upload a file Tap to take a picture or Long Press to upload a file
<img id="image-preview" src="#" alt="Image Preview" style="display: none; max-width: 100%;"> <img id="image-preview" src="#" alt="Image Preview" style="display: none; max-width: 100%;">
</div> </div>
<?= Html::activeFileInput($model, 'picture', ['style' => 'display: none;', 'id' => 'file-input']) ?> <?= Html::activeFileInput($model, 'picture', ['style' => 'display: none;', 'id' => 'file-input']) ?>
<?= $form->field($model, 'day')->textInput()->label(false); ?>
<input type="file" id="camera-input" accept="image/*" capture="environment" style="display: none;"> <input type="file" id="camera-input" accept="image/*" capture="environment" style="display: none;">
<input type="hidden" id="meal_day" value="Today">
<div id="metadata-fields"> <div id="metadata-fields">
<div class="mb-3"> <div class="mb-3">
<div class="btn-group d-flex justify-content-center" role="group"> <div class="btn-group d-flex justify-content-center" role="group">
<button id="prev-day-btn" class="btn btn-light" type="button">&lt;</button> <button id="prev-day-btn" class="btn btn-light" type="button">&lt;</button>
<button id="current-day-btn" class="btn btn-light" type="button" disabled>Today</button> <button id="current-day-btn" class="btn btn-light" type="button">Today</button>
<button id="next-day-btn" class="btn btn-light" type="button" disabled>&gt;</button> <button id="next-day-btn" class="btn btn-light" type="button" disabled>&gt;</button>
</div> </div>
</div> </div>
<div class="btn-group d-flex justify-content-center" role="group">
<input type="radio" class="btn-check" name="meal_for" id="breakfast" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="breakfast">Breakfast</label>
<input type="radio" class="btn-check" name="meal_for" id="lunch" autocomplete="off"> <?= $form
<label class="btn btn-outline-primary" for="lunch">Lunch</label> ->field($model, 'type')
->radioList($model->getTypeList(), [
<input type="radio" class="btn-check" name="meal_for" id="dinner" autocomplete="off"> 'class' => 'btn-group d-flex justify-content-center',
<label class="btn btn-outline-primary" for="dinner">Dinner</label> 'item' => function ($index, $label, $name, $checked, $value) {
$return = '<input class="btn-check" type="radio" value="'.$value.'" id="'.$value.'" name="' . $name . '" autocomplete="off" ' . ($checked ? "checked" : "") . '>';
<input type="radio" class="btn-check" name="meal_for" id="other" autocomplete="off"> $return .= '<label class="btn btn-outline-primary" for="'.$value.'">' . $label . '</label>';
<label class="btn btn-outline-primary" for="other">🤷</label> return $return;
</div> },
])
->label(false)
; ?>
</div> </div>
<div class="mb-3 form-check mt-3"> <div class="mb-3 form-check mt-3">
@@ -128,33 +134,9 @@ $this->registerJS(
</div> </div>
<?php ActiveForm::end(); ?> <?php
ActiveForm::end(); ?>
</div> </div>
<style>
#upload-title {
font-size: 2rem;
font-weight: bold;
min-height: 50px;
display: inline-block;
transition: color 0.2s ease-in-out;
}
#gesture-area {
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10+ and Edge */
user-select: none; /* Standard syntax */
-webkit-touch-callout: none; /* Prevents default callout on hold in iOS */
}
.gesture-area {
border: 2px dashed #ccc;
padding: 20px;
margin-bottom: 10px;
text-align: center;
}
</style>
<script> <script>
const gestureArea = document.getElementById('gesture-area'); const gestureArea = document.getElementById('gesture-area');
const fileInput = document.getElementById('file-input'); const fileInput = document.getElementById('file-input');
@@ -162,19 +144,10 @@ $this->registerJS(
const imagePreview = document.getElementById('image-preview'); // Get the image element const imagePreview = document.getElementById('image-preview'); // Get the image element
const body = document.body; const body = document.body;
const prevDayBtn = document.getElementById('prev-day-btn');
const nextDayBtn = document.getElementById('next-day-btn');
const currentDayBtn = document.getElementById('current-day-btn'); // Changed to button
const autoUploadCheckbox = document.getElementById('auto-upload-checkbox'); const autoUploadCheckbox = document.getElementById('auto-upload-checkbox');
fileInput.addEventListener('change', (event) => { fileInput.addEventListener('change', (event) => {
const file = event.target.files; const file = event.target.files; // Corrected to access the first file
if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
@@ -182,9 +155,11 @@ $this->registerJS(
imagePreview.style.display = 'block'; imagePreview.style.display = 'block';
} }
if (file) {
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}); });
// Load checkbox state from localStorage // Load checkbox state from localStorage
if (localStorage.getItem('autoUpload') === 'true') { if (localStorage.getItem('autoUpload') === 'true') {
autoUploadCheckbox.checked = true; autoUploadCheckbox.checked = true;
@@ -197,6 +172,12 @@ $this->registerJS(
let currentDate = new Date(); let currentDate = new Date();
let today = new Date(); // Store today's date let today = new Date(); // Store today's date
const prevDayBtn = document.getElementById('prev-day-btn');
const nextDayBtn = document.getElementById('next-day-btn');
const currentDayBtn = document.getElementById('current-day-btn'); // Changed to button
const day = document.getElementById('mealform-day'); // Changed to button
function updateDayDisplay() { function updateDayDisplay() {
const diff = Math.floor((today - currentDate) / (1000 * 60 * 60 * 24)); // Difference in days const diff = Math.floor((today - currentDate) / (1000 * 60 * 60 * 24)); // Difference in days
const options = {weekday: 'long'}; const options = {weekday: 'long'};
@@ -208,7 +189,6 @@ $this->registerJS(
} else { } else {
currentDayBtn.textContent = currentDate.toLocaleDateString(undefined, options); currentDayBtn.textContent = currentDate.toLocaleDateString(undefined, options);
} }
document.getElementById('meal_day').value = currentDayBtn.textContent;
// Disable "next" button if currentDate is today // Disable "next" button if currentDate is today
nextDayBtn.disabled = (currentDate.toDateString() === today.toDateString()); nextDayBtn.disabled = (currentDate.toDateString() === today.toDateString());
@@ -221,11 +201,13 @@ $this->registerJS(
prevDayBtn.addEventListener('click', () => { prevDayBtn.addEventListener('click', () => {
currentDate.setDate(currentDate.getDate() - 1); currentDate.setDate(currentDate.getDate() - 1);
day.value--;
updateDayDisplay(); updateDayDisplay();
}); });
nextDayBtn.addEventListener('click', () => { nextDayBtn.addEventListener('click', () => {
currentDate.setDate(currentDate.getDate() + 1); currentDate.setDate(currentDate.getDate() + 1);
day.value++;
updateDayDisplay(); updateDayDisplay();
}); });
@@ -276,30 +258,5 @@ $this->registerJS(
fileInput.click(); fileInput.click();
}); });
} }
/**
* Setup default buttons
*/
const breakfastBtn = document.getElementById('breakfast');
const lunchBtn = document.getElementById('lunch');
const dinnerBtn = document.getElementById('dinner');
const otherBtn = document.getElementById('other');
// Function to pre-select meal type based on time of day
function preselectMealType() {
const now = new Date();
const hour = now.getHours();
if (hour >= 6 && hour < 11) { // Breakfast time
breakfastBtn.click();
} else if (hour >= 11 && hour < 15) { // Lunch time
lunchBtn.click();
} else if (hour >= 15 && hour < 21) { // Dinner time
dinnerBtn.click();
} else {
otherBtn.click();
}
}
// Call the function to pre-select on page load
preselectMealType();
</script> </script>
</div> </div>

View File

@@ -0,0 +1,22 @@
#upload-title {
font-size: 2rem;
font-weight: bold;
min-height: 50px;
display: inline-block;
transition: color 0.2s ease-in-out;
}
#gesture-area {
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10+ and Edge */
user-select: none; /* Standard syntax */
-webkit-touch-callout: none; /* Prevents default callout on hold in iOS */
}
.gesture-area {
border: 2px dashed #ccc;
padding: 20px;
margin-bottom: 10px;
text-align: center;
}