From 10819cc6f5e92d2d0373d9a2a1649b352b89c6b3 Mon Sep 17 00:00:00 2001 From: aodulov Date: Wed, 19 Nov 2025 07:39:39 +0200 Subject: [PATCH] feat: Initial implementation of GymFlow fitness tracking application with workout, plan, and exercise management, stats, and AI coach features. --- .gitignore | 24 + App.tsx | 177 +++ README.md | 20 + components/AICoach.tsx | 144 ++ components/History.tsx | 304 ++++ components/Login.tsx | 148 ++ components/Navbar.tsx | 82 + components/Plans.tsx | 255 ++++ components/Profile.tsx | 532 +++++++ components/Stats.tsx | 136 ++ components/Tracker.tsx | 581 ++++++++ index.html | 101 ++ index.tsx | 15 + metadata.json | 5 + package-lock.json | 2978 +++++++++++++++++++++++++++++++++++++ package.json | 24 + services/auth.ts | 127 ++ services/geminiService.ts | 38 + services/i18n.ts | 279 ++++ services/storage.ts | 137 ++ tsconfig.json | 29 + types.ts | 82 + vite.config.ts | 23 + 23 files changed, 6241 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 README.md create mode 100644 components/AICoach.tsx create mode 100644 components/History.tsx create mode 100644 components/Login.tsx create mode 100644 components/Navbar.tsx create mode 100644 components/Plans.tsx create mode 100644 components/Profile.tsx create mode 100644 components/Stats.tsx create mode 100644 components/Tracker.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 services/auth.ts create mode 100644 services/geminiService.ts create mode 100644 services/i18n.ts create mode 100644 services/storage.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..0be9b96 --- /dev/null +++ b/App.tsx @@ -0,0 +1,177 @@ + +import React, { useState, useEffect } from 'react'; +import Navbar from './components/Navbar'; +import Tracker from './components/Tracker'; +import History from './components/History'; +import Stats from './components/Stats'; +import AICoach from './components/AICoach'; +import Plans from './components/Plans'; +import Login from './components/Login'; +import Profile from './components/Profile'; +import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types'; +import { getSessions, saveSession, deleteSession } from './services/storage'; +import { getCurrentUserProfile } from './services/auth'; +import { getSystemLanguage } from './services/i18n'; + +function App() { + const [currentUser, setCurrentUser] = useState(null); + const [currentTab, setCurrentTab] = useState('TRACK'); + const [language, setLanguage] = useState('en'); + + const [sessions, setSessions] = useState([]); + const [activeSession, setActiveSession] = useState(null); + const [activePlan, setActivePlan] = useState(null); + + useEffect(() => { + // Set initial language + setLanguage(getSystemLanguage()); + }, []); + + useEffect(() => { + if (currentUser) { + setSessions(getSessions(currentUser.id)); + const profile = getCurrentUserProfile(currentUser.id); + if (profile?.language) { + setLanguage(profile.language); + } + } else { + setSessions([]); + } + }, [currentUser]); + + const handleLogin = (user: User) => { + setCurrentUser(user); + setCurrentTab('TRACK'); + }; + + const handleLogout = () => { + setCurrentUser(null); + setActiveSession(null); + setActivePlan(null); + }; + + const handleLanguageChange = (lang: Language) => { + setLanguage(lang); + }; + + const handleStartSession = (plan?: WorkoutPlan) => { + if (!currentUser) return; + + // Get latest weight from profile or default + const profile = getCurrentUserProfile(currentUser.id); + const currentWeight = profile?.weight || 70; + + const newSession: WorkoutSession = { + id: crypto.randomUUID(), + startTime: Date.now(), + userBodyWeight: currentWeight, + sets: [], + planId: plan?.id, + planName: plan?.name + }; + setActivePlan(plan || null); + setActiveSession(newSession); + setCurrentTab('TRACK'); + }; + + const handleEndSession = () => { + if (activeSession && currentUser) { + const finishedSession = { ...activeSession, endTime: Date.now() }; + saveSession(currentUser.id, finishedSession); + setSessions(prev => [finishedSession, ...prev]); + setActiveSession(null); + setActivePlan(null); + } + }; + + const handleAddSet = (set: WorkoutSet) => { + if (activeSession) { + setActiveSession(prev => { + if (!prev) return null; + return { + ...prev, + sets: [...prev.sets, set] + }; + }); + } + }; + + const handleRemoveSetFromActive = (setId: string) => { + if (activeSession) { + setActiveSession(prev => { + if (!prev) return null; + return { + ...prev, + sets: prev.sets.filter(s => s.id !== setId) + }; + }); + } + }; + + const handleUpdateSession = (updatedSession: WorkoutSession) => { + if (!currentUser) return; + saveSession(currentUser.id, updatedSession); + setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s)); + }; + + const handleDeleteSession = (sessionId: string) => { + if (!currentUser) return; + deleteSession(currentUser.id, sessionId); + setSessions(prev => prev.filter(s => s.id !== sessionId)); + }; + + if (!currentUser) { + return ; + } + + return ( +
+ + {/* Desktop Navigation Rail (Left) */} + + + {/* Main Content Area */} +
+
+ {currentTab === 'TRACK' && ( + + )} + {currentTab === 'PLANS' && ( + + )} + {currentTab === 'HISTORY' && ( + + )} + {currentTab === 'STATS' && } + {currentTab === 'AI_COACH' && } + {currentTab === 'PROFILE' && ( + + )} +
+
+ + {/* Mobile Navigation (rendered inside Navbar component, fixed to bottom) */} +
+ ); +} + +export default App; diff --git a/README.md b/README.md new file mode 100644 index 0000000..001f999 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1S85Aj0cTtSgK3yfj_Ziq7_d7hRSxV70Q + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/AICoach.tsx b/components/AICoach.tsx new file mode 100644 index 0000000..f443125 --- /dev/null +++ b/components/AICoach.tsx @@ -0,0 +1,144 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react'; +import { createFitnessChat } from '../services/geminiService'; +import { WorkoutSession, Language } from '../types'; +import { Chat, GenerateContentResponse } from '@google/genai'; +import { t } from '../services/i18n'; + +interface AICoachProps { + history: WorkoutSession[]; + lang: Language; +} + +interface Message { + id: string; + role: 'user' | 'model'; + text: string; +} + +const AICoach: React.FC = ({ history, lang }) => { + const [messages, setMessages] = useState([ + { id: 'intro', role: 'model', text: t('ai_intro', lang) } + ]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const chatSessionRef = useRef(null); + const messagesEndRef = useRef(null); + + useEffect(() => { + try { + const chat = createFitnessChat(history); + if (chat) { + chatSessionRef.current = chat; + } else { + setError(t('ai_error', lang)); + } + } catch (err) { + setError("Failed to initialize AI"); + } + }, [history, lang]); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const handleSend = async () => { + if (!input.trim() || !chatSessionRef.current || loading) return; + + const userMsg: Message = { id: crypto.randomUUID(), role: 'user', text: input }; + setMessages(prev => [...prev, userMsg]); + setInput(''); + setLoading(true); + + try { + const result: GenerateContentResponse = await chatSessionRef.current.sendMessage({ message: userMsg.text }); + const text = result.text; + + const aiMsg: Message = { + id: crypto.randomUUID(), + role: 'model', + text: text || "Error generating response." + }; + setMessages(prev => [...prev, aiMsg]); + } catch (err) { + console.error(err); + setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: 'Connection error.' }]); + } finally { + setLoading(false); + } + }; + + if (error) { + return ( +
+ +

{error}

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +
+

{t('ai_expert', lang)}

+
+ + {/* Messages */} +
+ {messages.map((msg) => ( +
+
+ {msg.text} +
+
+ ))} + {loading && ( +
+
+ + {t('ai_typing', lang)} +
+
+ )} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + /> + +
+
+
+ ); +}; + +export default AICoach; diff --git a/components/History.tsx b/components/History.tsx new file mode 100644 index 0000000..ae97f85 --- /dev/null +++ b/components/History.tsx @@ -0,0 +1,304 @@ + +import React, { useState } from 'react'; +import { Calendar, Clock, TrendingUp, Scale, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react'; +import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types'; +import { t } from '../services/i18n'; + +interface HistoryProps { + sessions: WorkoutSession[]; + onUpdateSession?: (session: WorkoutSession) => void; + onDeleteSession?: (sessionId: string) => void; + lang: Language; +} + +const History: React.FC = ({ sessions, onUpdateSession, onDeleteSession, lang }) => { + const [editingSession, setEditingSession] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const calculateSessionWork = (session: WorkoutSession) => { + const bw = session.userBodyWeight || 70; + return session.sets.reduce((acc, set) => { + let w = 0; + if (set.type === ExerciseType.STRENGTH) { + w = (set.weight || 0) * (set.reps || 0); + } + if (set.type === ExerciseType.BODYWEIGHT) { + const percent = set.bodyWeightPercentage || 100; + const effectiveBw = bw * (percent / 100); + w = (effectiveBw + (set.weight || 0)) * (set.reps || 0); + } + return acc + Math.max(0, w); + }, 0); + }; + + const formatDateForInput = (timestamp: number) => { + const d = new Date(timestamp); + const pad = (n: number) => n < 10 ? '0' + n : n; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + }; + + const parseDateFromInput = (value: string) => { + return new Date(value).getTime(); + }; + + const handleSaveEdit = () => { + if (editingSession && onUpdateSession) { + onUpdateSession(editingSession); + setEditingSession(null); + } + }; + + const handleUpdateSet = (setId: string, field: keyof WorkoutSet, value: number) => { + if (!editingSession) return; + const updatedSets = editingSession.sets.map(s => + s.id === setId ? { ...s, [field]: value } : s + ); + setEditingSession({ ...editingSession, sets: updatedSets }); + }; + + const handleDeleteSet = (setId: string) => { + if (!editingSession) return; + setEditingSession({ + ...editingSession, + sets: editingSession.sets.filter(s => s.id !== setId) + }); + }; + + const handleConfirmDelete = () => { + if (deletingId && onDeleteSession) { + onDeleteSession(deletingId); + setDeletingId(null); + } + } + + if (sessions.length === 0) { + return ( +
+ +

{t('history_empty', lang)}

+
+ ); + } + + return ( +
+
+

{t('tab_history', lang)}

+
+ +
+ {sessions.map((session) => { + const totalWork = calculateSessionWork(session); + + return ( +
+
+
+
+ +
+
+
+ {new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { weekday: 'long', day: 'numeric', month: 'long' })} +
+
+ {new Date(session.startTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + {session.userBodyWeight && {session.userBodyWeight}kg} +
+
+
+ +
+ + +
+
+ +
+ {Array.from(new Set(session.sets.map(s => s.exerciseName))).slice(0, 4).map(exName => { + const sets = session.sets.filter(s => s.exerciseName === exName); + const count = sets.length; + const bestSet = sets[0]; + let detail = ""; + if (bestSet.type === ExerciseType.HIGH_JUMP) detail = `${t('max', lang)}: ${Math.max(...sets.map(s => s.height || 0))}cm`; + else if (bestSet.type === ExerciseType.LONG_JUMP) detail = `${t('max', lang)}: ${Math.max(...sets.map(s => s.distanceMeters || 0))}m`; + else if (bestSet.type === ExerciseType.STRENGTH) detail = `${t('upto', lang)} ${Math.max(...sets.map(s => s.weight || 0))}kg`; + + return ( +
+ {exName} + + {detail && {detail}} + {count} + +
+ ); + })} + {new Set(session.sets.map(s => s.exerciseName)).size > 4 && ( +
+ + ... +
+ )} +
+ +
+
+ {t('sets_count', lang)}: {session.sets.length} + {totalWork > 0 && ( + + + {(totalWork / 1000).toFixed(1)}t + + )} +
+
+ + {t('finished', lang)} +
+
+
+ )})} +
+ + {/* DELETE CONFIRMATION DIALOG (MD3) */} + {deletingId && ( +
+
+

{t('delete_workout', lang)}

+

{t('delete_confirm', lang)}

+
+ + +
+
+
+ )} + + {/* EDIT SESSION FULLSCREEN DIALOG */} + {editingSession && ( +
+
+ +

{t('edit', lang)}

+ +
+ +
+ {/* Meta Info */} +
+
+
+ + setEditingSession({...editingSession, startTime: parseDateFromInput(e.target.value)})} + className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1" + /> +
+
+ + setEditingSession({...editingSession, endTime: parseDateFromInput(e.target.value)})} + className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1" + /> +
+
+
+ + setEditingSession({...editingSession, userBodyWeight: parseFloat(e.target.value)})} + className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1" + /> +
+
+ +
+

{t('sets_count', lang)} ({editingSession.sets.length})

+ {editingSession.sets.map((set, idx) => ( +
+
+
+ {idx + 1} + {set.exerciseName} +
+ +
+ +
+ {(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && ( +
+ + handleUpdateSet(set.id, 'weight', parseFloat(e.target.value))} + /> +
+ )} + {(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.PLYOMETRIC) && ( +
+ + handleUpdateSet(set.id, 'reps', parseFloat(e.target.value))} + /> +
+ )} + {(set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && ( +
+ + handleUpdateSet(set.id, 'bodyWeightPercentage', parseFloat(e.target.value))} + /> +
+ )} + {/* Add other fields similarly styled if needed */} +
+
+ ))} +
+
+
+ )} +
+ ); +}; + +export default History; diff --git a/components/Login.tsx b/components/Login.tsx new file mode 100644 index 0000000..791126f --- /dev/null +++ b/components/Login.tsx @@ -0,0 +1,148 @@ + +import React, { useState } from 'react'; +import { login, changePassword } from '../services/auth'; +import { User, Language } from '../types'; +import { Dumbbell, ArrowRight, Lock, Mail, Globe } from 'lucide-react'; +import { t } from '../services/i18n'; + +interface LoginProps { + onLogin: (user: User) => void; + language: Language; + onLanguageChange: (lang: Language) => void; +} + +const Login: React.FC = ({ onLogin, language, onLanguageChange }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + // Force Password Change State + const [needsChange, setNeedsChange] = useState(false); + const [tempUser, setTempUser] = useState(null); + const [newPassword, setNewPassword] = useState(''); + + const handleLogin = (e: React.FormEvent) => { + e.preventDefault(); + const res = login(email, password); + if (res.success && res.user) { + if (res.user.isFirstLogin) { + setTempUser(res.user); + setNeedsChange(true); + } else { + onLogin(res.user); + } + } else { + setError(res.error || t('login_error', language)); + } + }; + + const handleChangePassword = () => { + if (tempUser && newPassword.length >= 4) { + changePassword(tempUser.id, newPassword); + const updatedUser = { ...tempUser, isFirstLogin: false }; + onLogin(updatedUser); + } else { + setError(t('login_password_short', language)); + } + }; + + if (needsChange) { + return ( +
+
+

{t('change_pass_title', language)}

+

{t('change_pass_desc', language)}

+ +
+
+ + setNewPassword(e.target.value)} + /> +
+ +
+
+
+ ) + } + + return ( +
+ + {/* Language Toggle */} +
+ + +
+ +
+
+ +
+

{t('login_title', language)}

+
+ +
+
+
+
+ + +
+ setEmail(e.target.value)} + className="w-full bg-transparent px-4 pb-3 pt-1 text-lg text-on-surface focus:outline-none" + placeholder="user@gymflow.ai" + /> +
+ +
+
+ + +
+ setPassword(e.target.value)} + className="w-full bg-transparent px-4 pb-3 pt-1 text-lg text-on-surface focus:outline-none" + /> +
+
+ + {error &&
{error}
} + + +
+ +

+ {t('login_contact_admin', language)} +

+
+ ); +}; + +export default Login; diff --git a/components/Navbar.tsx b/components/Navbar.tsx new file mode 100644 index 0000000..13f0abe --- /dev/null +++ b/components/Navbar.tsx @@ -0,0 +1,82 @@ + +import React from 'react'; +import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-react'; +import { TabView, Language } from '../types'; +import { t } from '../services/i18n'; + +interface NavbarProps { + currentTab: TabView; + onTabChange: (tab: TabView) => void; + lang: Language; +} + +const Navbar: React.FC = ({ currentTab, onTabChange, lang }) => { + const navItems = [ + { id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) }, + { id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) }, + { id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) }, + { id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) }, + { id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) }, + { id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) }, + ]; + + return ( + <> + {/* MOBILE: Bottom Navigation Bar (MD3) */} +
+
+ {navItems.map((item) => { + const isActive = currentTab === item.id; + return ( + + ); + })} +
+
+ + {/* DESKTOP: Navigation Rail (MD3) */} +
+
+ {navItems.map((item) => { + const isActive = currentTab === item.id; + return ( + + ); + })} +
+
+ + ); +}; + +export default Navbar; diff --git a/components/Plans.tsx b/components/Plans.tsx new file mode 100644 index 0000000..c314acf --- /dev/null +++ b/components/Plans.tsx @@ -0,0 +1,255 @@ + +import React, { useState, useEffect } from 'react'; +import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale } from 'lucide-react'; +import { WorkoutPlan, ExerciseDef, PlannedSet, Language } from '../types'; +import { getPlans, savePlan, deletePlan, getExercises } from '../services/storage'; +import { t } from '../services/i18n'; + +interface PlansProps { + userId: string; + onStartPlan: (plan: WorkoutPlan) => void; + lang: Language; +} + +const Plans: React.FC = ({ userId, onStartPlan, lang }) => { + const [plans, setPlans] = useState([]); + const [isEditing, setIsEditing] = useState(false); + + const [editId, setEditId] = useState(null); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [steps, setSteps] = useState([]); + + const [availableExercises, setAvailableExercises] = useState([]); + const [showExerciseSelector, setShowExerciseSelector] = useState(false); + + useEffect(() => { + setPlans(getPlans(userId)); + // Filter out archived exercises + setAvailableExercises(getExercises(userId).filter(e => !e.isArchived)); + }, [userId]); + + const handleCreateNew = () => { + setEditId(crypto.randomUUID()); + setName(''); + setDescription(''); + setSteps([]); + setIsEditing(true); + }; + + const handleSave = () => { + if (!name.trim() || !editId) return; + const newPlan: WorkoutPlan = { id: editId, name, description, steps }; + savePlan(userId, newPlan); + setPlans(getPlans(userId)); + setIsEditing(false); + }; + + const handleDelete = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (confirm(t('delete_confirm', lang))) { + deletePlan(userId, id); + setPlans(getPlans(userId)); + } + }; + + const addStep = (ex: ExerciseDef) => { + const newStep: PlannedSet = { + id: crypto.randomUUID(), + exerciseId: ex.id, + exerciseName: ex.name, + exerciseType: ex.type, + isWeighted: false + }; + setSteps([...steps, newStep]); + setShowExerciseSelector(false); + }; + + const toggleWeighted = (stepId: string) => { + setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s)); + }; + + const removeStep = (stepId: string) => { + setSteps(steps.filter(s => s.id !== stepId)); + }; + + const moveStep = (index: number, direction: 'up' | 'down') => { + if (direction === 'up' && index === 0) return; + if (direction === 'down' && index === steps.length - 1) return; + const newSteps = [...steps]; + const targetIndex = direction === 'up' ? index - 1 : index + 1; + [newSteps[index], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[index]]; + setSteps(newSteps); + }; + + if (isEditing) { + return ( +
+
+ +

{t('plan_editor', lang)}

+ +
+ +
+
+ + setName(e.target.value)} + /> +
+ +
+ +