
【コピペで試せる】AIと2時間で食事管理アプリを爆速開発した話
レコーディングダイエット、続いたためしがありますか?僕は、ないです。
健康診断の結果を見るたびに「今日から食事を記録するぞ!」と意気込むものの、自分でアプリを作るのも面倒で、つい後回しに…。そんな僕がずっと夢見ていたのが、「写真を撮るだけで、全部よしなにやってくれるアプリ」でした。
そんな夢みたいなアプリ、GoogleのAI「Gemini」を相棒にしたら、驚くほど短時間で実現できてしまったんです。かかった時間は、なんと、わずか2時間。
これは、面倒くさがりな僕がAIとのペアプログラミングで、自分だけの食事管理アプリを爆速開発した、ちょっと未来な体験談です。
写真を撮るだけ!ズボラ専用「カロリー分析AI」
僕が作ったアプリの使い方は、とにかくシンプル。
スマホで食事の写真を撮る。
「分析」ボタンを押す。

たったこれだけ。すると、Geminiが写真をもとにカロリーや栄養素を計算して、こんなレポートを出してくれます。


これなら、僕でも楽しく続けられそうです。
開発の裏側:AIとのペアプログラミング
「アプリ開発って時間がかかるんじゃ…?」と思いますよね。
驚くことに、このアプリの骨格は、ほとんどGeminiに「こんな感じのアプリが欲しい」と伝えて作ってもらいました。
僕がやったのは、AIが生成したコードの細かい部分を調整したり、機能を追加したりするだけ。アプリの心臓部、つまり栄養を分析してくれる“頭脳”はもちろんGeminiです。
AIとの開発は、まさに「ペアプログラミング」。僕がやりたいことの設計図を伝えると、AIがコードを書き、僕はそれレビューして修正する、という流れです。この「お願い」の部分が、AIアプリ開発の面白いところで、「プロンプト」と呼ばれています。
例えば、実際に使っている「お願い文(プロンプト)」はこんな感じです。
この食事の画像を分析して、以下の情報をJSON形式で返してください:
1. 検出された食材リスト(名前、量、個別カロリー)
2. 合計栄養成分(カロリー、タンパク質、炭水化物、脂質、食物繊維、塩分)
3. 食事内容の詳細分析(簡潔に2-3文で)
4. 栄養バランスや健康面でのアドバイス(3-4文で具体的に)
JSONは以下の形式で返してください:
{
"foods": [
{
"name": "食材名",
"quantity": "分量(例:100g、1個など)",
"calories": 120
}
],
"totalNutrition": {
"calories": 500,
"protein": 25,
"carbs": 60,
"fat": 20,
"fiber": 8,
"sodium": 600
},
"analysis": "この食事についての簡潔な分析コメント(2-3文、Markdown形式可)",
"advice": "栄養バランスや健康面でのアドバイス(3-4文、Markdown形式可)。不足している栄養素や改善点、おすすめの食材追加などを具体的に提案してください。"
}
(後略)
ポイントは、ただ「分析して」と頼むんじゃなくて、「こういう項目を、こういう順番で、こんな形式(JSONっていうらしい)で教えてね」と、APIの仕様書を書くように、具体的に指示を出すこと。
こうすることで、AIは迷わず僕が欲しい情報を正確に返してくれます。これをうまくやるのが「プロンプトエンジニアリング」ってやつで、AIと仕事をする上で一番大事なスキルなんだなと実感しました。
AIは優秀な“相棒”だった
今回、開発の「バディ」にGeminiを選んでみて、本当に驚かされました。
すごいなと思ったのは、ただの画像認識じゃないところ。ラーメンの写真を見せると、ただ「ラーメンですね」で終わらない。「チャーシュー、メンマ、ネギ、麺が入ってますね」と、具材までちゃんと分解して、それぞれのカロリーを推測してくれるんです。写真と言葉を一緒に理解できるから、こんな芸当ができるんですね。まるで、隣に座ってる優秀なアシスタントに「これ、データにしといて」って頼んでる感覚でした。
もちろん、万能ではありません。たまに見当違いなことを言ってくるので、「そうじゃなくて!」とプロンプトを何度も書き直したり。でも、そんな試行錯誤も含めて、AIと二人三脚でものを作るのは、これまでにないスピード感で、めちゃくちゃ楽しかったです。
アイデアさえあれば、誰でも開発者になれる時代
プログラミングは専門家だけのスキル、なんて時代はもう終わりなのかもしれません。
「こんなのあったら便利なのに」というアイデアさえあれば、AIに相談しながら、誰でも自分のためのツールを作れる。今回のアプリ開発で、そんな未来の入り口をちょっとだけ覗けた気がします。
この体験がすごく面白かったので、Googleとnoteがやっているコンテスト「 #AIとやってみた 」に応募してみることにしました。
この記事を読んで、「自分も何か作ってみたいかも」と思ってくれる人が一人でもいたら、嬉しいです。
ぜひ、あなたの食事で試してみてください
ちなみに、僕が作ったこのアプリ、下のコードを全部コピーしてGeminiに貼り付ければ、誰でもすぐに使えます。


ぜひ、今日のランチの写真でも撮って、遊んでみてください!
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>カロリー分析AI - 食事の写真から栄養成分を分析</title>
<meta name="description" content="写真をアップロードするだけで、AIが食事のカロリーや栄養成分を自動分析します。">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* General styling for a smooth experience */
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: -webkit-fill-available;
}
main {
flex-grow: 1;
}
/* Hide elements by default */
.hidden {
display: none !important;
}
/* Lucide icons custom styling */
.lucide {
display: inline-block;
width: 1em;
height: 1em;
stroke-width: 2;
stroke: currentColor;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Custom loader animation */
.loader {
width: 48px;
height: 48px;
border: 5px solid #FFF;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-gray-50">
<!-- Header -->
<header class="bg-gradient-to-r from-emerald-600 to-teal-600 text-white py-4 sm:py-6 shadow-lg flex-shrink-0">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="p-2 bg-white bg-opacity-20 rounded-lg backdrop-blur-sm">
<svg class="lucide sm:w-7 sm:h-7" width="24" height="24" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>
</div>
<h1 class="text-xl sm:text-2xl font-bold">カロリー分析AI</h1>
</div>
<button id="new-analysis-button" class="hidden flex items-center space-x-2 px-4 py-2 bg-white bg-opacity-20 hover:bg-opacity-30 backdrop-blur-sm text-white rounded-lg transition-all duration-200 hover:scale-105 border border-white border-opacity-30 shadow-lg">
<svg class="lucide sm:w-5 sm:h-5" width="16" height="16" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
<span class="text-sm sm:text-base font-medium">新しい分析</span>
</button>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-4xl mx-auto px-4 sm:px-6 py-6 sm:py-8 w-full">
<!-- Initial Loading State -->
<div id="initializer" class="h-full flex items-center justify-center">
<div class="text-center">
<div class="animate-spin w-10 h-10 border-4 border-emerald-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p class="text-lg text-gray-600">サービスを準備中...</p>
</div>
</div>
<!-- Error Message Container -->
<div id="error-container" class="hidden mb-4 sm:mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex flex-col sm:flex-row items-start sm:items-center space-y-3 sm:space-y-0 sm:space-x-3">
<svg class="lucide text-red-600 flex-shrink-0" width="20" height="20" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
<p id="error-message" class="flex-1 min-w-0 text-red-800 text-sm sm:text-base"></p>
<button id="retry-button" class="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors flex-shrink-0">再試行</button>
</div>
<!-- App View: Initial Upload Screen -->
<div id="upload-view" class="hidden space-y-6">
<div class="text-center max-w-2xl mx-auto">
<h2 class="text-lg sm:text-xl font-semibold text-gray-800 flex items-center justify-center mb-4">
<svg class="lucide text-emerald-600 mr-2" width="20" height="20" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
AI による食事分析
</h2>
<p class="text-gray-600 text-sm sm:text-base leading-relaxed mb-6">食事の写真をアップロードするだけで、AI が自動的にカロリーや栄養成分を分析します。健康的な食生活をサポートする、簡単で便利なツールです。</p>
</div>
<div id="image-upload-section" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-8">
<!-- Image upload component will be rendered here by JS -->
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 max-w-2xl mx-auto">
<div class="flex items-start space-x-3">
<svg class="lucide text-amber-600 flex-shrink-0 mt-0.5" width="18" height="18" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<p class="text-amber-800 text-sm sm:text-base leading-relaxed">AI による分析結果は参考値です。正確な栄養情報については専門家にご相談ください。</p>
</div>
</div>
</div>
<!-- App View: Analysis Result Screen -->
<div id="result-view" class="hidden space-y-4 sm:space-y-6">
<!-- Analyzed Image -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h2 class="text-xl sm:text-2xl font-bold text-gray-800 mb-4">分析した画像</h2>
<img id="result-image" src="" alt="分析した食事" class="w-full h-auto max-h-80 object-cover rounded-lg shadow-lg"/>
</div>
<!-- AI Analysis Text -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-3">AI分析結果</h3>
<div id="analysis-text" class="text-sm sm:text-base text-gray-700 leading-relaxed prose prose-sm sm:prose max-w-none"></div>
</div>
<!-- AI Health Advice -->
<div class="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-xl shadow-sm border border-emerald-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-emerald-800 mb-3 flex items-center">
<svg class="lucide mr-2 sm:w-5 sm:h-5" width="18" height="18" viewBox="0 0 24 24"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7z"/></svg>
健康アドバイス
</h3>
<div id="advice-text" class="text-sm sm:text-base text-emerald-700 leading-relaxed prose prose-sm sm:prose max-w-none"></div>
</div>
<!-- Nutrition Details Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<div class="space-y-4 sm:space-y-6">
<!-- Nutrition Card -->
<div id="nutrition-card-container" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"></div>
<!-- Radar Chart -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4 text-center">栄養バランス</h3>
<div class="h-64 sm:h-80"><canvas id="nutrition-chart"></canvas></div>
<p class="mt-4 text-center text-xs sm:text-sm text-gray-600">各栄養素の推奨摂取量に対する割合の目安</p>
</div>
</div>
<!-- Food List -->
<div id="food-list-container" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"></div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-white border-t border-gray-200 py-4 sm:py-6 mt-auto flex-shrink-0">
<div class="max-w-4xl mx-auto px-4 sm:px-6 text-center text-gray-600">
<p class="text-sm sm:text-base">© 2025 カロリー分析AI. All rights reserved.</p>
</div>
</footer>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="p-2 bg-amber-100 rounded-full"><svg class="lucide text-amber-600" width="20" height="20" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg></div>
<h3 id="modal-title" class="text-lg font-semibold text-gray-800"></h3>
</div>
<button id="modal-close-button" class="p-1 hover:bg-gray-100 rounded-full transition-colors"><svg class="lucide text-gray-500" width="20" height="20" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</div>
<p id="modal-message" class="text-gray-600 mb-6 leading-relaxed"></p>
<div class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
<button id="modal-cancel-button" class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"></button>
<button id="modal-confirm-button" class="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"></button>
</div>
</div>
</div>
</div>
<script type="module">
// Firebase SDK imports
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, onSnapshot, setDoc, deleteDoc, serverTimestamp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// --- DOM Elements ---
const DOMElements = {
initializer: document.getElementById('initializer'),
uploadView: document.getElementById('upload-view'),
resultView: document.getElementById('result-view'),
errorContainer: document.getElementById('error-container'),
errorMessage: document.getElementById('error-message'),
retryButton: document.getElementById('retry-button'),
newAnalysisButton: document.getElementById('new-analysis-button'),
imageUploadSection: document.getElementById('image-upload-section'),
resultImage: document.getElementById('result-image'),
analysisText: document.getElementById('analysis-text'),
adviceText: document.getElementById('advice-text'),
nutritionCardContainer: document.getElementById('nutrition-card-container'),
foodListContainer: document.getElementById('food-list-container'),
chartCanvas: document.getElementById('nutrition-chart'),
modal: document.getElementById('confirm-modal'),
modalTitle: document.getElementById('modal-title'),
modalMessage: document.getElementById('modal-message'),
modalConfirmButton: document.getElementById('modal-confirm-button'),
modalCancelButton: document.getElementById('modal-cancel-button'),
modalCloseButton: document.getElementById('modal-close-button'),
};
// --- State ---
let db, auth, userId;
let chartInstance = null;
let currentAnalysis = null;
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
// --- UI Rendering ---
function renderUploader(state = 'idle', previewUrl = null) {
DOMElements.imageUploadSection.innerHTML = '';
const container = document.createElement('div');
if (state === 'reading') {
container.className = 'relative border-2 border-dashed rounded-lg p-6 sm:p-8 flex items-center justify-center h-48 sm:h-64';
container.innerHTML = `
<div class="text-center">
<div class="loader mx-auto mb-2 !border-emerald-500 !border-b-transparent" style="border-top-color: #10B981; border-right-color: #10B981; border-left-color: #10B981; border-bottom-color: transparent;"></div>
<p class="text-sm sm:text-base text-gray-600">画像を読み込み中...</p>
</div>
`;
DOMElements.imageUploadSection.appendChild(container);
return;
}
if (previewUrl) {
const isAnalyzing = state === 'analyzing';
container.className = 'relative group';
container.innerHTML = `
<img src="${previewUrl}" alt="Preview" class="w-full h-48 sm:h-64 object-cover rounded-lg shadow-lg" />
${!isAnalyzing ? `<button id="clear-preview-button" class="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-red-600">
<svg class="lucide" width="16" height="16" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>` : ''}
${isAnalyzing ? `<div class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center rounded-lg">
<div class="text-white text-center">
<div class="loader mx-auto mb-2"></div>
<p class="text-sm sm:text-base">解析中...</p>
</div>
</div>` : ''}
`;
DOMElements.imageUploadSection.appendChild(container);
if (!isAnalyzing) {
document.getElementById('clear-preview-button').addEventListener('click', () => {
renderUploader('idle');
});
}
return;
}
// Default 'idle' state
container.className = 'relative border-2 border-dashed rounded-lg p-6 sm:p-8 transition-all duration-200 border-gray-300 hover:border-emerald-400 hover:bg-gray-50';
container.innerHTML = `
<div class="text-center">
<svg class="lucide text-gray-400 mx-auto mb-4 sm:w-12 sm:h-12" width="40" height="40" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>
<p class="text-base sm:text-lg font-medium text-gray-700 mb-2">食事の写真をアップロード</p>
<p class="text-xs sm:text-sm text-gray-500 mb-6">ドラッグ&ドロップまたはクリックして画像を選択</p>
<label class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors cursor-pointer text-sm sm:text-base">
<svg class="lucide mr-2 sm:w-5 sm:h-5" width="18" height="18" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,5 17,10"/><line x1="12" x2="12" y1="5" y2="15"/></svg>
画像を選択
<input id="file-input" type="file" accept="image/*" class="hidden" />
</label>
</div>
`;
DOMElements.imageUploadSection.appendChild(container);
const fileInput = document.getElementById('file-input');
container.addEventListener('dragenter', (e) => { e.preventDefault(); e.stopPropagation(); container.classList.add('border-emerald-500', 'bg-emerald-50'); });
container.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); container.classList.remove('border-emerald-500', 'bg-emerald-50'); });
container.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
container.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('border-emerald-500', 'bg-emerald-50');
const files = e.dataTransfer.files;
if (files && files[0]) handleFileSelect(files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) handleFileSelect(e.target.files[0]);
});
}
function renderResultView(analysis) {
DOMElements.resultImage.src = analysis.imageUrl;
const renderMarkdown = (text) => text ? text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\*(.*?)\*/g, '<em>$1</em>').replace(/\n/g, '<br />') : '';
DOMElements.analysisText.innerHTML = renderMarkdown(analysis.analysis);
DOMElements.adviceText.innerHTML = renderMarkdown(analysis.advice);
const nutrition = analysis.totalNutrition || {};
const nutritionItems = [
{ label: 'カロリー', value: `${nutrition.calories || 0}kcal`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', color: 'text-orange-600' },
{ label: 'タンパク質', value: `${nutrition.protein || 0}g`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>', color: 'text-red-600' },
{ label: '炭水化物', value: `${nutrition.carbs || 0}g`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><path d="M2 12h20"/><path d="M6 8c0 2-2 4-2 4s2 2 2 4"/><path d="M10 8c0 2-2 4-2 4s2 2 2 4"/><path d="M14 8c0 2-2 4-2 4s2 2 2 4"/><path d="M18 8c0 2-2 4-2 4s2 2 2 4"/></svg>', color: 'text-yellow-600' },
{ label: '脂質', value: `${nutrition.fat || 0}g`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z"/><path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2.04 4.6 4.14 5.93a10.97 10.97 0 0 1 2.83 3.4 10.97 10.97 0 0 1-2.83 3.4C16.04 17.08 14.5 19.18 14 21.68A10.97 10.97 0 0 1 12.56 6.6z"/></svg>', color: 'text-blue-600' },
];
DOMElements.nutritionCardContainer.innerHTML = `
<h3 class="text-lg font-semibold text-gray-800 mb-4">栄養成分</h3>
<div class="grid grid-cols-2 gap-3 sm:gap-4">
${nutritionItems.map(item => `
<div class="flex items-center space-x-2 sm:space-x-3 p-2 sm:p-3 bg-gray-50 rounded-lg">
<div class="${item.color} flex-shrink-0">${item.icon}</div>
<div class="min-w-0 flex-1">
<p class="text-xs sm:text-sm text-gray-600 truncate">${item.label}</p>
<p class="font-semibold text-gray-800 text-sm sm:text-base truncate">${item.value}</p>
</div>
</div>
`).join('')}
</div>
<div class="mt-4 pt-4 border-t border-gray-200">
<div class="flex flex-col sm:flex-row justify-between text-xs sm:text-sm text-gray-600 space-y-1 sm:space-y-0">
<span>食物繊維: ${nutrition.fiber || 0}g</span>
<span>塩分: ${nutrition.sodium || 0}mg</span>
</div>
</div>
`;
const foods = analysis.foods || [];
DOMElements.foodListContainer.innerHTML = `
<h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
<svg class="lucide mr-2 text-emerald-600 sm:w-5 sm:h-5" width="18" height="18" viewBox="0 0 24 24"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3z"/></svg>
検出された食材
</h3>
<div class="space-y-3 max-h-96 overflow-y-auto pr-2">
${foods.length > 0 ? foods.map(food => `
<div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<div class="flex-1 min-w-0 pr-3">
<p class="font-medium text-gray-800 text-sm sm:text-base truncate">${food.name}</p>
<p class="text-xs sm:text-sm text-gray-600 truncate">${food.quantity}</p>
</div>
<div class="text-right flex-shrink-0">
<p class="font-semibold text-emerald-600 text-sm sm:text-base">${food.calories}kcal</p>
</div>
</div>
`).join('') : '<p class="text-gray-500">食材が見つかりませんでした。</p>'}
</div>
`;
renderRadarChart(nutrition);
}
function renderRadarChart(nutrition) {
if (chartInstance) {
chartInstance.destroy();
}
const normalize = (val, max) => Math.min((val / max) * 100, 100);
const data = {
labels: ['カロリー', 'タンパク質', '炭水化物', '脂質', '食物繊維', '塩分'],
datasets: [{
label: '栄養バランス',
data: [
normalize(nutrition.calories, 800),
normalize(nutrition.protein, 60),
normalize(nutrition.carbs, 100),
normalize(nutrition.fat, 50),
normalize(nutrition.fiber, 25),
normalize((nutrition.sodium || 0) / 1000, 8)
],
backgroundColor: 'rgba(5, 150, 105, 0.2)',
borderColor: 'rgb(5, 150, 105)',
pointBackgroundColor: 'rgb(5, 150, 105)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgb(5, 150, 105)'
}]
};
chartInstance = new Chart(DOMElements.chartCanvas, {
type: 'radar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
angleLines: { display: true },
suggestedMin: 0,
suggestedMax: 100,
pointLabels: { font: { size: 12 } },
ticks: { backdropColor: 'transparent' }
}
},
plugins: {
legend: { display: false }
}
}
});
}
function switchView(view) {
DOMElements.uploadView.classList.add('hidden');
DOMElements.resultView.classList.add('hidden');
DOMElements.errorContainer.classList.add('hidden');
DOMElements.initializer.classList.add('hidden');
if (view === 'upload') {
DOMElements.uploadView.classList.remove('hidden');
DOMElements.newAnalysisButton.classList.add('hidden');
renderUploader('idle');
} else if (view === 'result' && currentAnalysis) {
DOMElements.resultView.classList.remove('hidden');
DOMElements.newAnalysisButton.classList.remove('hidden');
renderResultView(currentAnalysis);
} else if (view === 'error') {
DOMElements.errorContainer.classList.remove('hidden');
DOMElements.newAnalysisButton.classList.remove('hidden');
} else if (view === 'loading') {
DOMElements.initializer.classList.remove('hidden');
}
}
function showModal({ title, message, confirmText, cancelText, onConfirm, onCancel }) {
DOMElements.modalTitle.textContent = title;
DOMElements.modalMessage.textContent = message;
DOMElements.modalConfirmButton.textContent = confirmText;
DOMElements.modalCancelButton.textContent = cancelText;
const newConfirmButton = DOMElements.modalConfirmButton.cloneNode(true);
DOMElements.modalConfirmButton.parentNode.replaceChild(newConfirmButton, DOMElements.modalConfirmButton);
DOMElements.modalConfirmButton = newConfirmButton;
const newCancelButton = DOMElements.modalCancelButton.cloneNode(true);
DOMElements.modalCancelButton.parentNode.replaceChild(newCancelButton, DOMElements.modalCancelButton);
DOMElements.modalCancelButton = newCancelButton;
const newCloseButton = DOMElements.modalCloseButton.cloneNode(true);
DOMElements.modalCloseButton.parentNode.replaceChild(newCloseButton, DOMElements.modalCloseButton);
DOMElements.modalCloseButton = newCloseButton;
newConfirmButton.addEventListener('click', () => {
onConfirm();
hideModal();
});
const cancelAction = () => {
if(onCancel) onCancel();
hideModal();
};
newCancelButton.addEventListener('click', cancelAction);
newCloseButton.addEventListener('click', cancelAction);
DOMElements.modal.classList.remove('hidden');
}
function hideModal() {
DOMElements.modal.classList.add('hidden');
}
async function convertImageToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = err => reject(err);
reader.readAsDataURL(file);
});
}
async function performAnalysis(file) {
if (!db || !userId) {
DOMElements.errorMessage.textContent = "データベースに接続できません。";
switchView('error');
return;
}
try {
const previewUrl = await convertImageToBase64(file);
renderUploader('analyzing', previewUrl);
const base64Data = previewUrl.split(',')[1];
const apiKey = ""; // Provided by environment
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
const promptText = `この食事の画像を分析して、以下の情報をJSON形式で返してください:
1. 検出された食材リスト(名前、量、個別カロリー)
2. 合計栄養成分(カロリー、タンパク質、炭水化物、脂質、食物繊維、塩分)
3. 食事内容の詳細分析(簡潔に2-3文で)
4. 栄養バランスや健康面でのアドバイス(3-4文で具体的に)
JSONは以下の形式で返してください:
{
"foods": [
{
"name": "食材名",
"quantity": "分量(例:100g、1個など)",
"calories": 120
}
],
"totalNutrition": {
"calories": 500,
"protein": 25,
"carbs": 60,
"fat": 20,
"fiber": 8,
"sodium": 600
},
"analysis": "この食事についての簡潔な分析コメント(2-3文、Markdown形式可)",
"advice": "栄養バランスや健康面でのアドバイス(3-4文、Markdown形式可)。不足している栄養素や改善点、おすすめの食材追加などを具体的に提案してください。"
}
分析コメントとアドバイスは簡潔にし、必要に応じてMarkdown形式(**太字**、*斜体*、リストなど)を使用してください。
アドバイスでは、栄養バランスの改善点、不足している栄養素の補完方法、健康的な食事のための具体的な提案を含めてください。
日本語で回答し、できるだけ正確な栄養情報を提供してください。`;
const payload = {
contents: [{ parts: [ { text: promptText }, { inlineData: { mimeType: file.type, data: base64Data } } ] }],
generationConfig: { responseMimeType: "application/json" }
};
const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!response.ok) throw new Error(`Gemini API error: ${response.status}`);
const result = await response.json();
const textContent = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) throw new Error('APIからの応答が無効です。');
const analysisData = JSON.parse(textContent);
const finalData = {
id: crypto.randomUUID(),
imageUrl: previewUrl,
foods: analysisData.foods || [],
totalNutrition: analysisData.totalNutrition || {},
analysis: analysisData.analysis || "分析結果を取得できませんでした。",
advice: analysisData.advice || "アドバイスを取得できませんでした。",
};
const docRef = doc(db, 'artifacts', appId, 'users', userId, 'analysis', 'latest');
await setDoc(docRef, { ...finalData, createdAt: serverTimestamp() });
} catch (err) {
console.error('Analysis error:', err);
DOMElements.errorMessage.textContent = '画像の分析に失敗しました。もう一度お試しください。';
switchView('error');
}
}
function handleFileSelect(file) {
if (!file) return;
const start = (selectedFile) => {
switchView('upload');
renderUploader('reading');
performAnalysis(selectedFile);
};
if (currentAnalysis) {
showModal({
title: '新しい分析を開始',
message: '現在の分析結果が削除されます。新しい分析を開始しますか?',
confirmText: '開始する',
cancelText: 'キャンセル',
onConfirm: async () => {
await resetAnalysis();
start(file);
},
onCancel: () => {
switchView('result');
}
});
} else {
start(file);
}
}
async function resetAnalysis() {
if (!db || !userId) {
DOMElements.errorMessage.textContent = "データベースに接続できません。リセットできません。";
switchView('error');
return;
}
const docRef = doc(db, 'artifacts', appId, 'users', userId, 'analysis', 'latest');
try {
await deleteDoc(docRef);
} catch (err) {
console.error("Error deleting document:", err);
DOMElements.errorMessage.textContent = 'リセットに失敗しました。';
switchView('error');
}
}
function initialize() {
try {
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
if (!firebaseConfig) throw new Error("Firebase config not available.");
const app = initializeApp(firebaseConfig);
auth = getAuth(app);
db = getFirestore(app);
DOMElements.newAnalysisButton.addEventListener('click', () => {
showModal({
title: '分析結果をリセット',
message: '現在の分析結果を削除して、新しい画像をアップロードできるようにしますか?',
confirmText: 'リセットする',
cancelText: 'キャンセル',
onConfirm: resetAnalysis
});
});
DOMElements.retryButton.addEventListener('click', resetAnalysis);
onAuthStateChanged(auth, user => {
if (user) {
userId = user.uid;
listenForAnalysis();
} else {
switchView('error');
DOMElements.errorMessage.textContent = "認証に失敗しました。ページをリロードしてください。";
}
});
(async () => {
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
} catch (error) {
console.error("Authentication failed:", error);
DOMElements.errorMessage.textContent = "認証に失敗しました。";
switchView('error');
}
})();
} catch (error) {
console.error("Initialization failed:", error);
DOMElements.errorMessage.textContent = "アプリの初期化に失敗しました。";
switchView('error');
}
}
function listenForAnalysis() {
if (!db || !userId) return;
const docRef = doc(db, 'artifacts', appId, 'users', userId, 'analysis', 'latest');
onSnapshot(docRef, (docSnap) => {
if (docSnap.exists()) {
currentAnalysis = docSnap.data();
switchView('result');
} else {
currentAnalysis = null;
if (chartInstance) chartInstance.destroy();
switchView('upload');
}
}, (err) => {
console.error("Firestore snapshot error:", err);
DOMElements.errorMessage.textContent = "データの読み込みに失敗しました。";
switchView('error');
});
}
document.addEventListener('DOMContentLoaded', initialize);
</script>
</body>
</html>