Compare commits

..
9 Commits
Author SHA1 Message Date
dimitar febcdc111e redesign completed
alot of work ahead
2025-11-26 02:31:46 +01:00
dimitar 73d2c4c1ed claude take :( 2025-11-26 01:57:33 +01:00
dimitar fc12cecd30 db up 2025-11-26 01:34:10 +01:00
dimitar 803c205994 context and goals definition added 2025-11-26 01:30:15 +01:00
dimitar 28b5b52a8f context extended
maybe need polishing
2025-11-26 01:13:11 +01:00
dimitar 684481fd2c db consolidation done 2025-11-26 00:54:27 +01:00
dimitar bf532ba35d deepseek integrated succesfully 2025-11-24 19:25:34 +01:00
dimitar 0896dd46d1 ai rec refinement 2025-11-24 19:08:58 +01:00
dimitar e9ba9e2700 recommendatio edit added 2025-11-24 18:36:04 +01:00
43 changed files with 4793 additions and 848 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# DeepSeek AI API Key
# Get your API key from https://platform.deepseek.com/
DEEPSEEK_API_KEY=your_deepseek_api_key_here
Binary file not shown.
@@ -0,0 +1,49 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '../data/fitai.db');
const db = new Database(dbPath);
console.log('Creating fitness_goals table...');
try {
db.exec(`
CREATE TABLE IF NOT EXISTS fitness_goals (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
fitness_profile_id TEXT,
goal_type TEXT NOT NULL CHECK(goal_type IN ('weight_target', 'strength_milestone', 'endurance_target', 'flexibility_goal', 'habit_building', 'custom')),
title TEXT NOT NULL,
description TEXT,
target_value REAL,
current_value REAL,
unit TEXT,
start_date INTEGER NOT NULL,
target_date INTEGER,
completed_date INTEGER,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'abandoned', 'paused')),
progress REAL DEFAULT 0,
priority TEXT DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high')),
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles(userId) ON DELETE CASCADE
)
`);
// Create indexes for better query performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_fitness_goals_user_id ON fitness_goals(user_id);
CREATE INDEX IF NOT EXISTS idx_fitness_goals_status ON fitness_goals(status);
CREATE INDEX IF NOT EXISTS idx_fitness_goals_profile_id ON fitness_goals(fitness_profile_id);
`);
console.log('✅ fitness_goals table created successfully');
console.log('✅ Indexes created successfully');
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
} finally {
db.close();
}
@@ -1,7 +1,7 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
const dbPath = path.join(__dirname, '../../../data/fitai.db'); const dbPath = path.join(__dirname, '../data/fitai.db');
const db = new Database(dbPath); const db = new Database(dbPath);
console.log('Migrating fitness_profiles table...'); console.log('Migrating fitness_profiles table...');
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { getDatabase } from '@/lib/database';
// POST - Mark goal as complete
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const db = await getDatabase();
// Verify goal exists and user owns it
const existingGoal = await db.getFitnessGoalById(id);
if (!existingGoal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
if (existingGoal.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Mark as completed
const completedGoal = await db.completeGoal(id);
return NextResponse.json(completedGoal);
} catch (error) {
console.error('Error completing fitness goal:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { getDatabase } from '@/lib/database';
// GET - Get specific goal
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const db = await getDatabase();
const goal = await db.getFitnessGoalById(id);
if (!goal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
// Verify ownership
if (goal.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(goal);
} catch (error) {
console.error('Error fetching fitness goal:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PUT - Update goal
export async function PUT(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const db = await getDatabase();
// Verify goal exists and user owns it
const existingGoal = await db.getFitnessGoalById(id);
if (!existingGoal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
if (existingGoal.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const updates = await req.json();
// Don't allow changing userId or id
delete updates.userId;
delete updates.id;
delete updates.createdAt;
const updatedGoal = await db.updateFitnessGoal(id, updates);
return NextResponse.json(updatedGoal);
} catch (error) {
console.error('Error updating fitness goal:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// DELETE - Delete goal
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const db = await getDatabase();
// Verify goal exists and user owns it
const existingGoal = await db.getFitnessGoalById(id);
if (!existingGoal) {
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
}
if (existingGoal.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const deleted = await db.deleteFitnessGoal(id);
if (deleted) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: 'Failed to delete goal' }, { status: 500 });
}
} catch (error) {
console.error('Error deleting fitness goal:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { getDatabase } from '@/lib/database';
import { randomBytes } from 'crypto';
// GET - List user's fitness goals
export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const targetUserId = searchParams.get('userId') || userId;
const status = searchParams.get('status'); // active, completed, all
const db = await getDatabase();
// Fetch goals with optional status filter
const goals = await db.getFitnessGoalsByUserId(
targetUserId,
status && status !== 'all' ? status : undefined
);
return NextResponse.json(goals);
} catch (error) {
console.error('Error fetching fitness goals:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// POST - Create new fitness goal
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const {
goalType,
title,
description,
targetValue,
currentValue,
unit,
targetDate,
priority,
notes,
fitnessProfileId
} = body;
// Validation
if (!goalType || !title) {
return NextResponse.json(
{ error: 'goalType and title are required' },
{ status: 400 }
);
}
const db = await getDatabase();
// Create the goal
const goal = await db.createFitnessGoal({
id: randomBytes(16).toString('hex'),
userId,
fitnessProfileId: fitnessProfileId || undefined,
goalType,
title,
description: description || undefined,
targetValue: targetValue || undefined,
currentValue: currentValue || 0,
unit: unit || undefined,
startDate: new Date(),
targetDate: targetDate ? new Date(targetDate) : undefined,
status: 'active',
progress: 0,
priority: priority || 'medium',
notes: notes || undefined
});
return NextResponse.json(goal, { status: 201 });
} catch (error) {
console.error('Error creating fitness goal:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
@@ -1,9 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database"; import { getDatabase } from "@/lib/database";
import { buildAIContext } from "@/lib/ai/ai-context";
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { userId } = await req.json(); const { userId, useExternalModel } = await req.json();
if (!userId) { if (!userId) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 }); return NextResponse.json({ error: "User ID is required" }, { status: 400 });
@@ -21,35 +23,106 @@ export async function POST(req: Request) {
); );
} }
// Construct prompt for Ollama // Build AI context with goals and recommendations
const prompt = ` let prompt: string;
You are a professional fitness trainer and nutritionist. try {
Generate a detailed daily recommendation for a user with the following profile: const context = await buildAIContext(userId);
- Height: ${profile.height} cm prompt = buildEnhancedPrompt(context);
- Weight: ${profile.weight} kg console.log('Using enhanced AI context with goals and history');
- Age: ${profile.age} } catch (error) {
- Gender: ${profile.gender} // Fallback to basic prompt if context building fails
- Goal: ${profile.fitnessGoals.join(", ")} console.warn('Failed to build AI context, using basic prompt:', error);
- Activity Level: ${profile.activityLevel} prompt = buildBasicPrompt(profile);
- Medical Conditions: ${profile.medicalConditions || "None"}
- Injuries: ${profile.injuries || "None"}
Please provide the response in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
{
"recommendationText": "General advice and motivation for today.",
"activityPlan": "Detailed workout or activity plan for today.",
"dietPlan": "Detailed meal plan for today."
} }
`;
// Call Ollama let parsedResponse;
if (useExternalModel) {
// Use DeepSeek AI
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
if (!deepseekApiKey) {
return NextResponse.json(
{ error: "DeepSeek API key not configured" },
{ status: 500 }
);
}
console.log("Using DeepSeek AI model...");
const deepseekResponse = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${deepseekApiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content: "You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks."
},
{
role: "user",
content: prompt
}
],
temperature: 0.7,
max_tokens: 1000,
}),
});
if (!deepseekResponse.ok) {
const errorText = await deepseekResponse.text();
console.error("DeepSeek API error:", errorText);
return NextResponse.json(
{ error: "Failed to generate recommendation from DeepSeek AI" },
{ status: 500 }
);
}
const deepseekData = await deepseekResponse.json();
console.log("Raw DeepSeek Response:", deepseekData);
try {
const content = deepseekData.choices[0].message.content;
let cleanResponse = content.trim();
// Remove markdown code blocks if present
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
console.error("Failed to parse DeepSeek response:", deepseekData);
return NextResponse.json(
{ error: "Invalid response format from DeepSeek AI" },
{ status: 500 }
);
}
} else {
// Use local Ollama
console.log("Using local Ollama model...");
const ollamaResponse = await fetch("http://localhost:11434/api/generate", { const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
model: "gemma3:latest", // Make sure this model is pulled model: "gemma3:latest",
prompt: prompt, prompt: prompt,
stream: false, stream: false,
format: "json", format: "json",
@@ -59,17 +132,15 @@ export async function POST(req: Request) {
if (!ollamaResponse.ok) { if (!ollamaResponse.ok) {
console.error("Ollama API error:", await ollamaResponse.text()); console.error("Ollama API error:", await ollamaResponse.text());
return NextResponse.json( return NextResponse.json(
{ error: "Failed to generate recommendation from AI service" }, { error: "Failed to generate recommendation from Ollama" },
{ status: 500 } { status: 500 }
); );
} }
const aiData = await ollamaResponse.json(); const aiData = await ollamaResponse.json();
console.log("Raw AI Response:", aiData.response); console.log("Raw Ollama Response:", aiData.response);
let parsedResponse;
try { try {
// Helper to clean up the response
let cleanResponse = aiData.response.trim(); let cleanResponse = aiData.response.trim();
// Remove markdown code blocks if present // Remove markdown code blocks if present
@@ -89,19 +160,19 @@ export async function POST(req: Request) {
parsedResponse = JSON.parse(cleanResponse); parsedResponse = JSON.parse(cleanResponse);
} catch (e) { } catch (e) {
// Fallback if model doesn't return perfect JSON despite instruction console.error("Failed to parse Ollama response:", aiData.response);
console.error("Failed to parse AI response:", aiData.response);
return NextResponse.json( return NextResponse.json(
{ error: "Invalid response format from AI model" }, { error: "Invalid response format from Ollama" },
{ status: 500 } { status: 500 }
); );
} }
}
// Save to database // Save to database
const recommendation = await db.createRecommendation({ const recommendation = await db.createRecommendation({
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId, userId,
fitnessProfileId: profile.userId, // Using userId as ID for now since it's 1:1 fitnessProfileId: profile.userId,
type: 'ai_plan', type: 'ai_plan',
content: parsedResponse.recommendationText, content: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan, activityPlan: parsedResponse.activityPlan,
@@ -28,16 +28,22 @@ export async function GET(request: Request) {
} }
// Check permissions: Users can view their own, Admins/Trainers can view anyone's // Check permissions: Users can view their own, Admins/Trainers can view anyone's
if (currentUserId !== targetUserId) {
const currentUser = await db.getUserById(currentUserId) const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer' const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (currentUserId !== targetUserId) {
if (!isStaff) { if (!isStaff) {
return new NextResponse('Forbidden', { status: 403 }) return new NextResponse('Forbidden', { status: 403 })
} }
} }
const recommendations = await db.getRecommendationsByUserId(targetUserId) let recommendations = await db.getRecommendationsByUserId(targetUserId)
// Non-staff users should only see approved recommendations
if (!isStaff) {
recommendations = recommendations.filter((rec: any) => rec.status === 'approved')
}
return NextResponse.json(recommendations) return NextResponse.json(recommendations)
} catch (error) { } catch (error) {
console.error('Error fetching recommendations:', error) console.error('Error fetching recommendations:', error)
+66 -2
View File
@@ -26,6 +26,7 @@ export default function RecommendationsPage() {
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]); const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null); const [generating, setGenerating] = useState<string | null>(null);
const [useExternalModel, setUseExternalModel] = useState(false);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -56,7 +57,7 @@ export default function RecommendationsPage() {
const res = await fetch("/api/recommendations/generate", { const res = await fetch("/api/recommendations/generate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }), body: JSON.stringify({ userId, useExternalModel }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -98,6 +99,41 @@ export default function RecommendationsPage() {
} }
}; };
const handleEdit = async (rec: Recommendation) => {
const newContent = prompt("Edit Advice:", rec.content);
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
if (newContent === null || newActivityPlan === null || newDietPlan === null) {
// User cancelled one of the prompts
return;
}
try {
const res = await fetch("/api/recommendations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: rec.id,
content: newContent,
activityPlan: newActivityPlan,
dietPlan: newDietPlan,
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update recommendation: ${errorData.error || 'Unknown error'}`);
} else {
alert("Recommendation updated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error("Error updating recommendation:", error);
alert("Failed to update recommendation.");
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
@@ -108,7 +144,29 @@ export default function RecommendationsPage() {
return ( return (
<div className="container mx-auto py-10 px-4"> <div className="container mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-8">AI Recommendations</h1> <div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">AI Recommendations</h1>
{/* Model Selection Toggle */}
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
<span className="text-sm font-medium text-gray-700">
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
</span>
<button
onClick={() => setUseExternalModel(!useExternalModel)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${useExternalModel ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${useExternalModel ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-xs text-gray-500">
{useExternalModel ? "External" : "Local"}
</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Generate Section */} {/* Generate Section */}
@@ -171,6 +229,12 @@ export default function RecommendationsPage() {
</div> </div>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<button
onClick={() => handleEdit(rec)}
className="flex-1 bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
Edit
</button>
<button <button
onClick={() => handleApprove(rec.id, "approved")} onClick={() => handleApprove(rec.id, "approved")}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700" className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
+50 -13
View File
@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
@@ -9,22 +10,59 @@ import {
CreditCard, CreditCard,
Settings, Settings,
LogOut, LogOut,
Brain Brain,
} from "lucide-react"; } from "lucide-react";
import { UserButton, useUser } from "@clerk/nextjs"; import { UserButton, useUser } from "@clerk/nextjs";
const menuItems = [ interface Recommendation {
{ icon: LayoutDashboard, label: "Dashboard", href: "/" }, id: string;
{ icon: Users, label: "Users", href: "/users" }, status: string;
{ icon: Brain, label: "AI Recommendations", href: "/recommendations" }, }
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
{ icon: CreditCard, label: "Payments", href: "/payments" },
{ icon: Settings, label: "Settings", href: "/settings" },
];
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { user } = useUser(); const { user } = useUser();
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const fetchPending = async () => {
try {
const res = await fetch("/api/recommendations");
if (res.ok) {
const data = await res.json();
const pending = (data.recommendations || []).filter(
(r: Recommendation) => r.status === "pending"
);
setPendingCount(pending.length);
}
} catch (e) {
console.error("Failed to fetch pending recommendations", e);
}
};
fetchPending();
}, []);
const menuItems = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
{ icon: Users, label: "Users", href: "/users" },
{
icon: Brain,
label: (
<span className="flex items-center">
AI Recommendations
{pendingCount > 0 && (
<span className="ml-2 inline-block bg-red-600 text-xs rounded-full px-2 py-0.5">
{pendingCount}
</span>
)}
</span>
),
href: "/recommendations",
},
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
{ icon: CreditCard, label: "Payments", href: "/payments" },
{ icon: Settings, label: "Settings", href: "/settings" },
];
return ( return (
<aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800"> <aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800">
@@ -38,18 +76,17 @@ export function Sidebar() {
{menuItems.map((item) => { {menuItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = pathname === item.href; const isActive = pathname === item.href;
const label = typeof item.label === "string" ? item.label : item.label;
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20" ? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
: "text-slate-400 hover:bg-slate-800 hover:text-white" : "text-slate-400 hover:bg-slate-800 hover:text-white"}`}
}`}
> >
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} /> <Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
<span className="font-medium">{item.label}</span> <span className="font-medium">{label}</span>
</Link> </Link>
); );
})} })}
+88
View File
@@ -0,0 +1,88 @@
import { getDatabase } from '@/lib/database';
import type { FitnessGoal } from '@/lib/database/types';
// Import types from database package
import type {
FitnessProfile as DBFitnessProfile,
Recommendation as DBRecommendation,
} from '@fitai/database';
export interface AIContext {
profile: DBFitnessProfile;
activeGoals: FitnessGoal[];
completedGoals: FitnessGoal[];
recentRecommendations: DBRecommendation[];
progressSummary: {
goalsCompleted: number;
goalsActive: number;
averageProgress: number;
};
}
/**
* Build comprehensive AI context for a user
* Aggregates fitness profile, goals, and recommendation history
*/
export async function buildAIContext(userId: string): Promise<AIContext> {
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
throw new Error(`Fitness profile not found for user ${userId}`);
}
// Fetch all fitness goals
const allGoals = await db.getFitnessGoalsByUserId(userId);
// Separate active and completed goals
const activeGoals = allGoals.filter((g) => g.status === 'active');
const completedGoals = allGoals.filter((g) => g.status === 'completed');
// Get all recommendations
const allRecommendations = await db.getRecommendationsByUserId(userId);
// Filter recent recommendations (last 7 days)
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const recentRecommendations = allRecommendations
.filter((r) => new Date(r.createdAt) > sevenDaysAgo)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5);
// Calculate progress summary
const progressSummary = {
goalsCompleted: completedGoals.length,
goalsActive: activeGoals.length,
averageProgress:
activeGoals.length > 0
? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) / activeGoals.length
: 0,
};
return {
profile,
activeGoals,
completedGoals,
recentRecommendations,
progressSummary,
};
}
/**
* Format goal for display in AI prompt
*/
export function formatGoalForPrompt(goal: FitnessGoal): string {
const progress = goal.progress || 0;
const current = goal.currentValue || 0;
const target = goal.targetValue;
const unit = goal.unit || '';
if (target) {
return `${goal.title}: ${current}/${target} ${unit} (${progress.toFixed(1)}% complete)`;
}
return `${goal.title}: ${progress.toFixed(1)}% complete`;
}
+91
View File
@@ -0,0 +1,91 @@
import type { AIContext } from './ai-context';
import { formatGoalForPrompt } from './ai-context';
/**
* Build enhanced AI prompt with comprehensive user context
* Includes profile, goals, progress, and recommendation history
*/
export function buildEnhancedPrompt(context: AIContext): string {
const { profile, activeGoals, completedGoals, recentRecommendations, progressSummary } = context;
// Build goals section
const activeGoalsText =
activeGoals.length > 0
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join('\n')
: '- No active goals set';
const completedGoalsText =
completedGoals.length > 0
? completedGoals.slice(0, 3).map((g) => g.title).join(', ')
: 'None yet';
// Build context about recent recommendations
const recommendationContextText =
recentRecommendations.length > 0
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
: 'This is a new user or they haven\'t received recent recommendations. Provide comprehensive guidance.';
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
## User Profile
- Height: ${profile.height || 'Not specified'} cm
- Weight: ${profile.weight || 'Not specified'} kg
- Age: ${profile.age || 'Not specified'}
- Gender: ${profile.gender || 'Not specified'}
- Primary Goal: ${profile.fitnessGoal || 'General fitness'}
- Activity Level: ${profile.activityLevel || 'Not specified'}
- Medical Conditions: ${profile.medicalConditions || 'None'}
- Allergies: ${profile.allergies || 'None'}
- Injuries: ${profile.injuries || 'None'}
## Active Goals (${activeGoals.length})
${activeGoalsText}
## Recent Progress
- Goals Completed: ${progressSummary.goalsCompleted}
- Active Goals: ${progressSummary.goalsActive}
- Average Progress on Active Goals: ${progressSummary.averageProgress.toFixed(1)}%
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ''}
## Recommendation History
${recommendationContextText}
## Task
Generate a personalized daily recommendation that:
1. Acknowledges their progress on active goals
2. Provides specific, actionable steps toward goal completion
3. Builds on previous recommendations without being repetitive
4. Adapts to their current progress level
5. Takes into account their medical conditions, allergies, and injuries
Respond in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
{
"recommendationText": "Personalized motivation and progress acknowledgment (2-3 sentences)",
"activityPlan": "Specific workout plan aligned with active goals (detailed, 3-5 exercises or activities)",
"dietPlan": "Nutrition plan supporting their goals and restrictions (detailed, include meals and portions)"
}`;
}
/**
* Build basic prompt for users without complete context
* Fallback when goals or other data is missing
*/
export function buildBasicPrompt(profile: any): string {
return `You are a professional fitness trainer and nutritionist.
Generate a detailed daily recommendation for a user with the following profile:
- Height: ${profile.height} cm
- Weight: ${profile.weight} kg
- Age: ${profile.age}
- Gender: ${profile.gender}
- Goal: ${profile.fitnessGoal}
- Activity Level: ${profile.activityLevel}
- Medical Conditions: ${profile.medicalConditions || "None"}
- Injuries: ${profile.injuries || "None"}
Please provide the response in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
{
"recommendationText": "General advice and motivation for today.",
"activityPlan": "Detailed workout or activity plan for today.",
"dietPlan": "Detailed meal plan for today."
}`;
}
+168
View File
@@ -614,6 +614,174 @@ export class SQLiteDatabase implements IDatabase {
} }
} }
// Fitness Goals operations
async createFitnessGoal(goalData: Omit<import('./types').FitnessGoal, 'createdAt' | 'updatedAt'>): Promise<import('./types').FitnessGoal> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const goal: import('./types').FitnessGoal = {
...goalData,
createdAt: now,
updatedAt: now
}
const stmt = this.db.prepare(
`INSERT INTO fitness_goals (
id, user_id, fitness_profile_id, goal_type, title, description,
target_value, current_value, unit, start_date, target_date,
completed_date, status, progress, priority, notes, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
goal.id,
goal.userId,
goal.fitnessProfileId || null,
goal.goalType,
goal.title,
goal.description || null,
goal.targetValue || null,
goal.currentValue || null,
goal.unit || null,
goal.startDate.toISOString(),
goal.targetDate ? goal.targetDate.toISOString() : null,
goal.completedDate ? goal.completedDate.toISOString() : null,
goal.status,
goal.progress,
goal.priority,
goal.notes || null,
goal.createdAt.toISOString(),
goal.updatedAt.toISOString()
)
return goal
}
async getFitnessGoalById(id: string): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM fitness_goals WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToFitnessGoal(row) : null
}
async getFitnessGoalsByUserId(userId: string, status?: string): Promise<import('./types').FitnessGoal[]> {
if (!this.db) throw new Error('Database not connected')
let query = 'SELECT * FROM fitness_goals WHERE user_id = ?'
const params: any[] = [userId]
if (status) {
query += ' AND status = ?'
params.push(status)
}
query += ' ORDER BY created_at DESC'
const stmt = this.db.prepare(query)
const rows = stmt.all(...params)
return rows.map(row => this.mapRowToFitnessGoal(row))
}
async updateFitnessGoal(id: string, updates: Partial<import('./types').FitnessGoal>): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'createdAt')
if (fields.length === 0) return this.getFitnessGoalById(id)
// Map camelCase to snake_case for database columns
const columnMap: Record<string, string> = {
userId: 'user_id',
fitnessProfileId: 'fitness_profile_id',
goalType: 'goal_type',
targetValue: 'target_value',
currentValue: 'current_value',
startDate: 'start_date',
targetDate: 'target_date',
completedDate: 'completed_date',
updatedAt: 'updated_at'
}
const setClause = fields.map(field => `${columnMap[field] || field} = ?`).join(', ')
const values = fields.map(field => {
const val = (updates as any)[field]
return val instanceof Date ? val.toISOString() : val
})
values.push(new Date().toISOString()) // updatedAt
values.push(id)
const stmt = this.db.prepare(`UPDATE fitness_goals SET ${setClause}, updated_at = ? WHERE id = ?`)
stmt.run(values)
return this.getFitnessGoalById(id)
}
async deleteFitnessGoal(id: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM fitness_goals WHERE id = ?')
const result = stmt.run(id)
return (result.changes || 0) > 0
}
async updateGoalProgress(id: string, currentValue: number): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
// Get the goal to calculate progress
const goal = await this.getFitnessGoalById(id)
if (!goal) return null
let progress = goal.progress
if (goal.targetValue && goal.targetValue > 0) {
progress = Math.min(100, (currentValue / goal.targetValue) * 100)
}
const stmt = this.db.prepare(
'UPDATE fitness_goals SET current_value = ?, progress = ?, updated_at = ? WHERE id = ?'
)
stmt.run(currentValue, progress, new Date().toISOString(), id)
return this.getFitnessGoalById(id)
}
async completeGoal(id: string): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const stmt = this.db.prepare(
'UPDATE fitness_goals SET status = ?, progress = ?, completed_date = ?, updated_at = ? WHERE id = ?'
)
stmt.run('completed', 100, now.toISOString(), now.toISOString(), id)
return this.getFitnessGoalById(id)
}
private mapRowToFitnessGoal(row: any): import('./types').FitnessGoal {
return {
id: row.id,
userId: row.user_id,
fitnessProfileId: row.fitness_profile_id,
goalType: row.goal_type,
title: row.title,
description: row.description,
targetValue: row.target_value,
currentValue: row.current_value,
unit: row.unit,
startDate: new Date(row.start_date),
targetDate: row.target_date ? new Date(row.target_date) : undefined,
completedDate: row.completed_date ? new Date(row.completed_date) : undefined,
status: row.status,
progress: row.progress,
priority: row.priority,
notes: row.notes,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at)
}
}
async getDashboardStats(): Promise<{ async getDashboardStats(): Promise<{
totalUsers: number; totalUsers: number;
activeClients: number; activeClients: number;
+35
View File
@@ -61,6 +61,27 @@ export interface Recommendation {
approvedBy?: string; approvedBy?: string;
} }
export interface FitnessGoal {
id: string;
userId: string;
fitnessProfileId?: string;
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom";
title: string;
description?: string;
targetValue?: number;
currentValue?: number;
unit?: string;
startDate: Date;
targetDate?: Date;
completedDate?: Date;
status: "active" | "completed" | "abandoned" | "paused";
progress: number;
priority: "low" | "medium" | "high";
notes?: string;
createdAt: Date;
updatedAt: Date;
}
// Database Interface - allows us to swap implementations // Database Interface - allows us to swap implementations
export interface IDatabase { export interface IDatabase {
// Connection management // Connection management
@@ -121,6 +142,20 @@ export interface IDatabase {
): Promise<Recommendation | null>; ): Promise<Recommendation | null>;
deleteRecommendation(id: string): Promise<boolean>; deleteRecommendation(id: string): Promise<boolean>;
// Fitness Goals operations
createFitnessGoal(
goal: Omit<FitnessGoal, "createdAt" | "updatedAt">
): Promise<FitnessGoal>;
getFitnessGoalById(id: string): Promise<FitnessGoal | null>;
getFitnessGoalsByUserId(userId: string, status?: string): Promise<FitnessGoal[]>;
updateFitnessGoal(
id: string,
updates: Partial<FitnessGoal>
): Promise<FitnessGoal | null>;
deleteFitnessGoal(id: string): Promise<boolean>;
updateGoalProgress(id: string, currentValue: number): Promise<FitnessGoal | null>;
completeGoal(id: string): Promise<FitnessGoal | null>;
// Dashboard operations // Dashboard operations
getDashboardStats(): Promise<{ getDashboardStats(): Promise<{
totalUsers: number; totalUsers: number;
+203
View File
@@ -11,6 +11,7 @@
"@clerk/clerk-expo": "^2.18.3", "@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0", "@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0", "@hookform/resolvers": "^3.3.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-picker/picker": "2.11.1", "@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",
@@ -22,6 +23,8 @@
"expo-constants": "^18.0.10", "expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7", "expo-crypto": "^15.0.7",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0", "expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0", "expo-notifications": "~0.32.0",
"expo-router": "~6.0.14", "expo-router": "~6.0.14",
@@ -34,6 +37,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.0",
"react-native-web": "^0.21.2", "react-native-web": "^0.21.2",
"zod": "^3.22.0" "zod": "^3.22.0"
}, },
@@ -4029,6 +4033,29 @@
} }
} }
}, },
"node_modules/@react-native-community/datetimepicker": {
"version": "8.4.4",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": ">=52.0.0",
"react": "*",
"react-native": "*",
"react-native-windows": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"react-native-windows": {
"optional": true
}
}
},
"node_modules/@react-native-picker/picker": { "node_modules/@react-native-picker/picker": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
@@ -5571,6 +5598,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": { "node_modules/bplist-creator": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -6243,6 +6276,56 @@
"hyphenate-style-name": "^1.0.3" "hyphenate-style-name": "^1.0.3"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -6468,6 +6551,61 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.7", "version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -6555,6 +6693,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -7110,6 +7260,26 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-haptics": {
"version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.7.tgz",
"integrity": "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-linear-gradient": {
"version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",
"integrity": "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-linking": { "node_modules/expo-linking": {
"version": "8.0.9", "version": "8.0.9",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz",
@@ -9842,6 +10012,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -10439,6 +10615,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": { "node_modules/nullthrows": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -11460,6 +11648,21 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-svg": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.0.tgz",
"integrity": "sha512-/Wx6F/IZ88B/GcF88bK8K7ZseJDYt+7WGaiggyzLvTowChQ8BM5idmcd4pK+6QJP6a6DmzL2sfOMukFUn/NArg==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-url-polyfill": { "node_modules/react-native-url-polyfill": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
+4
View File
@@ -17,6 +17,7 @@
"@clerk/clerk-expo": "^2.18.3", "@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0", "@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0", "@hookform/resolvers": "^3.3.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-picker/picker": "2.11.1", "@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",
@@ -28,6 +29,8 @@
"expo-constants": "^18.0.10", "expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7", "expo-crypto": "^15.0.7",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0", "expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0", "expo-notifications": "~0.32.0",
"expo-router": "~6.0.14", "expo-router": "~6.0.14",
@@ -40,6 +43,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.0",
"react-native-web": "^0.21.2", "react-native-web": "^0.21.2",
"zod": "^3.22.0" "zod": "^3.22.0"
}, },
+22 -38
View File
@@ -1,7 +1,7 @@
import { Tabs, useRouter, useSegments } from "expo-router"; import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "@clerk/clerk-expo"; import { useAuth } from "@clerk/clerk-expo";
import { useEffect } from "react"; import { useEffect } from "react";
import { CustomTabBar } from "../../components/CustomTabBar";
export default function TabLayout() { export default function TabLayout() {
const { isSignedIn, isLoaded } = useAuth(); const { isSignedIn, isLoaded } = useAuth();
@@ -25,56 +25,40 @@ export default function TabLayout() {
return ( return (
<Tabs <Tabs
tabBar={(props) => <CustomTabBar {...props} />}
screenOptions={{ screenOptions={{
tabBarActiveTintColor: "#2563eb", headerShown: false, // We'll use custom headers in screens or layout
tabBarInactiveTintColor: "#6b7280", tabBarShowLabel: false,
headerShown: true,
headerStyle: {
backgroundColor: "#fff",
},
headerTitleStyle: {
fontWeight: "600",
},
}} }}
> >
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: "Home", title: "Home",
headerTitle: "FitAI",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="attendance"
options={{
title: "Attendance",
headerTitle: "Attendance",
tabBarIcon: ({ color, size }) => (
<Ionicons name="calendar" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
headerTitle: "My Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="goals" name="goals"
options={{ options={{
title: "Goals", title: "Goals",
headerTitle: "Fitness Goals", }}
tabBarIcon: ({ color, size }) => ( />
<Ionicons name="trophy" size={size} color={color} /> <Tabs.Screen
), name="recommendations"
options={{
title: "AI",
}}
/>
<Tabs.Screen
name="attendance"
options={{
title: "Attendance",
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
}} }}
/> />
</Tabs> </Tabs>
+184 -60
View File
@@ -1,15 +1,39 @@
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native' import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAuth } from '@clerk/clerk-expo' import { useAuth } from '@clerk/clerk-expo'
import { LinearGradient } from 'expo-linear-gradient'
import { Ionicons } from '@expo/vector-icons'
import { attendanceApi, Attendance } from '../../api/attendance' import { attendanceApi, Attendance } from '../../api/attendance'
import { theme } from '../../styles/theme'
import { Animated } from 'react-native'
export default function AttendanceScreen() { export default function AttendanceScreen() {
const { getToken, userId } = useAuth() const { getToken, userId } = useAuth()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null) const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
const [history, setHistory] = useState<Attendance[]>([]) const [history, setHistory] = useState<Attendance[]>([])
const pulseAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
if (activeCheckIn) {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
)
pulse.start()
return () => pulse.stop()
}
}, [activeCheckIn])
const fetchAttendance = async () => { const fetchAttendance = async () => {
try { try {
@@ -70,43 +94,101 @@ export default function AttendanceScreen() {
if (loading && !history.length) { if (loading && !history.length) {
return ( return (
<View style={styles.centered}> <View style={styles.centered}>
<ActivityIndicator size="large" color="#000" /> <ActivityIndicator size="large" color={theme.colors.primary} />
</View> </View>
) )
} }
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<Text style={styles.title}>Attendance</Text> <Text style={styles.title}>Attendance</Text>
<Text style={styles.subtitle}>Track your gym visits</Text> <Text style={styles.subtitle}>Track your gym visits</Text>
</LinearGradient>
<View style={styles.actionContainer}> <View style={styles.actionContainer}>
{activeCheckIn ? ( {activeCheckIn ? (
<View style={styles.activeCard}> <LinearGradient
colors={['rgba(16, 185, 129, 0.15)', 'rgba(5, 150, 105, 0.1)']}
style={[styles.activeCard, theme.shadows.medium]}
>
<View style={styles.activeCardContent}>
<View style={styles.activeIconContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.activeIcon}
>
<Ionicons name="checkmark-circle" size={32} color="#fff" />
</LinearGradient>
</View>
<View style={styles.activeTextContainer}>
<Text style={styles.activeText}>Currently Checked In</Text> <Text style={styles.activeText}>Currently Checked In</Text>
<Text style={styles.timeText}> <Text style={styles.timeText}>
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text> </Text>
<TouchableOpacity style={styles.checkOutButton} onPress={handleCheckOut}>
<Text style={styles.buttonText}>Check Out</Text>
</TouchableOpacity>
</View> </View>
) : ( </View>
<TouchableOpacity style={styles.checkInButton} onPress={handleCheckIn}> <TouchableOpacity onPress={handleCheckOut} activeOpacity={0.8}>
<Text style={styles.buttonText}>Check In</Text> <LinearGradient
colors={theme.gradients.danger}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.checkOutButton, theme.shadows.medium]}
>
<Ionicons name="log-out-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>Check Out</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</LinearGradient>
) : (
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<TouchableOpacity onPress={handleCheckIn} activeOpacity={0.8}>
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.checkInButton, theme.shadows.glow]}
>
<Ionicons name="log-in-outline" size={24} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.checkInButtonText}>Check In</Text>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
)} )}
</View> </View>
<Text style={styles.sectionTitle}>Recent History</Text> <Text style={styles.sectionTitle}>Recent History</Text>
{history.map((item) => ( {history.map((item) => (
<View key={item.id} style={styles.historyItem}> <LinearGradient
key={item.id}
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
style={[styles.historyItem, theme.shadows.medium]}
>
<View style={styles.historyLeft}>
<View style={styles.historyIconContainer}>
<LinearGradient
colors={item.checkOutTime ? theme.gradients.success : theme.gradients.primary}
style={styles.historyIcon}
>
<Ionicons
name={item.checkOutTime ? "checkmark" : "time-outline"}
size={16}
color="#fff"
/>
</LinearGradient>
</View>
<View> <View>
<Text style={styles.dateText}> <Text style={styles.dateText}>
{new Date(item.checkInTime).toLocaleDateString()} {new Date(item.checkInTime).toLocaleDateString()}
</Text> </Text>
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text> <Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
</View> </View>
</View>
<View style={styles.timeContainer}> <View style={styles.timeContainer}>
<Text style={styles.historyTime}> <Text style={styles.historyTime}>
In: {new Date(item.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} In: {new Date(item.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -117,7 +199,7 @@ export default function AttendanceScreen() {
</Text> </Text>
)} )}
</View> </View>
</View> </LinearGradient>
))} ))}
</ScrollView> </ScrollView>
) )
@@ -126,105 +208,147 @@ export default function AttendanceScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: theme.colors.background,
}, },
content: { content: {
padding: 20, paddingBottom: 20,
}, },
centered: { centered: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
header: {
paddingTop: 60,
paddingBottom: 24,
paddingHorizontal: 24,
marginBottom: 24,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
title: { title: {
fontSize: 28, fontSize: theme.typography.fontSize['3xl'],
fontWeight: 'bold', fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
marginBottom: 4, marginBottom: 4,
color: '#1a1a1a',
}, },
subtitle: { subtitle: {
fontSize: 16, fontSize: theme.typography.fontSize.base,
color: '#666', color: 'rgba(255, 255, 255, 0.9)',
marginBottom: 24,
}, },
actionContainer: { actionContainer: {
marginBottom: 32, marginBottom: 32,
paddingHorizontal: 20,
}, },
checkInButton: { checkInButton: {
backgroundColor: '#000', paddingVertical: 20,
paddingVertical: 16, paddingHorizontal: 24,
borderRadius: 12, borderRadius: theme.borderRadius.xl,
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
shadowColor: '#000', justifyContent: 'center',
shadowOffset: { width: 0, height: 2 }, },
shadowOpacity: 0.1, checkInButtonText: {
shadowRadius: 4, color: theme.colors.white,
elevation: 3, fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.semibold,
}, },
checkOutButton: { checkOutButton: {
backgroundColor: '#ff3b30', paddingVertical: 14,
paddingVertical: 16, paddingHorizontal: 20,
borderRadius: 12, borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
marginTop: 16, marginTop: 16,
}, },
buttonText: { buttonText: {
color: '#fff', color: theme.colors.white,
fontSize: 18, fontSize: theme.typography.fontSize.base,
fontWeight: '600', fontWeight: theme.typography.fontWeight.semibold,
}, },
activeCard: { activeCard: {
backgroundColor: '#fff',
padding: 20, padding: 20,
borderRadius: 16, borderRadius: theme.borderRadius.xl,
borderWidth: 1, borderWidth: 1,
borderColor: '#e0e0e0', borderColor: 'rgba(16, 185, 129, 0.2)',
},
activeCardContent: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
activeIconContainer: {
marginRight: 16,
},
activeIcon: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
},
activeTextContainer: {
flex: 1,
}, },
activeText: { activeText: {
fontSize: 18, fontSize: theme.typography.fontSize.lg,
fontWeight: '600', fontWeight: theme.typography.fontWeight.semibold,
color: '#2e7d32', color: theme.colors.gray900,
marginBottom: 4, marginBottom: 4,
}, },
timeText: { timeText: {
fontSize: 14, fontSize: theme.typography.fontSize.sm,
color: '#666', color: theme.colors.gray600,
}, },
sectionTitle: { sectionTitle: {
fontSize: 20, fontSize: theme.typography.fontSize.xl,
fontWeight: '600', fontWeight: theme.typography.fontWeight.semibold,
marginBottom: 16, marginBottom: 16,
color: '#1a1a1a', paddingHorizontal: 20,
color: theme.colors.gray900,
}, },
historyItem: { historyItem: {
backgroundColor: '#fff',
padding: 16, padding: 16,
borderRadius: 12, borderRadius: theme.borderRadius.xl,
marginBottom: 12, marginBottom: 12,
marginHorizontal: 20,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
shadowColor: '#000', borderWidth: 1,
shadowOffset: { width: 0, height: 1 }, borderColor: 'rgba(59, 130, 246, 0.1)',
shadowOpacity: 0.05, },
shadowRadius: 2, historyLeft: {
elevation: 2, flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
historyIconContainer: {
marginRight: 4,
},
historyIcon: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
}, },
dateText: { dateText: {
fontSize: 16, fontSize: theme.typography.fontSize.base,
fontWeight: '500', fontWeight: theme.typography.fontWeight.semibold,
color: '#1a1a1a', color: theme.colors.gray900,
}, },
typeText: { typeText: {
fontSize: 12, fontSize: theme.typography.fontSize.xs,
color: '#666', color: theme.colors.gray600,
marginTop: 2, marginTop: 2,
}, },
timeContainer: { timeContainer: {
alignItems: 'flex-end', alignItems: 'flex-end',
}, },
historyTime: { historyTime: {
fontSize: 14, fontSize: theme.typography.fontSize.sm,
color: '#444', color: theme.colors.gray700,
}, },
}) })
+223 -205
View File
@@ -1,210 +1,251 @@
import { useState, useEffect } from "react"; import React, { useState, useCallback, useRef } from 'react';
import { import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Animated, Alert } from 'react-native';
View, import { Ionicons } from '@expo/vector-icons';
Text, import { LinearGradient } from 'expo-linear-gradient';
StyleSheet, import { theme } from '../../styles/theme';
ScrollView, import { GoalProgressCard } from '../../components/GoalProgressCard';
TouchableOpacity, import { GoalCreationModal } from '../../components/GoalCreationModal';
ActivityIndicator, import { useUser, useAuth } from "@clerk/clerk-expo";
RefreshControl, import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from '../../services/fitnessGoals';
Alert, import { useFocusEffect } from 'expo-router';
} from "react-native";
import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
content: string;
status: "pending" | "completed";
createdAt: string;
}
export default function GoalsScreen() { export default function GoalsScreen() {
const { userId, getToken } = useAuth(); const { user } = useUser();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]); const { getToken } = useAuth();
const [loading, setLoading] = useState(true); const [goals, setGoals] = useState<FitnessGoal[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const fabScale = useRef(new Animated.Value(1)).current;
const fetchRecommendations = async () => { const loadGoals = useCallback(async () => {
if (!userId) return; if (!user?.id) return;
try { try {
const token = await getToken(); const token = await getToken();
const response = await fetch( const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`, setGoals(loadedGoals);
{ } catch (error) {
headers: { console.error('Error loading goals:', error);
Authorization: `Bearer ${token}`,
},
} }
}, [user?.id]); // Removed getToken from dependencies
useFocusEffect(
useCallback(() => {
loadGoals();
}, [loadGoals])
); );
if (response.ok) { const onRefresh = async () => {
const data = await response.json();
setRecommendations(data);
} else {
console.error("Failed to fetch recommendations");
}
} catch (error) {
console.error("Error fetching recommendations:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchRecommendations();
}, [userId]);
const onRefresh = () => {
setRefreshing(true); setRefreshing(true);
fetchRecommendations(); await loadGoals();
setRefreshing(false);
}; };
const toggleStatus = async (id: string, currentStatus: string) => { const handleCreateGoal = async (newGoal: CreateGoalData) => {
const newStatus = currentStatus === "pending" ? "completed" : "pending";
// Optimistic update
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: newStatus } : r)
);
try {
const token = await getToken(); const token = await getToken();
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, { await fitnessGoalsService.createGoal(newGoal, token);
method: "PUT", await loadGoals();
headers: { setIsModalVisible(false);
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
id,
status: newStatus,
}),
});
if (!response.ok) {
// Revert on failure
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
}
} catch (error) {
console.error(error);
// Revert on error
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
}
}; };
const renderSection = ( const handleCompleteGoal = async (goal: FitnessGoal) => {
title: string, const token = await getToken();
type: "short_term" | "medium_term" | "long_term" await fitnessGoalsService.completeGoal(goal.id, token);
) => { await loadGoals();
const items = recommendations.filter((r) => r.type === type);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{items.length === 0 ? (
<Text style={styles.emptyText}>No goals set yet.</Text>
) : (
items.map((rec) => (
<TouchableOpacity
key={rec.id}
style={[
styles.card,
rec.status === "completed" && styles.cardCompleted,
]}
onPress={() => toggleStatus(rec.id, rec.status)}
>
<View style={styles.checkbox}>
{rec.status === "completed" && (
<Ionicons name="checkmark" size={16} color="#fff" />
)}
</View>
<View style={styles.cardContent}>
<Text
style={[
styles.cardText,
rec.status === "completed" && styles.cardTextCompleted,
]}
>
{rec.content}
</Text>
<Text style={styles.dateText}>
{new Date(rec.createdAt).toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
))
)}
</View>
);
}; };
if (loading && !refreshing) { const handleDeleteGoal = async (goalId: string) => {
return ( const token = await getToken();
<View style={styles.center}> await fitnessGoalsService.deleteGoal(goalId, token);
<ActivityIndicator size="large" color="#2563eb" /> await loadGoals();
</View> };
);
} const activeGoals = goals.filter(g => g.status === 'active');
const completedGoals = goals.filter(g => g.status === 'completed');
return ( return (
<View style={styles.container}>
<ScrollView <ScrollView
style={styles.container} contentContainerStyle={styles.scrollContent}
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
} }
> >
<View style={styles.header}> <LinearGradient
<Text style={styles.headerTitle}>My Goals</Text> colors={theme.gradients.primary}
style={styles.header}
>
<View>
<Text style={styles.headerTitle}>Fitness Goals</Text>
<Text style={styles.headerSubtitle}> <Text style={styles.headerSubtitle}>
Track your fitness journey progress Track your fitness journey progress
</Text> </Text>
</View> </View>
</LinearGradient>
{renderSection("Short Term Goals", "short_term")} {/* Stats Summary */}
{renderSection("Medium Term Goals", "medium_term")} {goals.length > 0 && (
{renderSection("Long Term Goals", "long_term")} <View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{activeGoals.length}</Text>
<Text style={styles.statLabel}>Active</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{completedGoals.length}</Text>
<Text style={styles.statLabel}>Completed</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{activeGoals.length > 0
? Math.round(
activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
activeGoals.length
)
: 0}%
</Text>
<Text style={styles.statLabel}>Avg Progress</Text>
</View>
</View>
)}
{/* Active Goals */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Active Goals ({activeGoals.length})
</Text>
{activeGoals.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="flag-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyText}>No active goals yet</Text>
<Text style={styles.emptySubtext}>
Tap the + button to create your first goal
</Text>
</View>
) : (
activeGoals.map((goal) => (
<GoalProgressCard
key={goal.id}
goal={goal}
onComplete={() => handleCompleteGoal(goal)}
onDelete={() => handleDeleteGoal(goal.id)}
/>
))
)}
</View>
{/* Completed Goals */}
{completedGoals.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Completed Goals ({completedGoals.length})
</Text>
{completedGoals.map((goal) => (
<GoalProgressCard
key={goal.id}
goal={goal}
onDelete={() => handleDeleteGoal(goal.id)}
/>
))}
</View>
)}
<View style={styles.footer} /> <View style={styles.footer} />
</ScrollView> </ScrollView>
{/* Floating Action Button */}
<Animated.View style={[styles.fabContainer, { transform: [{ scale: fabScale }] }]}>
<TouchableOpacity
onPress={() => setIsModalVisible(true)}
onPressIn={() => {
Animated.spring(fabScale, {
toValue: 0.9,
friction: 8,
tension: 100,
useNativeDriver: true,
}).start();
}}
onPressOut={() => {
Animated.spring(fabScale, {
toValue: 1,
friction: 8,
tension: 100,
useNativeDriver: true,
}).start();
}}
activeOpacity={0.9}
>
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.fab}
>
<Ionicons name="add" size={28} color="#fff" />
</LinearGradient>
</TouchableOpacity>
</Animated.View>
{/* Create Goal Modal */}
<GoalCreationModal
visible={isModalVisible}
onClose={() => setIsModalVisible(false)}
onSubmit={handleCreateGoal}
/>
</View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#f3f4f6", backgroundColor: theme.colors.background,
}, },
center: { scrollContent: {
flex: 1, paddingBottom: 20,
justifyContent: "center",
alignItems: "center",
}, },
header: { header: {
padding: 20, padding: 24,
backgroundColor: "#fff", paddingTop: 60,
paddingBottom: 24,
marginBottom: 10, marginBottom: 10,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
}, },
headerTitle: { headerTitle: {
fontSize: 28, fontSize: theme.typography.fontSize['3xl'],
fontWeight: "bold", fontWeight: theme.typography.fontWeight.bold,
color: "#111827", color: theme.colors.white,
}, },
headerSubtitle: { headerSubtitle: {
fontSize: 16, fontSize: theme.typography.fontSize.base,
color: "#6b7280", color: "rgba(255, 255, 255, 0.9)",
marginTop: 4, marginTop: 4,
}, },
statsContainer: {
flexDirection: "row",
padding: 16,
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: theme.colors.white,
padding: 16,
borderRadius: theme.borderRadius.xl,
alignItems: 'center',
...theme.shadows.medium,
borderWidth: 1,
borderColor: "rgba(59, 130, 246, 0.1)",
},
statValue: {
fontSize: theme.typography.fontSize['2xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.primary,
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: "#6b7280",
fontWeight: "500",
},
section: { section: {
padding: 20, padding: 20,
paddingTop: 10, paddingTop: 10,
@@ -215,58 +256,35 @@ const styles = StyleSheet.create({
color: "#374151", color: "#374151",
marginBottom: 12, marginBottom: 12,
}, },
emptyText: { emptyState: {
fontStyle: "italic",
color: "#9ca3af",
},
card: {
backgroundColor: "#fff",
padding: 16,
borderRadius: 12,
marginBottom: 12,
flexDirection: "row",
alignItems: "flex-start",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
cardCompleted: {
backgroundColor: "#f0fdf4", // light green
borderColor: "#bbf7d0",
borderWidth: 1,
},
checkbox: {
width: 24,
height: 24,
borderRadius: 6,
borderWidth: 2,
borderColor: "#d1d5db",
marginRight: 12,
marginTop: 2,
justifyContent: "center",
alignItems: "center", alignItems: "center",
backgroundColor: "#fff", paddingVertical: 40,
}, },
cardContent: { emptyText: {
flex: 1,
},
cardText: {
fontSize: 16, fontSize: 16,
color: "#1f2937", fontWeight: "500",
lineHeight: 24, color: "#6b7280",
marginTop: 12,
}, },
cardTextCompleted: { emptySubtext: {
textDecorationLine: "line-through", fontSize: 14,
color: "#9ca3af",
},
dateText: {
fontSize: 12,
color: "#9ca3af", color: "#9ca3af",
marginTop: 4, marginTop: 4,
}, },
footer: { footer: {
height: 40, height: 100,
},
fabContainer: {
position: "absolute",
right: 20,
bottom: 110, // Adjusted for tab bar height
},
fab: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
...theme.shadows.glow,
}, },
}); });
+166 -257
View File
@@ -1,320 +1,229 @@
import React from "react"; import { View, Text, StyleSheet, ScrollView, RefreshControl, Image } from "react-native";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
} from "react-native";
import { useUser } from "@clerk/clerk-expo"; import { useUser } from "@clerk/clerk-expo";
import { LinearGradient } from "expo-linear-gradient";
import { useState, useCallback } from "react";
import { theme } from "../../styles/theme";
import { ActivityWidget } from "../../components/ActivityWidget";
import { QuickActionGrid } from "../../components/QuickActionGrid";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
export default function HomeScreen() { export default function HomeScreen() {
const { user, isLoaded } = useUser(); const { user } = useUser();
const router = useRouter(); const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setRefreshing(false);
}, 2000);
}, []);
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good Morning";
if (hour < 18) return "Good Afternoon";
return "Good Evening";
};
if (!isLoaded || !user) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text>Loading...</Text> <ScrollView
</View> contentContainerStyle={styles.scrollContent}
); refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
} }
>
const firstName = user.firstName || "User"; {/* Header Section */}
const greeting = getGreeting();
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
{/* Welcome Header */}
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.greeting}>{greeting}!</Text> <View>
<Text style={styles.name}>{firstName}</Text> <Text style={styles.greeting}>{getGreeting()},</Text>
<Text style={styles.name}>{user?.firstName || "Athlete"}</Text>
</View>
<View style={styles.avatarContainer}>
{user?.imageUrl ? (
<Image source={{ uri: user.imageUrl }} style={styles.avatar} />
) : (
<View style={styles.placeholderAvatar}>
<Ionicons name="person" size={24} color="#fff" />
</View>
)}
</View>
</View> </View>
{/* Quick Stats */} {/* Activity Widget */}
<View style={styles.statsContainer}> <ActivityWidget
<View style={styles.statCard}> steps={8432}
<Ionicons name="calendar-outline" size={32} color="#2563eb" /> calories={640}
<Text style={styles.statValue}>0</Text> duration={45}
<Text style={styles.statLabel}>This Month</Text> />
</View>
<View style={styles.statCard}>
<Ionicons name="flame-outline" size={32} color="#ef4444" />
<Text style={styles.statValue}>0</Text>
<Text style={styles.statLabel}>Day Streak</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="trophy-outline" size={32} color="#f59e0b" />
<Text style={styles.statValue}>0</Text>
<Text style={styles.statLabel}>Total Visits</Text>
</View>
</View>
{/* Quick Actions */} {/* Quick Actions */}
<QuickActionGrid />
{/* Recent Activity Section */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Actions</Text> <View style={styles.sectionHeader}>
<TouchableOpacity
style={styles.actionButton}
onPress={() => router.push("/fitness-profile")}
>
<View style={styles.actionIcon}>
<Ionicons name="fitness-outline" size={24} color="#ec4899" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>Fitness Profile</Text>
<Text style={styles.actionSubtitle}>
Manage your fitness information
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</TouchableOpacity>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="log-in-outline" size={24} color="#2563eb" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>Check In</Text>
<Text style={styles.actionSubtitle}>
Start your workout session
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="calendar-outline" size={24} color="#10b981" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>View Schedule</Text>
<Text style={styles.actionSubtitle}>
Check your upcoming classes
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="card-outline" size={24} color="#8b5cf6" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>Payments</Text>
<Text style={styles.actionSubtitle}>View payment history</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
</View>
{/* Membership Info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Membership</Text>
<View style={styles.membershipCard}>
<View style={styles.membershipHeader}>
<Text style={styles.membershipType}>Basic Plan</Text>
<View style={styles.statusBadge}>
<Text style={styles.statusText}>Active</Text>
</View>
</View>
<Text style={styles.membershipEmail}>
{user.primaryEmailAddress?.emailAddress}
</Text>
<Text style={styles.membershipDate}>
Member since {new Date(user.createdAt!).toLocaleDateString()}
</Text>
</View>
</View>
{/* Recent Activity */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Recent Activity</Text> <Text style={styles.sectionTitle}>Recent Activity</Text>
<View style={styles.emptyState}> <Text style={styles.seeAll}>See All</Text>
<Ionicons name="barbell-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyStateText}>No recent activity</Text>
<Text style={styles.emptyStateSubtext}>
Check in to start tracking your workouts
</Text>
</View> </View>
</View>
</View>
</ScrollView>
);
}
function getGreeting(): string { <View style={styles.activityCard}>
const hour = new Date().getHours(); <LinearGradient
if (hour < 12) return "Good morning"; colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.5)']}
if (hour < 18) return "Good afternoon"; style={[styles.recentItem, theme.shadows.subtle]}
return "Good evening"; >
<View style={styles.recentIconContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.recentIcon}
>
<Ionicons name="barbell" size={20} color="#fff" />
</LinearGradient>
</View>
<View style={styles.recentInfo}>
<Text style={styles.recentTitle}>Upper Body Power</Text>
<Text style={styles.recentSubtitle}>Today, 10:00 AM</Text>
</View>
<Text style={styles.recentValue}>45m</Text>
</LinearGradient>
<LinearGradient
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.5)']}
style={[styles.recentItem, theme.shadows.subtle]}
>
<View style={styles.recentIconContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.recentIcon}
>
<Ionicons name="bicycle" size={20} color="#fff" />
</LinearGradient>
</View>
<View style={styles.recentInfo}>
<Text style={styles.recentTitle}>Morning Cardio</Text>
<Text style={styles.recentSubtitle}>Yesterday, 7:30 AM</Text>
</View>
<Text style={styles.recentValue}>30m</Text>
</LinearGradient>
</View>
</View>
{/* Bottom Spacer for Tab Bar */}
<View style={{ height: 100 }} />
</ScrollView>
</View>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#f5f5f5", backgroundColor: theme.colors.background,
}, },
content: { scrollContent: {
padding: 20, paddingTop: 60,
}, },
header: { header: {
marginBottom: 24, flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 32,
}, },
greeting: { greeting: {
fontSize: 16, fontSize: 16,
color: "#6b7280", color: theme.colors.gray600,
fontWeight: "500",
marginBottom: 4, marginBottom: 4,
}, },
name: { name: {
fontSize: 32, fontSize: 32,
fontWeight: "bold", fontWeight: "800",
color: "#1a1a1a", color: theme.colors.gray900,
letterSpacing: -0.5,
}, },
statsContainer: { avatarContainer: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 24,
},
statCard: {
flex: 1,
backgroundColor: "white",
borderRadius: 12,
padding: 16,
alignItems: "center",
marginHorizontal: 4,
shadowColor: "#000", shadowColor: "#000",
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05, shadowOpacity: 0.1,
shadowRadius: 2, shadowRadius: 12,
elevation: 2, elevation: 5,
}, },
statValue: { avatar: {
fontSize: 24, width: 56,
fontWeight: "bold", height: 56,
color: "#1a1a1a", borderRadius: 20,
marginTop: 8, borderWidth: 2,
marginBottom: 4, borderColor: "#fff",
}, },
statLabel: { placeholderAvatar: {
fontSize: 12, width: 56,
color: "#6b7280", height: 56,
textAlign: "center", borderRadius: 20,
backgroundColor: theme.colors.primary,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#fff",
}, },
section: { section: {
paddingHorizontal: 20,
marginBottom: 24, marginBottom: 24,
}, },
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
sectionTitle: { sectionTitle: {
fontSize: 18, fontSize: 18,
fontWeight: "600", fontWeight: "700",
color: "#1a1a1a", color: theme.colors.gray900,
marginBottom: 12,
}, },
actionButton: { seeAll: {
fontSize: 14,
color: theme.colors.primary,
fontWeight: "600",
},
activityCard: {
gap: 12,
},
recentItem: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
backgroundColor: "white",
borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 8, borderRadius: 20,
shadowColor: "#000", backgroundColor: "#fff",
shadowOffset: { width: 0, height: 1 }, borderWidth: 1,
shadowOpacity: 0.05, borderColor: "rgba(255, 255, 255, 0.6)",
shadowRadius: 2,
elevation: 2,
}, },
actionIcon: { recentIconContainer: {
marginRight: 16,
},
recentIcon: {
width: 48, width: 48,
height: 48, height: 48,
borderRadius: 24, borderRadius: 16,
backgroundColor: "#f0f9ff",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: 12,
}, },
actionContent: { recentInfo: {
flex: 1, flex: 1,
}, },
actionTitle: { recentTitle: {
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
color: "#1a1a1a", color: theme.colors.gray900,
marginBottom: 2,
},
actionSubtitle: {
fontSize: 14,
color: "#6b7280",
},
membershipCard: {
backgroundColor: "white",
borderRadius: 12,
padding: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
membershipHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
membershipType: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
},
statusBadge: {
backgroundColor: "#dcfce7",
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 12,
fontWeight: "600",
color: "#16a34a",
},
membershipEmail: {
fontSize: 14,
color: "#6b7280",
marginBottom: 4, marginBottom: 4,
}, },
membershipDate: { recentSubtitle: {
fontSize: 12, fontSize: 12,
color: "#9ca3af", color: theme.colors.gray500,
}, },
emptyState: { recentValue: {
backgroundColor: "white",
borderRadius: 12,
padding: 32,
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
emptyStateText: {
fontSize: 16,
fontWeight: "600",
color: "#6b7280",
marginTop: 12,
marginBottom: 4,
},
emptyStateSubtext: {
fontSize: 14, fontSize: 14,
color: "#9ca3af", fontWeight: "600",
textAlign: "center", color: theme.colors.gray900,
}, },
}); });
+198 -238
View File
@@ -1,307 +1,267 @@
import React from "react"; import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
import { import { useUser, useClerk } from "@clerk/clerk-expo";
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
ScrollView,
} from "react-native";
import { useUser, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { theme } from "../../styles/theme";
import { AnimatedButton } from "../../components/AnimatedButton";
import { GradientBackground } from "../../components/GradientBackground";
export default function ProfileScreen() { export default function ProfileScreen() {
const { user } = useUser(); const { user } = useUser();
const { signOut } = useAuth(); const { signOut } = useClerk();
const router = useRouter();
const handleLogout = async () => { const handleSignOut = async () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try { try {
await signOut(); await signOut();
router.replace("/(auth)/sign-in"); } catch (err) {
} catch (error) { console.error("Error signing out:", err);
Alert.alert("Error", "Failed to sign out");
} }
},
},
]);
}; };
if (!user) { const confirmSignOut = () => {
return ( Alert.alert(
<View style={styles.container}> "Sign Out",
<Text>Loading...</Text> "Are you sure you want to sign out?",
</View> [
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
]
); );
} };
return ( return (
<ScrollView style={styles.container}> <View style={styles.container}>
<View style={styles.content}> <GradientBackground variant="primary" style={styles.header}>
{/* Profile Header */}
<View style={styles.profileCard}> <View style={styles.profileCard}>
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
{user.imageUrl ? ( {user?.imageUrl ? (
<View style={styles.avatar}> <Image source={{ uri: user.imageUrl }} style={styles.avatar} />
<Text style={styles.avatarText}>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0)}
</Text>
</View>
) : ( ) : (
<View style={styles.avatar}> <View style={styles.placeholderAvatar}>
<Ionicons name="person" size={40} color="#fff" /> <Ionicons name="person" size={40} color="#fff" />
</View> </View>
)} )}
<View style={styles.editBadge}>
<Ionicons name="pencil" size={12} color={theme.colors.primary} />
</View> </View>
<Text style={styles.name}>
{user.firstName} {user.lastName}
</Text>
<Text style={styles.email}>
{user.primaryEmailAddress?.emailAddress}
</Text>
{user.primaryPhoneNumber && (
<Text style={styles.phone}>
{user.primaryPhoneNumber.phoneNumber}
</Text>
)}
</View> </View>
<Text style={styles.name}>{user?.fullName || "User"}</Text>
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
<View style={styles.memberBadge}>
<Text style={styles.memberText}>Premium Member</Text>
</View>
</View>
</GradientBackground>
{/* Account Information */} <View style={styles.content}>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Account Information</Text> <Text style={styles.sectionTitle}>Account</Text>
<View style={[styles.infoCard, theme.shadows.subtle]}>
<View style={styles.infoRow}> <TouchableOpacity style={styles.infoRow}>
<View style={styles.infoLabel}> <LinearGradient
<Ionicons name="mail-outline" size={20} color="#666" /> colors={['rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.05)']}
<Text style={styles.infoLabelText}>Email</Text> style={styles.iconContainer}
>
<Ionicons name="person-outline" size={20} color={theme.colors.primary} />
</LinearGradient>
<Text style={styles.infoLabel}>Personal Details</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(16, 185, 129, 0.1)', 'rgba(16, 185, 129, 0.05)']}
style={styles.iconContainer}
>
<Ionicons name="fitness-outline" size={20} color={theme.colors.success} />
</LinearGradient>
<Text style={styles.infoLabel}>Fitness Profile</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(245, 158, 11, 0.1)', 'rgba(245, 158, 11, 0.05)']}
style={styles.iconContainer}
>
<Ionicons name="notifications-outline" size={20} color={theme.colors.warning} />
</LinearGradient>
<Text style={styles.infoLabel}>Notifications</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
</TouchableOpacity>
</View> </View>
<Text style={styles.infoValue}>
{user.primaryEmailAddress?.emailAddress}
</Text>
</View> </View>
{user.primaryPhoneNumber && ( <View style={styles.section}>
<View style={styles.infoRow}> <Text style={styles.sectionTitle}>Support</Text>
<View style={styles.infoLabel}> <View style={[styles.infoCard, theme.shadows.subtle]}>
<Ionicons name="call-outline" size={20} color="#666" /> <TouchableOpacity style={styles.infoRow}>
<Text style={styles.infoLabelText}>Phone</Text> <LinearGradient
colors={['rgba(139, 92, 246, 0.1)', 'rgba(139, 92, 246, 0.05)']}
style={styles.iconContainer}
>
<Ionicons name="help-circle-outline" size={20} color={theme.colors.secondary} />
</LinearGradient>
<Text style={styles.infoLabel}>Help Center</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(107, 114, 128, 0.1)', 'rgba(107, 114, 128, 0.05)']}
style={styles.iconContainer}
>
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.gray600} />
</LinearGradient>
<Text style={styles.infoLabel}>Privacy & Security</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
</TouchableOpacity>
</View> </View>
<Text style={styles.infoValue}>
{user.primaryPhoneNumber.phoneNumber}
</Text>
</View>
)}
<View style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons name="calendar-outline" size={20} color="#666" />
<Text style={styles.infoLabelText}>Member Since</Text>
</View>
<Text style={styles.infoValue}>
{new Date(user.createdAt!).toLocaleDateString()}
</Text>
</View> </View>
<View style={styles.infoRow}> <AnimatedButton
<View style={styles.infoLabel}> title="Sign Out"
<Ionicons onPress={confirmSignOut}
name="shield-checkmark-outline" variant="danger"
size={20} style={styles.signOutButton}
color="#666" icon={<Ionicons name="log-out-outline" size={20} color="#fff" />}
/> />
<Text style={styles.infoLabelText}>Email Verified</Text>
</View>
<Text style={styles.infoValue}>
{user.primaryEmailAddress?.verification?.status === "verified"
? "Yes"
: "No"}
</Text>
</View>
</View>
{/* Quick Actions */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="person-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Edit Profile</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="notifications-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Notifications</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="card-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Payment History</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="settings-outline" size={24} color="#2563eb" />
<Text style={styles.actionButtonText}>Settings</Text>
<Ionicons name="chevron-forward" size={20} color="#999" />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={20} color="#fff" />
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
{/* App Version */}
<Text style={styles.version}>Version 1.0.0</Text> <Text style={styles.version}>Version 1.0.0</Text>
</View> </View>
</ScrollView> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#f5f5f5", backgroundColor: theme.colors.background,
}, },
content: { header: {
padding: 20, paddingTop: 60,
paddingBottom: 30,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
alignItems: 'center',
}, },
profileCard: { profileCard: {
backgroundColor: "white", alignItems: 'center',
borderRadius: 16,
padding: 24,
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
marginBottom: 24,
}, },
avatarContainer: { avatarContainer: {
position: 'relative',
marginBottom: 16, marginBottom: 16,
}, },
avatar: { avatar: {
width: 80, width: 100,
height: 80, height: 100,
borderRadius: 40, borderRadius: 50,
backgroundColor: "#2563eb", borderWidth: 4,
justifyContent: "center", borderColor: 'rgba(255, 255, 255, 0.3)',
alignItems: "center",
}, },
avatarText: { placeholderAvatar: {
fontSize: 32, width: 100,
fontWeight: "bold", height: 100,
color: "#fff", borderRadius: 50,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
editBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: theme.colors.white,
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
}, },
name: { name: {
fontSize: 24, fontSize: theme.typography.fontSize['2xl'],
fontWeight: "bold", fontWeight: theme.typography.fontWeight.bold,
color: "#1a1a1a", color: theme.colors.white,
marginBottom: 4, marginBottom: 4,
}, },
email: { email: {
fontSize: 16, fontSize: theme.typography.fontSize.sm,
color: "#666", color: 'rgba(255, 255, 255, 0.8)',
marginBottom: 4, marginBottom: 12,
}, },
phone: { memberBadge: {
fontSize: 14, backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: "#999", paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
memberText: {
color: theme.colors.white,
fontSize: theme.typography.fontSize.xs,
fontWeight: theme.typography.fontWeight.bold,
letterSpacing: 0.5,
},
content: {
padding: 20,
marginTop: -20,
}, },
section: { section: {
backgroundColor: "white", marginBottom: 24,
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
}, },
sectionTitle: { sectionTitle: {
fontSize: 18, fontSize: theme.typography.fontSize.lg,
fontWeight: "600", fontWeight: theme.typography.fontWeight.bold,
color: "#1a1a1a", color: theme.colors.gray900,
marginBottom: 16, marginBottom: 12,
marginLeft: 4,
},
infoCard: {
backgroundColor: theme.colors.white,
borderRadius: theme.borderRadius.xl,
padding: 8,
borderWidth: 1,
borderColor: theme.colors.gray100,
}, },
infoRow: { infoRow: {
flexDirection: "row", flexDirection: 'row',
justifyContent: "space-between", alignItems: 'center',
alignItems: "center", padding: 12,
paddingVertical: 12, },
borderBottomWidth: 1, iconContainer: {
borderBottomColor: "#f0f0f0", width: 36,
height: 36,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
}, },
infoLabel: { infoLabel: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
infoLabelText: {
fontSize: 14,
color: "#666",
fontWeight: "500",
},
infoValue: {
fontSize: 14,
color: "#1a1a1a",
fontWeight: "500",
},
actionButton: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
actionButtonText: {
flex: 1, flex: 1,
fontSize: 16, fontSize: theme.typography.fontSize.base,
color: "#1a1a1a", color: theme.colors.gray900,
marginLeft: 12, fontWeight: theme.typography.fontWeight.medium,
fontWeight: "500",
}, },
logoutButton: { divider: {
backgroundColor: "#ef4444", height: 1,
paddingVertical: 16, backgroundColor: theme.colors.gray100,
borderRadius: 12, marginLeft: 60,
alignItems: "center", },
justifyContent: "center", signOutButton: {
flexDirection: "row",
gap: 8,
marginTop: 8, marginTop: 8,
marginBottom: 16,
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
logoutText: {
color: "white",
fontSize: 16,
fontWeight: "600",
}, },
version: { version: {
textAlign: "center", textAlign: 'center',
fontSize: 12, marginTop: 24,
color: "#999", color: theme.colors.gray400,
marginTop: 8, fontSize: theme.typography.fontSize.xs,
marginBottom: 20,
}, },
}); });
@@ -0,0 +1,291 @@
import { useEffect, useState } from "react";
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
import { theme } from "../../styles/theme";
interface Recommendation {
id: string;
userId: string;
content: string;
activityPlan?: string;
dietPlan?: string;
status: string;
createdAt: string;
}
export default function RecommendationsScreen() {
const { getToken, userId } = useAuth();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchRecommendations = async () => {
try {
if (!userId) {
console.error('No userId available');
return;
}
const token = await getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
console.log('Fetching recommendations from:', url);
const res = await fetch(url, { headers });
if (!res.ok) {
const errorText = await res.text();
console.error('API Error:', res.status, errorText);
throw new Error(`Network response was not ok: ${res.status}`);
}
const data = await res.json();
console.log('Recommendations data:', data);
setRecommendations(data.recommendations || data || []);
} catch (e) {
console.error('Failed to load recommendations', e);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchRecommendations();
}, []);
const onRefresh = () => {
setRefreshing(true);
fetchRecommendations();
};
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
);
}
return (
<View style={styles.container}>
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<Text style={styles.headerTitle}>AI Recommendations</Text>
</LinearGradient>
{/* AI Context Info Banner with Glassmorphism */}
<LinearGradient
colors={['rgba(59, 130, 246, 0.15)', 'rgba(139, 92, 246, 0.1)'] as const}
style={styles.infoBanner}
>
<View style={styles.infoBannerIconContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.infoBannerIcon}
>
<Ionicons name="sparkles" size={16} color="#fff" />
</LinearGradient>
</View>
<Text style={styles.infoBannerText}>
Personalized based on your active fitness goals and progress
</Text>
</LinearGradient>
<FlatList
data={recommendations}
keyExtractor={(item) => item.id}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<LinearGradient
colors={['rgba(209, 213, 219, 0.3)', 'rgba(209, 213, 219, 0.1)'] as const}
style={styles.emptyIconGradient}
>
<Ionicons name="sparkles-outline" size={48} color="#9ca3af" />
</LinearGradient>
</View>
<Text style={styles.empty}>No recommendations available yet.</Text>
<Text style={styles.emptySub}>Pull down to refresh</Text>
</View>
}
renderItem={({ item }) => (
<LinearGradient
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
style={[styles.card, theme.shadows.medium]}
>
<View style={styles.cardHeader}>
<LinearGradient
colors={theme.gradients.success}
style={styles.statusBadge}
>
<Text style={styles.statusText}>{item.status.toUpperCase()}</Text>
</LinearGradient>
<Text style={styles.date}>{new Date(item.createdAt).toLocaleDateString()}</Text>
</View>
<Text style={styles.sectionTitle}>Daily Advice</Text>
<Text style={styles.content}>{item.content}</Text>
{item.activityPlan && (
<>
<Text style={styles.sectionTitle}>Activity Plan</Text>
<Text style={styles.content}>{item.activityPlan}</Text>
</>
)}
{item.dietPlan && (
<>
<Text style={styles.sectionTitle}>Diet Plan</Text>
<Text style={styles.content}>{item.dietPlan}</Text>
</>
)}
</LinearGradient>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
paddingTop: 60,
paddingBottom: 24,
paddingHorizontal: 24,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
headerTitle: {
fontSize: theme.typography.fontSize['3xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
},
infoBanner: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 16,
marginTop: 16,
marginBottom: 12,
padding: 14,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.2)',
gap: 10,
},
infoBannerIconContainer: {
marginRight: 4,
},
infoBannerIcon: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
infoBannerText: {
flex: 1,
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray700,
lineHeight: 18,
fontWeight: theme.typography.fontWeight.medium,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.colors.background,
},
listContent: {
padding: 16,
},
card: {
padding: 18,
marginBottom: 14,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.1)',
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 14,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: theme.colors.gray200,
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: theme.borderRadius.md,
},
statusText: {
fontSize: theme.typography.fontSize.xs,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.white,
},
date: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray600,
fontWeight: theme.typography.fontWeight.medium,
},
sectionTitle: {
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray900,
marginTop: 12,
marginBottom: 6,
},
content: {
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray700,
lineHeight: 20,
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyIconContainer: {
marginBottom: 16,
},
emptyIconGradient: {
width: 96,
height: 96,
borderRadius: 48,
justifyContent: 'center',
alignItems: 'center',
},
empty: {
textAlign: 'center',
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray700,
fontWeight: theme.typography.fontWeight.semibold,
marginBottom: 4,
},
emptySub: {
textAlign: 'center',
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray500,
},
});
@@ -0,0 +1,157 @@
import React from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
const { width } = Dimensions.get('window');
interface ActivityWidgetProps {
steps: number;
calories: number;
duration: number; // in minutes
}
export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) {
return (
<View style={styles.container}>
<LinearGradient
colors={theme.gradients.dark}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, theme.shadows.medium]}
>
<View style={styles.header}>
<Text style={styles.title}>Daily Activity</Text>
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
</View>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<View style={[styles.iconContainer, { backgroundColor: 'rgba(59, 130, 246, 0.2)' }]}>
<Ionicons name="footsteps" size={20} color="#3b82f6" />
</View>
<Text style={styles.statValue}>{steps.toLocaleString()}</Text>
<Text style={styles.statLabel}>Steps</Text>
</View>
<View style={styles.divider} />
<View style={styles.statItem}>
<View style={[styles.iconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.2)' }]}>
<Ionicons name="flame" size={20} color="#ef4444" />
</View>
<Text style={styles.statValue}>{calories}</Text>
<Text style={styles.statLabel}>Kcal</Text>
</View>
<View style={styles.divider} />
<View style={styles.statItem}>
<View style={[styles.iconContainer, { backgroundColor: 'rgba(16, 185, 129, 0.2)' }]}>
<Ionicons name="time" size={20} color="#10b981" />
</View>
<Text style={styles.statValue}>{duration}m</Text>
<Text style={styles.statLabel}>Active</Text>
</View>
</View>
{/* Simple Bar Chart Visualization */}
<View style={styles.chartContainer}>
{[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => (
<View key={index} style={styles.barContainer}>
<LinearGradient
colors={theme.gradients.primaryVertical}
style={[styles.bar, { height: height * 60 }]}
/>
<Text style={styles.dayLabel}>
{['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]}
</Text>
</View>
))}
</View>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginHorizontal: 20,
marginBottom: 20,
},
card: {
borderRadius: 24,
padding: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#fff',
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
statItem: {
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
statValue: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
marginBottom: 2,
},
statLabel: {
fontSize: 12,
color: theme.colors.gray400,
fontWeight: '500',
},
divider: {
width: 1,
height: 40,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
chartContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
height: 80,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.1)',
},
barContainer: {
alignItems: 'center',
gap: 8,
},
bar: {
width: 6,
borderRadius: 3,
opacity: 0.8,
},
dayLabel: {
fontSize: 10,
color: theme.colors.gray500,
fontWeight: '600',
},
});
@@ -0,0 +1,154 @@
/**
* Animated Button Component
* Modern button with gradient backgrounds and smooth animations
*/
import React, { useRef } from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
Animated,
ActivityIndicator,
ViewStyle,
TextStyle,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
import { haptics } from '../utils/haptics';
interface AnimatedButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
icon?: React.ReactNode;
}
export function AnimatedButton({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
icon,
}: AnimatedButtonProps) {
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
Animated.spring(scale, {
toValue: 0.95,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
}).start();
};
const handlePress = () => {
if (!disabled && !loading) {
haptics.light();
onPress();
}
};
const getGradientColors = () => {
if (disabled) return ['#9ca3af', '#6b7280'] as const;
switch (variant) {
case 'secondary':
return theme.gradients.purple;
case 'success':
return theme.gradients.success;
case 'danger':
return theme.gradients.danger;
case 'warning':
return theme.gradients.warning;
default:
return theme.gradients.primary;
}
};
const getSizeStyles = () => {
switch (size) {
case 'sm':
return { paddingVertical: 8, paddingHorizontal: 16, fontSize: 14 };
case 'lg':
return { paddingVertical: 16, paddingHorizontal: 32, fontSize: 18 };
default:
return { paddingVertical: 12, paddingHorizontal: 24, fontSize: 16 };
}
};
const sizeStyles = getSizeStyles();
return (
<TouchableOpacity
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
disabled={disabled || loading}
activeOpacity={0.9}
style={[styles.container, style]}
>
<Animated.View style={{ transform: [{ scale }], width: '100%' }}>
<LinearGradient
colors={getGradientColors()}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.gradient,
{ paddingVertical: sizeStyles.paddingVertical, paddingHorizontal: sizeStyles.paddingHorizontal },
]}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<>
{icon && <React.Fragment>{icon}</React.Fragment>}
<Text
style={[
styles.text,
{ fontSize: sizeStyles.fontSize },
icon ? { marginLeft: 8 } : {},
textStyle,
]}
>
{title}
</Text>
</>
)}
</LinearGradient>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
gradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
...theme.shadows.medium,
},
text: {
color: '#fff',
fontWeight: '600',
textAlign: 'center',
},
});
@@ -0,0 +1,72 @@
import React, { useEffect, useRef } from 'react';
import { View, Animated, StyleSheet, Text } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import { theme } from '../styles/theme';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
interface CircularProgressProps {
size?: number;
strokeWidth?: number;
progress: number; // 0 to 100
color?: string;
backgroundColor?: string;
}
export function CircularProgress({
size = 60,
strokeWidth = 6,
progress,
color = theme.colors.primary,
backgroundColor = theme.colors.gray200,
}: CircularProgressProps) {
const animatedValue = useRef(new Animated.Value(0)).current;
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
useEffect(() => {
Animated.timing(animatedValue, {
toValue: progress,
duration: 1000,
useNativeDriver: true,
}).start();
}, [progress]);
const strokeDashoffset = animatedValue.interpolate({
inputRange: [0, 100],
outputRange: [circumference, 0],
});
return (
<View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={backgroundColor}
strokeWidth={strokeWidth}
fill="transparent"
/>
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
rotation="-90"
origin={`${size / 2}, ${size / 2}`}
/>
</Svg>
<View style={[StyleSheet.absoluteFillObject, { justifyContent: 'center', alignItems: 'center' }]}>
<Text style={{ fontSize: size * 0.25, fontWeight: 'bold', color: theme.colors.gray900 }}>
{Math.round(progress)}%
</Text>
</View>
</View>
);
}
+143
View File
@@ -0,0 +1,143 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
import { Animated } from 'react-native';
const { width } = Dimensions.get('window');
export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
return (
<View style={styles.container}>
<LinearGradient
colors={['rgba(255, 255, 255, 0.9)', 'rgba(255, 255, 255, 0.7)']}
style={[styles.tabBar, theme.shadows.medium]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const getIconName = (routeName: string, focused: boolean): keyof typeof Ionicons.glyphMap => {
switch (routeName) {
case 'index':
return focused ? 'home' : 'home-outline';
case 'goals':
return focused ? 'trophy' : 'trophy-outline';
case 'attendance':
return focused ? 'calendar' : 'calendar-outline';
case 'recommendations':
return focused ? 'sparkles' : 'sparkles-outline';
case 'profile':
return focused ? 'person' : 'person-outline';
default:
return 'ellipse-outline';
}
};
// Animation for scale
const scaleValue = React.useRef(new Animated.Value(1)).current;
React.useEffect(() => {
Animated.spring(scaleValue, {
toValue: isFocused ? 1.2 : 1,
useNativeDriver: true,
friction: 8,
}).start();
}, [isFocused]);
return (
<TouchableOpacity
key={index}
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={(options as any).tabBarTestID}
onPress={onPress}
style={styles.tabItem}
activeOpacity={0.7}
>
<Animated.View style={{ transform: [{ scale: scaleValue }] }}>
{isFocused ? (
<LinearGradient
colors={theme.gradients.primary}
style={styles.iconContainer}
>
<Ionicons
name={getIconName(route.name, true)}
size={20}
color="#fff"
/>
</LinearGradient>
) : (
<Ionicons
name={getIconName(route.name, false)}
size={24}
color={theme.colors.gray500}
/>
)}
</Animated.View>
</TouchableOpacity>
);
})}
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
alignItems: 'center',
paddingBottom: Platform.OS === 'ios' ? 30 : 20,
pointerEvents: 'box-none',
},
tabBar: {
flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 35,
height: 70,
width: width - 40,
justifyContent: 'space-around',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
paddingHorizontal: 10,
},
tabItem: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
});
+223
View File
@@ -0,0 +1,223 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, Dimensions, ScrollView, TouchableOpacity, Animated } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
import { CircularProgress } from './CircularProgress';
import type { FitnessGoal } from '../services/fitnessGoals';
const { width } = Dimensions.get('window');
const CARD_WIDTH = width * 0.8;
const SPACING = 20;
interface GoalCarouselProps {
goals: FitnessGoal[];
onGoalPress: (goal: FitnessGoal) => void;
}
export function GoalCarousel({ goals, onGoalPress }: GoalCarouselProps) {
const scrollX = useRef(new Animated.Value(0)).current;
if (goals.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No active goals</Text>
</View>
);
}
return (
<View>
<Animated.ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + SPACING}
decelerationRate="fast"
contentContainerStyle={styles.scrollContent}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { x: scrollX } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
>
{goals.map((goal, index) => {
const inputRange = [
(index - 1) * (CARD_WIDTH + SPACING),
index * (CARD_WIDTH + SPACING),
(index + 1) * (CARD_WIDTH + SPACING),
];
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.9, 1, 0.9],
extrapolate: 'clamp',
});
const opacity = scrollX.interpolate({
inputRange,
outputRange: [0.7, 1, 0.7],
extrapolate: 'clamp',
});
return (
<TouchableOpacity
key={goal.id}
activeOpacity={0.9}
onPress={() => onGoalPress(goal)}
>
<Animated.View
style={[
styles.cardContainer,
{ transform: [{ scale }], opacity },
]}
>
<LinearGradient
colors={['#ffffff', '#f9fafb']}
style={[styles.card, theme.shadows.medium]}
>
<View style={styles.cardHeader}>
<View style={styles.iconContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.iconGradient}
>
<Ionicons name="trophy" size={24} color="#fff" />
</LinearGradient>
</View>
<View style={styles.priorityBadge}>
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
</View>
</View>
<Text style={styles.title} numberOfLines={1}>{goal.title}</Text>
<Text style={styles.description} numberOfLines={2}>{goal.description}</Text>
<View style={styles.progressContainer}>
<CircularProgress
progress={goal.progress || 0}
size={80}
strokeWidth={8}
color={theme.colors.primary}
/>
<View style={styles.progressDetails}>
<Text style={styles.progressValue}>
{goal.currentValue} / {goal.targetValue}
</Text>
<Text style={styles.progressUnit}>{goal.unit}</Text>
</View>
</View>
<View style={styles.footer}>
<Text style={styles.date}>
Target: {goal.targetDate ? new Date(goal.targetDate).toLocaleDateString() : 'No date'}
</Text>
</View>
</LinearGradient>
</Animated.View>
</TouchableOpacity>
);
})}
</Animated.ScrollView>
</View>
);
}
const styles = StyleSheet.create({
scrollContent: {
paddingHorizontal: (width - CARD_WIDTH) / 2,
paddingVertical: 20,
},
emptyContainer: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: theme.colors.gray500,
fontSize: 16,
},
cardContainer: {
width: CARD_WIDTH,
marginRight: SPACING,
},
card: {
borderRadius: 24,
padding: 24,
height: 320,
justifyContent: 'space-between',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
},
iconContainer: {
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
iconGradient: {
width: 48,
height: 48,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
priorityBadge: {
backgroundColor: 'rgba(59, 130, 246, 0.1)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
priorityText: {
color: theme.colors.primary,
fontSize: 12,
fontWeight: '700',
},
title: {
fontSize: 24,
fontWeight: '800',
color: theme.colors.gray900,
marginBottom: 8,
},
description: {
fontSize: 14,
color: theme.colors.gray500,
marginBottom: 24,
lineHeight: 20,
},
progressContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 24,
},
progressDetails: {
alignItems: 'flex-end',
},
progressValue: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
},
progressUnit: {
fontSize: 14,
color: theme.colors.gray500,
fontWeight: '500',
},
footer: {
borderTopWidth: 1,
borderTopColor: theme.colors.gray100,
paddingTop: 16,
},
date: {
fontSize: 12,
color: theme.colors.gray400,
fontWeight: '500',
},
});
@@ -0,0 +1,426 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TextInput,
TouchableOpacity,
ScrollView,
Platform,
Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import type { CreateGoalData } from '../services/fitnessGoals';
interface GoalCreationModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (goalData: CreateGoalData) => Promise<void>;
}
const GOAL_TYPES = [
{ value: 'weight_target', label: 'Weight Target' },
{ value: 'strength_milestone', label: 'Strength Milestone' },
{ value: 'endurance_target', label: 'Endurance Target' },
{ value: 'flexibility_goal', label: 'Flexibility Goal' },
{ value: 'habit_building', label: 'Habit Building' },
{ value: 'custom', label: 'Custom Goal' },
] as const;
const PRIORITIES = [
{ value: 'low', label: 'Low', color: '#10b981' },
{ value: 'medium', label: 'Medium', color: '#f59e0b' },
{ value: 'high', label: 'High', color: '#ef4444' },
] as const;
export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationModalProps) {
const [goalType, setGoalType] = useState<CreateGoalData['goalType']>('weight_target');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [targetValue, setTargetValue] = useState('');
const [currentValue, setCurrentValue] = useState('');
const [unit, setUnit] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [targetDate, setTargetDate] = useState<Date | undefined>();
const [showDatePicker, setShowDatePicker] = useState(false);
const [submitting, setSubmitting] = useState(false);
const resetForm = () => {
setGoalType('weight_target');
setTitle('');
setDescription('');
setTargetValue('');
setCurrentValue('');
setUnit('');
setPriority('medium');
setTargetDate(undefined);
};
const handleSubmit = async () => {
if (!title.trim()) {
Alert.alert('Error', 'Please enter a goal title');
return;
}
setSubmitting(true);
try {
const goalData: CreateGoalData = {
goalType,
title: title.trim(),
description: description.trim() || undefined,
targetValue: targetValue ? parseFloat(targetValue) : undefined,
currentValue: currentValue ? parseFloat(currentValue) : undefined,
unit: unit.trim() || undefined,
targetDate: targetDate?.toISOString(),
priority,
};
await onSubmit(goalData);
resetForm();
onClose();
} catch (error) {
console.error('Error creating goal:', error);
Alert.alert('Error', 'Failed to create goal. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={handleClose}
>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Create Fitness Goal</Text>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#111827" />
</TouchableOpacity>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* Goal Type */}
<View style={styles.field}>
<Text style={styles.label}>Goal Type *</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.typeScroll}>
{GOAL_TYPES.map((type) => (
<TouchableOpacity
key={type.value}
style={[
styles.typeButton,
goalType === type.value && styles.typeButtonActive,
]}
onPress={() => setGoalType(type.value)}
>
<Text
style={[
styles.typeButtonText,
goalType === type.value && styles.typeButtonTextActive,
]}
>
{type.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Title */}
<View style={styles.field}>
<Text style={styles.label}>Title *</Text>
<TextInput
style={styles.input}
value={title}
onChangeText={setTitle}
placeholder="e.g., Lose 5kg"
placeholderTextColor="#9ca3af"
/>
</View>
{/* Description */}
<View style={styles.field}>
<Text style={styles.label}>Description</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={description}
onChangeText={setDescription}
placeholder="Optional description"
placeholderTextColor="#9ca3af"
multiline
numberOfLines={3}
/>
</View>
{/* Target Value & Unit */}
<View style={styles.row}>
<View style={[styles.field, styles.flex1]}>
<Text style={styles.label}>Target Value</Text>
<TextInput
style={styles.input}
value={targetValue}
onChangeText={setTargetValue}
placeholder="e.g., 70"
placeholderTextColor="#9ca3af"
keyboardType="numeric"
/>
</View>
<View style={[styles.field, styles.flex1]}>
<Text style={styles.label}>Unit</Text>
<TextInput
style={styles.input}
value={unit}
onChangeText={setUnit}
placeholder="e.g., kg"
placeholderTextColor="#9ca3af"
/>
</View>
</View>
{/* Current Value */}
<View style={styles.field}>
<Text style={styles.label}>Current Value</Text>
<TextInput
style={styles.input}
value={currentValue}
onChangeText={setCurrentValue}
placeholder="Starting value (optional)"
placeholderTextColor="#9ca3af"
keyboardType="numeric"
/>
</View>
{/* Target Date */}
<View style={styles.field}>
<Text style={styles.label}>Target Date</Text>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowDatePicker(!showDatePicker)}
>
<Text style={targetDate ? styles.dateText : styles.datePlaceholder}>
{targetDate ? targetDate.toLocaleDateString() : 'Select target date'}
</Text>
<Ionicons name={showDatePicker ? "chevron-up" : "calendar-outline"} size={20} color="#6b7280" />
</TouchableOpacity>
</View>
{showDatePicker && (
<View style={Platform.OS === 'ios' ? styles.datePickerContainer : undefined}>
<DateTimePicker
value={targetDate || new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'default'}
onChange={(event, selectedDate) => {
setShowDatePicker(Platform.OS === 'ios');
if (selectedDate) {
setTargetDate(selectedDate);
}
}}
minimumDate={new Date()}
themeVariant="light"
/>
</View>
)}
{/* Priority */}
<View style={styles.field}>
<Text style={styles.label}>Priority</Text>
<View style={styles.priorityContainer}>
{PRIORITIES.map((p) => (
<TouchableOpacity
key={p.value}
style={[
styles.priorityButton,
priority === p.value && { backgroundColor: p.color },
]}
onPress={() => setPriority(p.value)}
>
<Text
style={[
styles.priorityButtonText,
priority === p.value && styles.priorityButtonTextActive,
]}
>
{p.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.submitButton, submitting && styles.submitButtonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.submitButtonText}>
{submitting ? 'Creating...' : 'Create Goal'}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: Platform.OS === 'ios' ? 60 : 20,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
headerTitle: {
fontSize: 20,
fontWeight: '600',
color: '#111827',
},
closeButton: {
padding: 4,
},
content: {
flex: 1,
padding: 20,
},
field: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
marginBottom: 8,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
color: '#111827',
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
row: {
flexDirection: 'row',
gap: 12,
},
flex1: {
flex: 1,
},
typeScroll: {
flexGrow: 0,
},
typeButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#fff',
marginRight: 8,
},
typeButtonActive: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
typeButtonText: {
fontSize: 14,
color: '#374151',
},
typeButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
dateButton: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
},
dateText: {
fontSize: 16,
color: '#111827',
},
datePlaceholder: {
fontSize: 16,
color: '#9ca3af',
},
priorityContainer: {
flexDirection: 'row',
gap: 12,
},
priorityButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#fff',
alignItems: 'center',
},
priorityButtonText: {
fontSize: 14,
color: '#374151',
fontWeight: '500',
},
priorityButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
footer: {
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
},
submitButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
submitButtonDisabled: {
opacity: 0.6,
},
submitButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
datePickerContainer: {
backgroundColor: '#fff',
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
marginTop: 8,
overflow: 'hidden',
},
});
@@ -0,0 +1,293 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import type { FitnessGoal } from '../services/fitnessGoals';
import { theme } from '../styles/theme';
interface GoalProgressCardProps {
goal: FitnessGoal;
onPress?: () => void;
onComplete?: () => void;
onDelete?: () => void;
}
export function GoalProgressCard({ goal, onPress, onComplete, onDelete }: GoalProgressCardProps) {
const isCompleted = goal.status === 'completed';
const progress = goal.progress || 0;
// Calculate days remaining
const daysRemaining = goal.targetDate
? Math.ceil((new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null;
const getGoalTypeIcon = (type: string) => {
switch (type) {
case 'weight_target': return 'scale-outline';
case 'strength_milestone': return 'barbell-outline';
case 'endurance_target': return 'bicycle-outline';
case 'flexibility_goal': return 'body-outline';
case 'habit_building': return 'calendar-outline';
default: return 'flag-outline';
}
};
const getPriorityGradient = (priority: string): readonly [string, string] => {
switch (priority) {
case 'high': return theme.gradients.danger;
case 'medium': return theme.gradients.warning;
case 'low': return theme.gradients.success;
default: return theme.gradients.primary;
}
};
const handleDelete = () => {
Alert.alert(
'Delete Goal',
'Are you sure you want to delete this goal?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: onDelete },
]
);
};
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
>
<LinearGradient
colors={isCompleted
? ['rgba(16, 185, 129, 0.05)', 'rgba(5, 150, 105, 0.02)'] as const
: ['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const
}
style={[
styles.card,
theme.shadows.medium,
isCompleted && styles.cardCompleted
]}
>
{/* Priority Accent Bar */}
<LinearGradient
colors={getPriorityGradient(goal.priority)}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.priorityAccent}
/>
<View style={styles.header}>
<View style={styles.titleRow}>
<LinearGradient
colors={isCompleted ? theme.gradients.success : getPriorityGradient(goal.priority)}
style={styles.iconContainer}
>
<Ionicons
name={getGoalTypeIcon(goal.goalType) as any}
size={20}
color="#fff"
/>
</LinearGradient>
<View style={styles.titleContainer}>
<Text style={[styles.title, isCompleted && styles.titleCompleted]}>
{goal.title}
</Text>
{goal.description && (
<Text style={styles.description} numberOfLines={2}>
{goal.description}
</Text>
)}
</View>
</View>
<View style={styles.actions}>
{!isCompleted && onComplete && (
<TouchableOpacity onPress={onComplete} style={styles.actionButton}>
<Ionicons name="checkmark-circle-outline" size={24} color={theme.colors.success} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
<Ionicons name="trash-outline" size={22} color={theme.colors.danger} />
</TouchableOpacity>
)}
</View>
</View>
{goal.targetValue && (
<View style={styles.progressSection}>
<View style={styles.progressInfo}>
<Text style={styles.progressText}>
{goal.currentValue || 0} / {goal.targetValue} {goal.unit || ''}
</Text>
<Text style={[styles.progressPercentage, isCompleted && { color: theme.colors.success }]}>
{progress.toFixed(0)}%
</Text>
</View>
<View style={styles.progressBarContainer}>
<LinearGradient
colors={isCompleted ? theme.gradients.success : getPriorityGradient(goal.priority)}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.progressBar,
{ width: `${Math.min(progress, 100)}%` }
]}
/>
</View>
</View>
)}
<View style={styles.footer}>
<LinearGradient
colors={getPriorityGradient(goal.priority)}
style={styles.priorityBadge}
>
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
</LinearGradient>
{daysRemaining !== null && !isCompleted && (
<Text style={[styles.daysRemaining, daysRemaining < 0 && styles.overdue]}>
{daysRemaining < 0
? `${Math.abs(daysRemaining)} days overdue`
: `${daysRemaining} days remaining`
}
</Text>
)}
{isCompleted && goal.completedDate && (
<Text style={styles.completedDate}>
Completed {new Date(goal.completedDate).toLocaleDateString()}
</Text>
)}
</View>
</LinearGradient>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: theme.borderRadius.xl,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.1)',
overflow: 'hidden',
},
cardCompleted: {
borderColor: 'rgba(16, 185, 129, 0.2)',
},
priorityAccent: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
marginLeft: 8,
},
titleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
titleContainer: {
flex: 1,
},
title: {
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray900,
marginBottom: 4,
},
titleCompleted: {
color: theme.colors.gray600,
textDecorationLine: 'line-through',
},
description: {
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray600,
lineHeight: 18,
},
actions: {
flexDirection: 'row',
gap: 8,
},
actionButton: {
padding: 4,
},
progressSection: {
marginBottom: 12,
marginLeft: 8,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
progressText: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.medium,
color: theme.colors.gray700,
},
progressPercentage: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.primary,
},
progressBarContainer: {
height: 8,
backgroundColor: theme.colors.gray200,
borderRadius: 4,
overflow: 'hidden',
},
progressBar: {
height: '100%',
borderRadius: 4,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginLeft: 8,
},
priorityBadge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: theme.borderRadius.md,
},
priorityText: {
fontSize: theme.typography.fontSize.xs,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.white,
},
daysRemaining: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray600,
fontWeight: theme.typography.fontWeight.medium,
},
overdue: {
color: theme.colors.danger,
fontWeight: theme.typography.fontWeight.semibold,
},
completedDate: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.success,
fontWeight: theme.typography.fontWeight.medium,
},
});
@@ -0,0 +1,46 @@
/**
* Gradient Background Component
* Reusable gradient background with multiple presets
*/
import React from 'react';
import { StyleSheet, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
interface GradientBackgroundProps {
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'purple' | 'ocean' | 'sunset' | 'dark';
colors?: string[];
style?: ViewStyle;
children?: React.ReactNode;
start?: { x: number; y: number };
end?: { x: number; y: number };
}
export function GradientBackground({
variant = 'primary',
colors,
style,
children,
start = { x: 0, y: 0 },
end = { x: 1, y: 1 },
}: GradientBackgroundProps) {
const gradientColors = (colors || theme.gradients[variant]) as readonly [string, string, ...string[]];
return (
<LinearGradient
colors={gradientColors}
start={start}
end={end}
style={[styles.gradient, style]}
>
{children}
</LinearGradient>
);
}
const styles = StyleSheet.create({
gradient: {
flex: 1,
},
});
@@ -0,0 +1,131 @@
import React from 'react';
import { StyleSheet, View, Animated, Dimensions, ImageSourcePropType } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
const { width } = Dimensions.get('window');
const HEADER_HEIGHT = 300;
interface ParallaxScrollViewProps {
children: React.ReactNode;
headerImage?: ImageSourcePropType;
headerBackgroundColor?: string;
headerContent?: React.ReactNode;
}
export function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor = theme.colors.primary,
headerContent,
}: ParallaxScrollViewProps) {
const scrollY = React.useRef(new Animated.Value(0)).current;
const headerTranslateY = scrollY.interpolate({
inputRange: [0, HEADER_HEIGHT],
outputRange: [0, -HEADER_HEIGHT / 2],
extrapolate: 'clamp',
});
const imageScale = scrollY.interpolate({
inputRange: [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
outputRange: [2, 1, 1],
extrapolate: 'clamp',
});
const headerOpacity = scrollY.interpolate({
inputRange: [0, HEADER_HEIGHT / 2],
outputRange: [1, 0],
extrapolate: 'clamp',
});
return (
<View style={styles.container}>
<Animated.ScrollView
contentContainerStyle={styles.scrollContent}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
<View style={styles.contentContainer}>{children}</View>
</Animated.ScrollView>
<Animated.View
style={[
styles.header,
{
transform: [{ translateY: headerTranslateY }],
},
]}
>
<Animated.View
style={[
StyleSheet.absoluteFill,
{
transform: [{ scale: imageScale }],
},
]}
>
<LinearGradient
colors={theme.gradients.primary}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{headerImage && (
<Animated.Image
source={headerImage}
style={[styles.headerImage, { opacity: 0.5 }]}
/>
)}
</Animated.View>
<Animated.View style={[styles.headerContent, { opacity: headerOpacity }]}>
{headerContent}
</Animated.View>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
scrollContent: {
paddingTop: HEADER_HEIGHT,
},
contentContainer: {
backgroundColor: theme.colors.background,
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
marginTop: -32,
paddingTop: 32,
minHeight: 800,
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: HEADER_HEIGHT,
overflow: 'hidden',
zIndex: 1,
},
headerImage: {
...StyleSheet.absoluteFillObject,
width: undefined,
height: undefined,
resizeMode: 'cover',
},
headerContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingBottom: 32,
},
});
@@ -0,0 +1,114 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
const { width } = Dimensions.get('window');
const ITEM_WIDTH = (width - 40 - 16) / 2; // (Screen width - padding - gap) / 2
interface QuickActionProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
gradient: readonly [string, string, ...string[]];
onPress?: () => void;
}
function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8} style={styles.itemContainer}>
<LinearGradient
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.6)']}
style={[styles.item, theme.shadows.subtle]}
>
<LinearGradient
colors={gradient}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={icon} size={24} color="#fff" />
</LinearGradient>
<Text style={styles.label}>{label}</Text>
<Ionicons name="chevron-forward" size={16} color={theme.colors.gray400} style={styles.arrow} />
</LinearGradient>
</TouchableOpacity>
);
}
export function QuickActionGrid() {
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.grid}>
<QuickActionItem
icon="barbell"
label="Log Workout"
gradient={theme.gradients.primary}
/>
<QuickActionItem
icon="restaurant"
label="Track Meal"
gradient={theme.gradients.success}
/>
<QuickActionItem
icon="water"
label="Add Water"
gradient={theme.gradients.ocean}
/>
<QuickActionItem
icon="scan"
label="Scan Food"
gradient={theme.gradients.purple}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 20,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: theme.colors.gray900,
marginBottom: 16,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
},
itemContainer: {
width: ITEM_WIDTH,
},
item: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 20,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.6)',
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
label: {
fontSize: 14,
fontWeight: '600',
color: theme.colors.gray800,
flex: 1,
},
arrow: {
opacity: 0.5,
},
});
+9 -1
View File
@@ -1,5 +1,5 @@
export const API_BASE_URL = __DEV__ export const API_BASE_URL = __DEV__
? 'https://0ccbc9f6f846.ngrok-free.app' ? 'https://221260dc4a2b.ngrok-free.app'
: 'https://your-production-url.com' : 'https://your-production-url.com'
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
@@ -18,4 +18,12 @@ export const API_ENDPOINTS = {
HISTORY: '/api/attendance/history', HISTORY: '/api/attendance/history',
}, },
RECOMMENDATIONS: '/api/recommendations', RECOMMENDATIONS: '/api/recommendations',
FITNESS_GOALS: {
LIST: '/api/fitness-goals',
CREATE: '/api/fitness-goals',
GET: (id: string) => `/api/fitness-goals/${id}`,
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
DELETE: (id: string) => `/api/fitness-goals/${id}`,
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
},
} }
+153
View File
@@ -0,0 +1,153 @@
import { API_BASE_URL, API_ENDPOINTS } from '../config/api';
export interface FitnessGoal {
id: string;
userId: string;
fitnessProfileId?: string;
goalType: 'weight_target' | 'strength_milestone' | 'endurance_target' | 'flexibility_goal' | 'habit_building' | 'custom';
title: string;
description?: string;
targetValue?: number;
currentValue?: number;
unit?: string;
startDate: string;
targetDate?: string;
completedDate?: string;
status: 'active' | 'completed' | 'abandoned' | 'paused';
progress: number;
priority: 'low' | 'medium' | 'high';
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateGoalData {
goalType: FitnessGoal['goalType'];
title: string;
description?: string;
targetValue?: number;
currentValue?: number;
unit?: string;
targetDate?: string;
priority?: FitnessGoal['priority'];
notes?: string;
}
export class FitnessGoalsService {
private async getAuthHeaders(token: string | null): Promise<any> {
const headers: any = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
async getGoals(userId: string, token: string | null, status?: string): Promise<FitnessGoal[]> {
try {
const headers = await this.getAuthHeaders(token);
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
if (status && status !== 'all') {
url += `&status=${status}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Failed to fetch goals: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching fitness goals:', error);
throw error;
}
}
async createGoal(goalData: CreateGoalData, token: string | null): Promise<FitnessGoal> {
try {
const headers = await this.getAuthHeaders(token);
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, {
method: 'POST',
headers,
body: JSON.stringify(goalData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create goal');
}
return await response.json();
} catch (error) {
console.error('Error creating fitness goal:', error);
throw error;
}
}
async updateGoal(id: string, updates: Partial<FitnessGoal>, token: string | null): Promise<FitnessGoal> {
try {
const headers = await this.getAuthHeaders(token);
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, {
method: 'PUT',
headers,
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error('Failed to update goal');
}
return await response.json();
} catch (error) {
console.error('Error updating fitness goal:', error);
throw error;
}
}
async updateProgress(id: string, currentValue: number, token: string | null): Promise<FitnessGoal> {
return this.updateGoal(id, { currentValue }, token);
}
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
try {
const headers = await this.getAuthHeaders(token);
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, {
method: 'POST',
headers,
});
if (!response.ok) {
throw new Error('Failed to complete goal');
}
return await response.json();
} catch (error) {
console.error('Error completing fitness goal:', error);
throw error;
}
}
async deleteGoal(id: string, token: string | null): Promise<void> {
try {
const headers = await this.getAuthHeaders(token);
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, {
method: 'DELETE',
headers,
});
if (!response.ok) {
throw new Error('Failed to delete goal');
}
} catch (error) {
console.error('Error deleting fitness goal:', error);
throw error;
}
}
}
export const fitnessGoalsService = new FitnessGoalsService();
+191
View File
@@ -0,0 +1,191 @@
/**
* Modern Design System Theme
* Centralized theme configuration with gradients, colors, shadows, and spacing
*/
export const theme = {
// Color Palette
colors: {
// Primary colors
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryLight: '#60a5fa',
secondary: '#8b5cf6',
// Accent colors
purple: '#8b5cf6',
purpleDark: '#7c3aed',
pink: '#ec4899',
// Success
success: '#10b981',
successDark: '#059669',
successLight: '#34d399',
// Warning
warning: '#f59e0b',
warningDark: '#d97706',
// Danger
danger: '#ef4444',
dangerDark: '#dc2626',
// Neutrals
white: '#ffffff',
black: '#000000',
gray50: '#f9fafb',
gray100: '#f3f4f6',
gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af',
gray500: '#6b7280',
gray600: '#4b5563',
gray700: '#374151',
gray800: '#1f2937',
gray900: '#111827',
// Backgrounds
background: '#f5f5f5',
backgroundDark: '#0f172a',
surface: '#ffffff',
surfaceDark: '#1e293b',
},
// Gradient Definitions
gradients: {
primary: ['#3b82f6', '#8b5cf6'] as const,
primaryVertical: ['#3b82f6', '#2563eb'] as const,
success: ['#10b981', '#059669'] as const,
warning: ['#f59e0b', '#d97706'] as const,
danger: ['#ef4444', '#ec4899'] as const,
purple: ['#8b5cf6', '#7c3aed'] as const,
ocean: ['#06b6d4', '#3b82f6'] as const,
sunset: ['#f59e0b', '#ef4444'] as const,
forest: ['#10b981', '#059669'] as const,
lavender: ['#a78bfa', '#ec4899'] as const,
dark: ['#1e293b', '#0f172a'] as const,
},
// Shadow System
shadows: {
subtle: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
medium: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
strong: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
glow: {
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
glowDanger: {
shadowColor: '#ef4444',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
},
// Typography
typography: {
// Font sizes
fontSize: {
xs: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
'2xl': 24,
'3xl': 28,
'4xl': 32,
'5xl': 36,
},
// Font weights
fontWeight: {
normal: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
extrabold: '800' as const,
},
// Line heights
lineHeight: {
tight: 1.2,
normal: 1.5,
relaxed: 1.75,
},
},
// Spacing Scale
spacing: {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
'2xl': 24,
'3xl': 32,
'4xl': 40,
'5xl': 48,
},
// Border Radius
borderRadius: {
sm: 4,
md: 8,
lg: 12,
xl: 16,
'2xl': 20,
'3xl': 24,
full: 9999,
},
// Animation Timing
animation: {
duration: {
fast: 150,
normal: 250,
slow: 350,
},
easing: {
easeIn: 'ease-in',
easeOut: 'ease-out',
easeInOut: 'ease-in-out',
},
},
// Glassmorphism
glass: {
light: {
backgroundColor: 'rgba(255, 255, 255, 0.7)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
dark: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
},
};
export type Theme = typeof theme;
+149
View File
@@ -0,0 +1,149 @@
/**
* Reusable Animation Utilities
* Pre-configured animations for consistent motion design
*/
import { Animated, Easing } from 'react-native';
export const animations = {
// Fade animations
fadeIn: (animatedValue: Animated.Value, duration = 250) => {
return Animated.timing(animatedValue, {
toValue: 1,
duration,
easing: Easing.out(Easing.ease),
useNativeDriver: true,
});
},
fadeOut: (animatedValue: Animated.Value, duration = 250) => {
return Animated.timing(animatedValue, {
toValue: 0,
duration,
easing: Easing.in(Easing.ease),
useNativeDriver: true,
});
},
// Scale animations
scaleIn: (animatedValue: Animated.Value, duration = 250) => {
return Animated.spring(animatedValue, {
toValue: 1,
friction: 8,
tension: 40,
useNativeDriver: true,
});
},
scaleOut: (animatedValue: Animated.Value, duration = 200) => {
return Animated.timing(animatedValue, {
toValue: 0,
duration,
easing: Easing.in(Easing.ease),
useNativeDriver: true,
});
},
// Press animation (scale down slightly)
pressIn: (animatedValue: Animated.Value) => {
return Animated.spring(animatedValue, {
toValue: 0.95,
friction: 8,
tension: 100,
useNativeDriver: true,
});
},
pressOut: (animatedValue: Animated.Value) => {
return Animated.spring(animatedValue, {
toValue: 1,
friction: 8,
tension: 100,
useNativeDriver: true,
});
},
// Slide animations
slideInUp: (animatedValue: Animated.Value, duration = 300) => {
return Animated.timing(animatedValue, {
toValue: 0,
duration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
});
},
slideOutDown: (animatedValue: Animated.Value, duration = 250) => {
return Animated.timing(animatedValue, {
toValue: 100,
duration,
easing: Easing.in(Easing.cubic),
useNativeDriver: true,
});
},
// Pulse animation (for FAB or notifications)
pulse: (animatedValue: Animated.Value) => {
return Animated.sequence([
Animated.timing(animatedValue, {
toValue: 1.1,
duration: 150,
easing: Easing.out(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(animatedValue, {
toValue: 1,
duration: 150,
easing: Easing.in(Easing.ease),
useNativeDriver: true,
}),
]);
},
// Stagger animation for lists
stagger: (animations: Animated.CompositeAnimation[], delay = 50) => {
return Animated.stagger(delay, animations);
},
// Parallel animations
parallel: (animations: Animated.CompositeAnimation[]) => {
return Animated.parallel(animations);
},
// Sequence animations
sequence: (animations: Animated.CompositeAnimation[]) => {
return Animated.sequence(animations);
},
};
// Spring configurations
export const springConfig = {
gentle: {
friction: 10,
tension: 40,
},
bouncy: {
friction: 5,
tension: 40,
},
stiff: {
friction: 8,
tension: 100,
},
};
// Timing configurations
export const timingConfig = {
fast: {
duration: 150,
easing: Easing.out(Easing.ease),
},
normal: {
duration: 250,
easing: Easing.out(Easing.ease),
},
slow: {
duration: 350,
easing: Easing.out(Easing.ease),
},
};
+40
View File
@@ -0,0 +1,40 @@
import * as Haptics from 'expo-haptics';
import { Platform } from 'react-native';
export const haptics = {
light: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
},
medium: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
},
heavy: () => {
if (Platform.OS !== 'web') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}
},
success: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
},
warning: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
},
error: () => {
if (Platform.OS !== 'web') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
},
selection: () => {
if (Platform.OS !== 'web') {
Haptics.selectionAsync();
}
},
};
File renamed without changes.
+217
View File
@@ -0,0 +1,217 @@
# Database Setup Guide
## Database Location
**Primary Database**: `apps/admin/data/fitai.db`
This is the single source of truth for all application data. All API routes, migration scripts, and database operations use this file.
## Database Path Configuration
### Database Package (`@fitai/database`)
The database package supports configurable paths via environment variables:
```typescript
// Default behavior:
// - Development: ../../apps/admin/data/fitai.db
// - Production: ./data/fitai.db
// - Custom: Set DATABASE_URL environment variable
```
**Environment Variable**:
```bash
# Optional: Override default database path
DATABASE_URL=/path/to/your/fitai.db
```
### Admin App
The admin app uses the following database path configuration:
```typescript
// Default: ./data/fitai.db (relative to apps/admin/)
filename: './data/fitai.db'
```
All API routes and scripts use this consistent path.
## Migration Scripts
All migration scripts are located in `apps/admin/scripts/` and use the correct database path:
- `seed-superadmin.js` - Creates super admin user
- `migrate-roles.js` - Adds role column to users table
- `migrate-fitness-profile.js` - Adds allergies and injuries columns
- `migrate-last-visit.js` - Adds lastVisit column to clients table
**Running Migrations**:
```bash
cd apps/admin
node scripts/migrate-fitness-profile.js
node scripts/migrate-roles.js
node scripts/migrate-last-visit.js
```
## Drizzle ORM
Drizzle Kit is configured to use the admin database:
```typescript
// packages/database/drizzle.config.ts
dbCredentials: {
url: "../../apps/admin/data/fitai.db"
}
```
**Drizzle Commands**:
```bash
cd packages/database
# Push schema changes to database
npm run db:push
# Open Drizzle Studio (database GUI)
npm run db:studio
```
## Backup and Restore
### Creating a Backup
```bash
# From project root
cp apps/admin/data/fitai.db backups/fitai-$(date +%Y%m%d-%H%M%S).db
```
### Restoring from Backup
```bash
# From project root
cp backups/fitai-YYYYMMDD-HHMMSS.db apps/admin/data/fitai.db
```
## Database Schema
The database uses SQLite with the following main tables:
- `users` - User accounts (admin, trainer, client)
- `clients` - Client profiles linked to users
- `fitness_profiles` - Fitness goals, preferences, and health info
- `payments` - Payment records
- `attendance` - Check-in/check-out records
- `notifications` - User notifications
- `recommendations` - AI-generated fitness recommendations
## Troubleshooting
### Database Not Found Error
If you see "database not found" errors:
1. Ensure you're running commands from the correct directory
2. Check that `apps/admin/data/fitai.db` exists
3. Verify file permissions (should be readable/writable)
### Multiple Database Files
**There should only be ONE database file**: `apps/admin/data/fitai.db`
If you find other `fitai.db` files:
- `data/fitai.db` (root level) - Old duplicate, should not exist
- `packages/database/fitai.db` - Old duplicate, should not exist
These were consolidated on 2025-11-26. Backups are in `backups/database-consolidation-2025-11-26/`.
### Migration Script Errors
If migration scripts fail:
1. Check that you're in the `apps/admin` directory
2. Verify the database file exists and is writable
3. Check the script output for specific error messages
4. Ensure no other process has the database locked
## Development Workflow
### Starting Fresh
```bash
# Remove existing database
rm apps/admin/data/fitai.db
# Run migrations to create tables
cd packages/database
npm run db:push
# Seed super admin user
cd ../../apps/admin
node scripts/seed-superadmin.js
```
### Viewing Database Contents
**Option 1: Drizzle Studio (Recommended)**
```bash
cd packages/database
npm run db:studio
# Opens at http://localhost:4983
```
**Option 2: SQLite CLI**
```bash
cd apps/admin/data
sqlite3 fitai.db
# Then run SQL queries
SELECT * FROM users;
.exit
```
**Option 3: VS Code Extension**
Install "SQLite Viewer" extension and open `apps/admin/data/fitai.db`
## Production Deployment
### Environment Variables
Set the following in production:
```bash
# Optional: Custom database path
DATABASE_URL=/var/data/fitai.db
# Set production mode
NODE_ENV=production
```
### Database Location in Production
Ensure the database directory exists and has proper permissions:
```bash
mkdir -p /path/to/data
chmod 755 /path/to/data
```
### Backup Strategy
Implement automated backups in production:
```bash
# Daily backup cron job
0 2 * * * cp /var/data/fitai.db /var/backups/fitai-$(date +\%Y\%m\%d).db
```
## Security Considerations
1. **Never commit the database file to git** - It's in `.gitignore`
2. **Protect database file permissions** - Only application should have access
3. **Regular backups** - Implement automated backup strategy
4. **Encryption at rest** - Consider encrypting the database file in production
5. **Access control** - Ensure only authorized users can access the database
## Related Documentation
- [Clerk Webhook Setup](./CLERK_WEBHOOK_SETUP.md) - User synchronization
- [Testing Fitness Profile](./TESTING_FITNESS_PROFILE.md) - Feature testing
- [Webhook Database Fix](./WEBHOOK_DATABASE_FIX.md) - Webhook implementation details
+7 -1
View File
@@ -2,7 +2,13 @@ import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema' import * as schema from './schema'
const sqlite = new Database('./fitai.db') // Configurable database path with intelligent defaults
const dbPath = process.env.DATABASE_URL ||
(process.env.NODE_ENV === 'production'
? './data/fitai.db'
: '../../apps/admin/data/fitai.db')
const sqlite = new Database(dbPath)
export const db = drizzle(sqlite, { schema }) export const db = drizzle(sqlite, { schema })
export * from './schema' export * from './schema'
+64
View File
@@ -145,6 +145,67 @@ export const fitnessProfiles = sqliteTable("fitness_profiles", {
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}); });
export const fitnessGoals = sqliteTable("fitness_goals", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
fitnessProfileId: text("fitness_profile_id").references(
() => fitnessProfiles.id,
{ onDelete: "cascade" }
),
// Goal details
goalType: text("goal_type", {
enum: [
"weight_target",
"strength_milestone",
"endurance_target",
"flexibility_goal",
"habit_building",
"custom",
],
}).notNull(),
title: text("title").notNull(),
description: text("description"),
// Measurable targets
targetValue: real("target_value"), // e.g., 70 (kg), 100 (kg bench press)
currentValue: real("current_value"), // Current progress
unit: text("unit"), // kg, km, reps, etc.
// Timeline
startDate: integer("start_date", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
targetDate: integer("target_date", { mode: "timestamp" }),
completedDate: integer("completed_date", { mode: "timestamp" }),
// Status tracking
status: text("status", {
enum: ["active", "completed", "abandoned", "paused"],
})
.notNull()
.default("active"),
progress: real("progress").default(0), // 0-100 percentage
// Metadata
priority: text("priority", {
enum: ["low", "medium", "high"],
}).default("medium"),
notes: text("notes"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const recommendations = sqliteTable("recommendations", { export const recommendations = sqliteTable("recommendations", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
userId: text("user_id") userId: text("user_id")
@@ -186,5 +247,8 @@ export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert; export type NewNotification = typeof notifications.$inferInsert;
export type FitnessProfile = typeof fitnessProfiles.$inferSelect; export type FitnessProfile = typeof fitnessProfiles.$inferSelect;
export type NewFitnessProfile = typeof fitnessProfiles.$inferInsert; export type NewFitnessProfile = typeof fitnessProfiles.$inferInsert;
export type FitnessGoal = typeof fitnessGoals.$inferSelect;
export type NewFitnessGoal = typeof fitnessGoals.$inferInsert;
export type Recommendation = typeof recommendations.$inferSelect; export type Recommendation = typeof recommendations.$inferSelect;
export type NewRecommendation = typeof recommendations.$inferInsert; export type NewRecommendation = typeof recommendations.$inferInsert;