Compare commits

..
11 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
dimitar 3ab9908588 ai rec works
as intended, needs refinments
2025-11-24 17:57:35 +01:00
dimitar 0183a8ea6a fitnees profile fix in user page 2025-11-23 18:15:43 +01:00
54 changed files with 6356 additions and 1528 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.
+36
View File
@@ -0,0 +1,36 @@
[Approve] Updating recommendation a2a4d10a-463d-4648-b2a3-3b97fd053078 with: {"status":"approved","approvedAt":"2025-11-24T16:18:30.211Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for a2a4d10a-463d-4648-b2a3-3b97fd053078: {"changes":0,"lastInsertRowid":0}
[SQLite] Fetch after update for a2a4d10a-463d-4648-b2a3-3b97fd053078: Not Found
[Approve] Result for a2a4d10a-463d-4648-b2a3-3b97fd053078: Not Found
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:21:37.787Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":1}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:22:27.297Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":1}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:24:28.104Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:24:29.552Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:25:42.561Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:25:43.627Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:27:02.858Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":2}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"rejected"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":2}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
+800 -582
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@clerk/nextjs": "^6.34.5",
"@fitai/database": "file:../../packages/database",
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-slot": "^1.2.4",
@@ -29,6 +30,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"postcss": "^8.4.31",
@@ -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 path = require('path');
const dbPath = path.join(__dirname, '../../../data/fitai.db');
const dbPath = path.join(__dirname, '../data/fitai.db');
const db = new Database(dbPath);
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 }
);
}
}
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
export async function POST(req: Request) {
try {
const { recommendationId, status, approvedBy } = await req.json();
if (!recommendationId || !status) {
return NextResponse.json(
{ error: "Recommendation ID and status are required" },
{ status: 400 }
);
}
const db = await getDatabase();
// Update recommendation status
const updates: any = {
status,
approvedAt: status === "approved" ? new Date() : undefined,
approvedBy: status === "approved" ? approvedBy : undefined,
};
// Remove undefined keys
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
const updatedRecommendation = await db.updateRecommendation(recommendationId, updates);
if (!updatedRecommendation) {
return NextResponse.json(
{ error: "Recommendation not found" },
{ status: 404 }
);
}
// If approved, create a notification for the user
// Note: IDatabase doesn't have createNotification yet, so we'll skip it for now
// or we need to add it to IDatabase/SQLiteDatabase
// For now, let's assume the notification is handled elsewhere or add it later
return NextResponse.json(updatedRecommendation);
} catch (error) {
console.error("Error approving recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,191 @@
import { NextResponse } from "next/server";
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) {
try {
const { userId, useExternalModel } = await req.json();
if (!userId) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
}
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
return NextResponse.json(
{ error: "Fitness profile not found for this user" },
{ status: 404 }
);
}
// Build AI context with goals and recommendations
let prompt: string;
try {
const context = await buildAIContext(userId);
prompt = buildEnhancedPrompt(context);
console.log('Using enhanced AI context with goals and history');
} catch (error) {
// Fallback to basic prompt if context building fails
console.warn('Failed to build AI context, using basic prompt:', error);
prompt = buildBasicPrompt(profile);
}
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", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest",
prompt: prompt,
stream: false,
format: "json",
}),
});
if (!ollamaResponse.ok) {
console.error("Ollama API error:", await ollamaResponse.text());
return NextResponse.json(
{ error: "Failed to generate recommendation from Ollama" },
{ status: 500 }
);
}
const aiData = await ollamaResponse.json();
console.log("Raw Ollama Response:", aiData.response);
try {
let cleanResponse = aiData.response.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 Ollama response:", aiData.response);
return NextResponse.json(
{ error: "Invalid response format from Ollama" },
{ status: 500 }
);
}
}
// Save to database
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.userId,
type: 'ai_plan',
content: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
dietPlan: parsedResponse.dietPlan,
status: 'pending'
});
return NextResponse.json(recommendation);
} catch (error) {
console.error("Error generating recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+54 -24
View File
@@ -12,23 +12,38 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const targetUserId = searchParams.get('userId')
if (!targetUserId) {
return new NextResponse('User ID is required', { status: 400 })
}
const db = await getDatabase()
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
if (currentUserId !== targetUserId) {
// If no userId provided, check if staff and return all recommendations
if (!targetUserId) {
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('User ID is required', { status: 400 })
}
const recommendations = await db.getAllRecommendations()
return NextResponse.json({ recommendations })
}
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (currentUserId !== targetUserId) {
if (!isStaff) {
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)
} catch (error) {
console.error('Error fetching recommendations:', error)
@@ -52,21 +67,37 @@ export async function POST(request: Request) {
}
const body = await request.json()
const { userId, type, content, status } = body
const { userId, fitnessProfileId, recommendationText, activityPlan, dietPlan, status, type, content } = body
if (!userId || !type || !content) {
return new NextResponse('Missing required fields', { status: 400 })
// Handle AI Plan (Legacy/Specific)
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId,
type: 'ai_plan',
content: recommendationText,
activityPlan,
dietPlan,
status: status || 'pending'
})
return NextResponse.json(recommendation)
}
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
type,
content,
status: status || 'pending'
})
// Handle User Goal (Generic)
if (type && content) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
type,
content,
status: status || 'pending'
})
return NextResponse.json(recommendation)
}
return NextResponse.json('Missing required fields', { status: 400 })
return NextResponse.json(recommendation)
} catch (error) {
console.error('Error creating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
@@ -81,7 +112,7 @@ export async function PUT(request: Request) {
}
const body = await request.json()
const { id, status, content } = body
const { id, status, recommendationText, activityPlan, dietPlan, content } = body
if (!id) {
return new NextResponse('Recommendation ID is required', { status: 400 })
@@ -89,13 +120,12 @@ export async function PUT(request: Request) {
const db = await getDatabase()
// Users can update status (e.g. mark as completed), Staff can update content too
// Ideally we'd check ownership for status update, but for now let's allow it if they have the ID
// A stricter check would be: fetch recommendation, check if userId matches currentUserId OR if currentUser is staff
const updated = await db.updateRecommendation(id, {
...(status && { status }),
...(content && { content })
...(recommendationText && { content: recommendationText }), // Map legacy field
...(content && { content }),
...(activityPlan && { activityPlan }),
...(dietPlan && { dietPlan })
})
return NextResponse.json(updated)
+1 -1
View File
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
}
// Enforce Hierarchy
const allowed = {
const allowed: Record<string, string[]> = {
superAdmin: ["admin", "trainer", "client"],
admin: ["trainer", "client"],
trainer: ["client"],
+2 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/input";
interface UserProfile {
@@ -55,7 +55,7 @@ export default function ProfilePage() {
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Profile</h1>
<Button
variant={isEditing ? "default" : "outline"}
variant={isEditing ? "primary" : "secondary"}
onClick={() => setIsEditing(!isEditing)}
>
{isEditing ? "Cancel" : "Edit Profile"}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
}
interface Recommendation {
id: string;
userId: string;
content: string;
activityPlan: string;
dietPlan: string;
status: string;
createdAt: Date;
}
export default function RecommendationsPage() {
const { user } = useUser();
const [users, setUsers] = useState<User[]>([]);
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [useExternalModel, setUseExternalModel] = useState(false);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
// Fetch users
const usersRes = await fetch("/api/users");
const usersData = await usersRes.json();
setUsers(usersData.users || []);
// Fetch pending recommendations
const recsRes = await fetch("/api/recommendations");
const recsData = await recsRes.json();
const allRecs = recsData.recommendations || [];
setPendingRecommendations(allRecs.filter((r: any) => r.status === 'pending'));
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const handleGenerate = async (userId: string) => {
setGenerating(userId);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, useExternalModel }),
});
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Failed to generate recommendation.");
} finally {
setGenerating(null);
}
};
const handleApprove = async (recommendationId: string, status: "approved" | "rejected") => {
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId,
status,
approvedBy: user?.id || "admin",
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update status: ${errorData.error || 'Unknown error'}`);
} else {
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Error processing request");
}
};
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) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-xl">Loading...</div>
</div>
);
}
return (
<div className="container mx-auto py-10 px-4">
<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">
{/* Generate Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Generate Recommendations</h2>
<div className="bg-white shadow rounded-lg p-6">
<p className="mb-4 text-gray-600">
Select a user to generate a new daily recommendation.
</p>
<ul className="space-y-4">
{users.map((user) => (
<li key={user.id} className="flex items-center justify-between border-b pb-2">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<button
onClick={() => handleGenerate(user.id)}
disabled={generating === user.id}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{generating === user.id ? "Generating..." : "Generate"}
</button>
</li>
))}
{users.length === 0 && (
<p className="text-gray-500 italic">No users found.</p>
)}
</ul>
</div>
</div>
{/* Pending Approvals Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Pending Approvals</h2>
<div className="bg-white shadow rounded-lg p-6">
{pendingRecommendations.length === 0 ? (
<p className="text-gray-500 italic">No pending recommendations.</p>
) : (
<ul className="space-y-6">
{pendingRecommendations.map((rec) => (
<li key={rec.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">For: User {rec.userId}</h3>
<span className="text-xs text-gray-500">
{new Date(rec.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span> {rec.content}
</div>
<div>
<span className="font-semibold">Activity:</span> {rec.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span> {rec.dietPlan}
</div>
</div>
<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
onClick={() => handleApprove(rec.id, "approved")}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
>
Approve
</button>
<button
onClick={() => handleApprove(rec.id, "rejected")}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700"
>
Reject
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
);
}
+18 -15
View File
@@ -3,10 +3,10 @@
import { type ReactElement } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, Users, BarChart3, User } from "lucide-react";
import { Home, Users, BarChart3, User, Brain } from "lucide-react";
import { SignedIn, UserButton } from "@clerk/nextjs";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/Button";
interface NavItem {
href: string;
@@ -25,6 +25,11 @@ const navItems: NavItem[] = [
label: "Clients",
icon: Users,
},
{
href: "/recommendations",
label: "AI Recommendations",
icon: Brain,
},
{
href: "/analytics",
label: "Analytics",
@@ -59,22 +64,20 @@ export function Navigation(): ReactElement {
>
{navItems.map((item) => (
<li key={item.href}>
<Button
asChild
variant={pathname === item.href ? "default" : "ghost"}
size="sm"
className={cn(
"h-9 px-4 py-2",
pathname === item.href &&
<Link href={item.href} className="flex items-center gap-2">
<Button
variant={pathname === item.href ? "primary" : "secondary"}
className={cn(
"h-9 px-4 py-2",
pathname === item.href &&
"bg-primary text-primary-foreground",
)}
aria-current={pathname === item.href ? "page" : undefined}
>
<Link href={item.href} className="flex items-center gap-2">
)}
aria-current={pathname === item.href ? "page" : undefined}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
</Button>
</Button>
</Link>
</li>
))}
</ul>
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import { UserGrowthChart } from '@/components/charts/UserGrowthChart'
import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart'
import { RevenueChart } from '@/components/charts/RevenueChart'
import { Card, CardHeader, CardContent } from '@/components/ui/Card'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
interface ChartData {
label: string
@@ -86,7 +86,7 @@ export function AnalyticsDashboard() {
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-center">
@@ -95,7 +95,7 @@ export function AnalyticsDashboard() {
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-center">
@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
export function GenerateButton({ userId }: { userId: string }) {
const [loading, setLoading] = useState(false);
const handleGenerate = async () => {
setLoading(true);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully! Check Pending Approvals.");
// In a real app, we'd revalidate the path or update state
window.location.reload();
}
} catch (error) {
console.error(error);
alert("Failed to generate recommendation.");
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleGenerate}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate
</button>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { Loader2, Check, X } from "lucide-react";
type Recommendation = {
recommendation: {
id: string;
recommendationText: string;
activityPlan: string;
dietPlan: string;
createdAt: Date;
};
user: {
firstName: string;
lastName: string;
};
};
export function RecommendationList({
initialRecommendations,
}: {
initialRecommendations: Recommendation[];
}) {
const [recommendations, setRecommendations] = useState(initialRecommendations);
const [processingId, setProcessingId] = useState<string | null>(null);
const handleAction = async (id: string, status: "approved" | "rejected") => {
setProcessingId(id);
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId: id,
status,
approvedBy: "admin_placeholder", // In real app, get from auth context
}),
});
if (!res.ok) {
alert("Failed to update status");
} else {
setRecommendations((prev) =>
prev.filter((item) => item.recommendation.id !== id)
);
}
} catch (error) {
console.error(error);
alert("Error processing request");
} finally {
setProcessingId(null);
}
};
if (recommendations.length === 0) {
return <p className="text-gray-500 italic">No pending recommendations.</p>;
}
return (
<ul className="space-y-6">
{recommendations.map(({ recommendation, user }) => (
<li key={recommendation.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">
For: {user.firstName} {user.lastName}
</h3>
<span className="text-xs text-gray-500">
{new Date(recommendation.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span>{" "}
{recommendation.recommendationText}
</div>
<div>
<span className="font-semibold">Activity:</span>{" "}
{recommendation.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span> {recommendation.dietPlan}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleAction(recommendation.id, "approved")}
disabled={processingId === recommendation.id}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 flex justify-center items-center"
>
{processingId === recommendation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" /> Approve
</>
)}
</button>
<button
onClick={() => handleAction(recommendation.id, "rejected")}
disabled={processingId === recommendation.id}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700 disabled:opacity-50 flex justify-center items-center"
>
{processingId === recommendation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-2 h-4 w-4" /> Reject
</>
)}
</button>
</div>
</li>
))}
</ul>
);
}
+52 -13
View File
@@ -2,27 +2,67 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
LayoutDashboard,
Users,
CalendarCheck,
CreditCard,
Settings,
LogOut
LogOut,
Brain,
} from "lucide-react";
import { UserButton, useUser } from "@clerk/nextjs";
const menuItems = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
{ icon: Users, label: "Users", href: "/users" },
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
{ icon: CreditCard, label: "Payments", href: "/payments" },
{ icon: Settings, label: "Settings", href: "/settings" },
];
interface Recommendation {
id: string;
status: string;
}
export function Sidebar() {
const pathname = usePathname();
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 (
<aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800">
@@ -36,18 +76,17 @@ export function Sidebar() {
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
const label = typeof item.label === "string" ? item.label : item.label;
return (
<Link
key={item.href}
href={item.href}
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"
: "text-slate-400 hover:bg-slate-800 hover:text-white"
}`}
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
: "text-slate-400 hover:bg-slate-800 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>
);
})}
@@ -7,9 +7,11 @@ import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
content: string;
status: "pending" | "completed";
activityPlan?: string;
dietPlan?: string;
status: "pending" | "completed" | "approved" | "rejected";
createdAt: string;
}
@@ -68,15 +70,8 @@ export function Recommendations({ userId }: RecommendationsProps) {
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure?")) return;
// Note: Delete API not implemented in route.ts yet, but good to have UI ready or we can add it.
// For now, let's assume we might add it or just omit.
// Actually, I didn't add DELETE to route.ts. Let's skip for now.
alert("Delete functionality not available yet.");
};
const groupedRecs = {
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
short_term: recommendations.filter((r) => r.type === "short_term"),
medium_term: recommendations.filter((r) => r.type === "medium_term"),
long_term: recommendations.filter((r) => r.type === "long_term"),
@@ -84,7 +79,7 @@ export function Recommendations({ userId }: RecommendationsProps) {
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term",
type: "short_term" | "medium_term" | "long_term" | "ai_plan",
items: Recommendation[]
) => (
<div className="mb-6">
@@ -96,58 +91,66 @@ export function Recommendations({ userId }: RecommendationsProps) {
{items.map((rec) => (
<div
key={rec.id}
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed"
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed" || rec.status === "approved"
? "bg-green-50 border-green-200"
: "bg-white border-gray-200"
}`}
>
<div>
<p className="text-sm">{rec.content}</p>
<p className="text-xs text-gray-400 mt-1">
<div className="w-full">
<p className="text-sm font-medium">{rec.content}</p>
{rec.type === 'ai_plan' && (
<div className="mt-2 text-xs text-gray-600 space-y-1">
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
{rec.dietPlan && <p><span className="font-semibold">Diet:</span> {rec.dietPlan}</p>}
</div>
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
<span
className={
rec.status === "completed"
rec.status === "completed" || rec.status === "approved"
? "text-green-600 font-medium"
: "text-yellow-600"
}
>
{rec.status === "completed" ? "Completed" : "Pending"}
{rec.status === "completed" ? "Completed" : rec.status === "approved" ? "Approved" : "Pending"}
</span>
</p>
</div>
</div>
))}
</div>
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input
type="hidden"
value={type}
onChange={() => setNewRec({ ...newRec, type })}
/>
{newRec.type === type && (
<>
<input
type="text"
placeholder={`Add ${title.toLowerCase()}...`}
className="flex-1 border rounded px-3 py-1 text-sm"
value={newRec.content}
onChange={(e) =>
setNewRec({ ...newRec, content: e.target.value })
}
required
/>
<Button type="submit" variant="secondary">
Add
{type !== 'ai_plan' && (
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input
type="hidden"
value={type}
onChange={() => setNewRec({ ...newRec, type: type as any })}
/>
{newRec.type === type && (
<>
<input
type="text"
placeholder={`Add ${title.toLowerCase()}...`}
className="flex-1 border rounded px-3 py-1 text-sm"
value={newRec.content}
onChange={(e) =>
setNewRec({ ...newRec, content: e.target.value })
}
required
/>
<Button type="submit" variant="secondary">
Add
</Button>
</>
)}
{newRec.type !== type && (
<Button type="button" variant="secondary" onClick={() => setNewRec({ type: type as any, content: "" })} className="text-xs text-gray-500">
+ Add New
</Button>
</>
)}
{newRec.type !== type && (
<Button type="button" variant="secondary" onClick={() => setNewRec({ type, content: "" })} className="text-xs text-gray-500">
+ Add New
</Button>
)}
</form>
)}
</form>
)}
</div>
);
@@ -159,7 +162,8 @@ export function Recommendations({ userId }: RecommendationsProps) {
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
{renderSection("Short Term Goals", "short_term", groupedRecs.short_term)}
{renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)}
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
+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."
}`;
}
+213 -16
View File
@@ -121,16 +121,24 @@ export class SQLiteDatabase implements IDatabase {
`)
// Recommendations table
// Removed DROP TABLE to persist data. Schema is now stable.
// this.db.exec(`DROP TABLE IF EXISTS recommendations`)
this.db.exec(`
CREATE TABLE IF NOT EXISTS recommendations (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('short_term', 'medium_term', 'long_term')),
fitnessProfileId TEXT,
type TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed')),
activityPlan TEXT,
dietPlan TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
approvedAt DATETIME,
approvedBy TEXT,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
)
`)
@@ -145,7 +153,9 @@ export class SQLiteDatabase implements IDatabase {
}
// User operations
async createUser(userData: Omit<User, 'createdAt' | 'updatedAt'>): Promise<User> {
async createUser(
userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
): Promise<User> {
if (!this.db) throw new Error('Database not connected')
const id = userData.id || Math.random().toString(36).substr(2, 9)
@@ -509,25 +519,29 @@ export class SQLiteDatabase implements IDatabase {
}
// Recommendation operations
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'updatedAt'>): Promise<Recommendation> {
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'approvedAt' | 'approvedBy'>): Promise<Recommendation> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const recommendation: Recommendation = {
...data,
createdAt: now,
updatedAt: now
status: data.status || 'pending'
}
const stmt = this.db.prepare(
`INSERT INTO recommendations (id, userId, type, content, status, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO recommendations (
id, userId, fitnessProfileId, type, content,
activityPlan, dietPlan, status, createdAt
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
recommendation.id, recommendation.userId, recommendation.type,
recommendation.content, recommendation.status,
recommendation.createdAt.toISOString(), recommendation.updatedAt.toISOString()
recommendation.id, recommendation.userId, recommendation.fitnessProfileId,
recommendation.type, recommendation.content, recommendation.activityPlan,
recommendation.dietPlan, recommendation.status,
recommendation.createdAt.toISOString()
)
return recommendation
@@ -542,6 +556,15 @@ export class SQLiteDatabase implements IDatabase {
return rows.map(row => this.mapRowToRecommendation(row))
}
async getAllRecommendations(): Promise<Recommendation[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM recommendations ORDER BY createdAt DESC')
const rows = stmt.all()
return rows.map(row => this.mapRowToRecommendation(row))
}
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
if (!this.db) throw new Error('Database not connected')
@@ -553,11 +576,13 @@ export class SQLiteDatabase implements IDatabase {
}
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => (updates as any)[field])
values.push(new Date().toISOString()) // updatedAt
const values = fields.map(field => {
const val = (updates as any)[field]
return val instanceof Date ? val.toISOString() : val
})
values.push(id)
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause}, updatedAt = ? WHERE id = ?`)
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause} WHERE id = ?`)
stmt.run(values)
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
@@ -577,11 +602,183 @@ export class SQLiteDatabase implements IDatabase {
return {
id: row.id,
userId: row.userId,
fitnessProfileId: row.fitnessProfileId,
type: row.type,
content: row.content,
activityPlan: row.activityPlan,
dietPlan: row.dietPlan,
status: row.status,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt)
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
approvedBy: row.approvedBy
}
}
// 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)
}
}
+46 -4
View File
@@ -50,9 +50,34 @@ export interface Attendance {
export interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
fitnessProfileId?: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
content: string;
status: "pending" | "completed";
activityPlan?: string;
dietPlan?: string;
status: "pending" | "approved" | "rejected" | "completed";
createdAt: Date;
approvedAt?: Date;
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;
}
@@ -64,7 +89,9 @@ export interface IDatabase {
disconnect(): Promise<void>;
// User operations
createUser(user: Omit<User, "createdAt" | "updatedAt">): Promise<User>;
createUser(
user: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
): Promise<User>;
getUserById(id: string): Promise<User | null>;
getUserByEmail(email: string): Promise<User | null>;
getAllUsers(): Promise<User[]>;
@@ -105,15 +132,30 @@ export interface IDatabase {
// Recommendation operations
createRecommendation(
recommendation: Omit<Recommendation, "createdAt" | "updatedAt">,
recommendation: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
): Promise<Recommendation>;
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
getAllRecommendations(): Promise<Recommendation[]>;
updateRecommendation(
id: string,
updates: Partial<Recommendation>,
): Promise<Recommendation | null>;
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
getDashboardStats(): Promise<{
totalUsers: number;
+203
View File
@@ -11,6 +11,7 @@
"@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0",
@@ -22,6 +23,8 @@
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-font": "~14.0.9",
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
@@ -34,6 +37,7 @@
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.0",
"react-native-web": "^0.21.2",
"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": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
@@ -5571,6 +5598,12 @@
"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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -6243,6 +6276,56 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -6468,6 +6551,61 @@
"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": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -6555,6 +6693,18 @@
"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": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -7110,6 +7260,26 @@
"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": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz",
@@ -9842,6 +10012,12 @@
"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": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -10439,6 +10615,18 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -11460,6 +11648,21 @@
"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": {
"version": "2.0.0",
"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",
"@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0",
@@ -28,6 +29,8 @@
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-font": "~14.0.9",
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
@@ -40,6 +43,7 @@
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.0",
"react-native-web": "^0.21.2",
"zod": "^3.22.0"
},
+22 -38
View File
@@ -1,7 +1,7 @@
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "@clerk/clerk-expo";
import { useEffect } from "react";
import { CustomTabBar } from "../../components/CustomTabBar";
export default function TabLayout() {
const { isSignedIn, isLoaded } = useAuth();
@@ -25,56 +25,40 @@ export default function TabLayout() {
return (
<Tabs
tabBar={(props) => <CustomTabBar {...props} />}
screenOptions={{
tabBarActiveTintColor: "#2563eb",
tabBarInactiveTintColor: "#6b7280",
headerShown: true,
headerStyle: {
backgroundColor: "#fff",
},
headerTitleStyle: {
fontWeight: "600",
},
headerShown: false, // We'll use custom headers in screens or layout
tabBarShowLabel: false,
}}
>
<Tabs.Screen
name="index"
options={{
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
name="goals"
options={{
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>
+195 -71
View File
@@ -1,15 +1,39 @@
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 { LinearGradient } from 'expo-linear-gradient'
import { Ionicons } from '@expo/vector-icons'
import { attendanceApi, Attendance } from '../../api/attendance'
import { theme } from '../../styles/theme'
import { Animated } from 'react-native'
export default function AttendanceScreen() {
const { getToken, userId } = useAuth()
const [loading, setLoading] = useState(true)
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
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 () => {
try {
@@ -70,42 +94,100 @@ export default function AttendanceScreen() {
if (loading && !history.length) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#000" />
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
)
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Attendance</Text>
<Text style={styles.subtitle}>Track your gym visits</Text>
<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.subtitle}>Track your gym visits</Text>
</LinearGradient>
<View style={styles.actionContainer}>
{activeCheckIn ? (
<View style={styles.activeCard}>
<Text style={styles.activeText}>Currently Checked In</Text>
<Text style={styles.timeText}>
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
<TouchableOpacity style={styles.checkOutButton} onPress={handleCheckOut}>
<Text style={styles.buttonText}>Check Out</Text>
<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.timeText}>
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
</View>
<TouchableOpacity onPress={handleCheckOut} activeOpacity={0.8}>
<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>
</View>
</LinearGradient>
) : (
<TouchableOpacity style={styles.checkInButton} onPress={handleCheckIn}>
<Text style={styles.buttonText}>Check In</Text>
</TouchableOpacity>
<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>
<Text style={styles.sectionTitle}>Recent History</Text>
{history.map((item) => (
<View key={item.id} style={styles.historyItem}>
<View>
<Text style={styles.dateText}>
{new Date(item.checkInTime).toLocaleDateString()}
</Text>
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
<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>
<Text style={styles.dateText}>
{new Date(item.checkInTime).toLocaleDateString()}
</Text>
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
</View>
</View>
<View style={styles.timeContainer}>
<Text style={styles.historyTime}>
@@ -117,7 +199,7 @@ export default function AttendanceScreen() {
</Text>
)}
</View>
</View>
</LinearGradient>
))}
</ScrollView>
)
@@ -126,105 +208,147 @@ export default function AttendanceScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: theme.colors.background,
},
content: {
padding: 20,
paddingBottom: 20,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
paddingTop: 60,
paddingBottom: 24,
paddingHorizontal: 24,
marginBottom: 24,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
title: {
fontSize: 28,
fontWeight: 'bold',
fontSize: theme.typography.fontSize['3xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
marginBottom: 4,
color: '#1a1a1a',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 24,
fontSize: theme.typography.fontSize.base,
color: 'rgba(255, 255, 255, 0.9)',
},
actionContainer: {
marginBottom: 32,
paddingHorizontal: 20,
},
checkInButton: {
backgroundColor: '#000',
paddingVertical: 16,
borderRadius: 12,
paddingVertical: 20,
paddingHorizontal: 24,
borderRadius: theme.borderRadius.xl,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
justifyContent: 'center',
},
checkInButtonText: {
color: theme.colors.white,
fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.semibold,
},
checkOutButton: {
backgroundColor: '#ff3b30',
paddingVertical: 16,
borderRadius: 12,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
color: theme.colors.white,
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.semibold,
},
activeCard: {
backgroundColor: '#fff',
padding: 20,
borderRadius: 16,
borderRadius: theme.borderRadius.xl,
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: {
fontSize: 18,
fontWeight: '600',
color: '#2e7d32',
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray900,
marginBottom: 4,
},
timeText: {
fontSize: 14,
color: '#666',
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray600,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.semibold,
marginBottom: 16,
color: '#1a1a1a',
paddingHorizontal: 20,
color: theme.colors.gray900,
},
historyItem: {
backgroundColor: '#fff',
padding: 16,
borderRadius: 12,
borderRadius: theme.borderRadius.xl,
marginBottom: 12,
marginHorizontal: 20,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.1)',
},
historyLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
historyIconContainer: {
marginRight: 4,
},
historyIcon: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
dateText: {
fontSize: 16,
fontWeight: '500',
color: '#1a1a1a',
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray900,
},
typeText: {
fontSize: 12,
color: '#666',
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray600,
marginTop: 2,
},
timeContainer: {
alignItems: 'flex-end',
},
historyTime: {
fontSize: 14,
color: '#444',
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray700,
},
})
+233 -215
View File
@@ -1,210 +1,251 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
Alert,
} 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;
}
import React, { useState, useCallback, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Animated, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../../styles/theme';
import { GoalProgressCard } from '../../components/GoalProgressCard';
import { GoalCreationModal } from '../../components/GoalCreationModal';
import { useUser, useAuth } from "@clerk/clerk-expo";
import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from '../../services/fitnessGoals';
import { useFocusEffect } from 'expo-router';
export default function GoalsScreen() {
const { userId, getToken } = useAuth();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const { user } = useUser();
const { getToken } = useAuth();
const [goals, setGoals] = useState<FitnessGoal[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const fabScale = useRef(new Animated.Value(1)).current;
const fetchRecommendations = async () => {
if (!userId) return;
const loadGoals = useCallback(async () => {
if (!user?.id) return;
try {
const token = await getToken();
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
} else {
console.error("Failed to fetch recommendations");
}
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
setGoals(loadedGoals);
} catch (error) {
console.error("Error fetching recommendations:", error);
} finally {
setLoading(false);
setRefreshing(false);
console.error('Error loading goals:', error);
}
};
}, [user?.id]); // Removed getToken from dependencies
useEffect(() => {
fetchRecommendations();
}, [userId]);
useFocusEffect(
useCallback(() => {
loadGoals();
}, [loadGoals])
);
const onRefresh = () => {
const onRefresh = async () => {
setRefreshing(true);
fetchRecommendations();
await loadGoals();
setRefreshing(false);
};
const toggleStatus = async (id: string, currentStatus: string) => {
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 response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, {
method: "PUT",
headers: {
"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 handleCreateGoal = async (newGoal: CreateGoalData) => {
const token = await getToken();
await fitnessGoalsService.createGoal(newGoal, token);
await loadGoals();
setIsModalVisible(false);
};
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term"
) => {
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>
);
const handleCompleteGoal = async (goal: FitnessGoal) => {
const token = await getToken();
await fitnessGoalsService.completeGoal(goal.id, token);
await loadGoals();
};
if (loading && !refreshing) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
);
}
const handleDeleteGoal = async (goalId: string) => {
const token = await getToken();
await fitnessGoalsService.deleteGoal(goalId, token);
await loadGoals();
};
const activeGoals = goals.filter(g => g.status === 'active');
const completedGoals = goals.filter(g => g.status === 'completed');
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>My Goals</Text>
<Text style={styles.headerSubtitle}>
Track your fitness journey progress
</Text>
</View>
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
>
<LinearGradient
colors={theme.gradients.primary}
style={styles.header}
>
<View>
<Text style={styles.headerTitle}>Fitness Goals</Text>
<Text style={styles.headerSubtitle}>
Track your fitness journey progress
</Text>
</View>
</LinearGradient>
{renderSection("Short Term Goals", "short_term")}
{renderSection("Medium Term Goals", "medium_term")}
{renderSection("Long Term Goals", "long_term")}
{/* Stats Summary */}
{goals.length > 0 && (
<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>
)}
<View style={styles.footer} />
</ScrollView>
{/* 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} />
</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({
container: {
flex: 1,
backgroundColor: "#f3f4f6",
backgroundColor: theme.colors.background,
},
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
scrollContent: {
paddingBottom: 20,
},
header: {
padding: 20,
backgroundColor: "#fff",
padding: 24,
paddingTop: 60,
paddingBottom: 24,
marginBottom: 10,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
headerTitle: {
fontSize: 28,
fontWeight: "bold",
color: "#111827",
fontSize: theme.typography.fontSize['3xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
},
headerSubtitle: {
fontSize: 16,
color: "#6b7280",
fontSize: theme.typography.fontSize.base,
color: "rgba(255, 255, 255, 0.9)",
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: {
padding: 20,
paddingTop: 10,
@@ -215,58 +256,35 @@ const styles = StyleSheet.create({
color: "#374151",
marginBottom: 12,
},
emptyText: {
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",
emptyState: {
alignItems: "center",
backgroundColor: "#fff",
paddingVertical: 40,
},
cardContent: {
flex: 1,
},
cardText: {
emptyText: {
fontSize: 16,
color: "#1f2937",
lineHeight: 24,
fontWeight: "500",
color: "#6b7280",
marginTop: 12,
},
cardTextCompleted: {
textDecorationLine: "line-through",
color: "#9ca3af",
},
dateText: {
fontSize: 12,
emptySubtext: {
fontSize: 14,
color: "#9ca3af",
marginTop: 4,
},
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,
},
});
+161 -252
View File
@@ -1,320 +1,229 @@
import React from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
} from "react-native";
import { View, Text, StyleSheet, ScrollView, RefreshControl, Image } from "react-native";
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 { useRouter } from "expo-router";
export default function HomeScreen() {
const { user, isLoaded } = useUser();
const router = useRouter();
const { user } = useUser();
const [refreshing, setRefreshing] = useState(false);
if (!isLoaded || !user) {
return (
<View style={styles.container}>
<Text>Loading...</Text>
</View>
);
}
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setRefreshing(false);
}, 2000);
}, []);
const firstName = user.firstName || "User";
const greeting = getGreeting();
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good Morning";
if (hour < 18) return "Good Afternoon";
return "Good Evening";
};
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
{/* Welcome Header */}
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
>
{/* Header Section */}
<View style={styles.header}>
<Text style={styles.greeting}>{greeting}!</Text>
<Text style={styles.name}>{firstName}</Text>
</View>
{/* Quick Stats */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Ionicons name="calendar-outline" size={32} color="#2563eb" />
<Text style={styles.statValue}>0</Text>
<Text style={styles.statLabel}>This Month</Text>
<View>
<Text style={styles.greeting}>{getGreeting()},</Text>
<Text style={styles.name}>{user?.firstName || "Athlete"}</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 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>
{/* Activity Widget */}
<ActivityWidget
steps={8432}
calories={640}
duration={45}
/>
{/* Quick Actions */}
<QuickActionGrid />
{/* Recent Activity Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<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 style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Recent Activity</Text>
<Text style={styles.seeAll}>See All</Text>
</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 style={styles.activityCard}>
<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.primary}
style={styles.recentIcon}
>
<Ionicons name="barbell" size={20} color="#fff" />
</LinearGradient>
</View>
</View>
<Text style={styles.membershipEmail}>
{user.primaryEmailAddress?.emailAddress}
</Text>
<Text style={styles.membershipDate}>
Member since {new Date(user.createdAt!).toLocaleDateString()}
</Text>
<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>
{/* Recent Activity */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Recent Activity</Text>
<View style={styles.emptyState}>
<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>
</ScrollView>
{/* Bottom Spacer for Tab Bar */}
<View style={{ height: 100 }} />
</ScrollView>
</View>
);
}
function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
backgroundColor: theme.colors.background,
},
content: {
padding: 20,
scrollContent: {
paddingTop: 60,
},
header: {
marginBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 32,
},
greeting: {
fontSize: 16,
color: "#6b7280",
color: theme.colors.gray600,
fontWeight: "500",
marginBottom: 4,
},
name: {
fontSize: 32,
fontWeight: "bold",
color: "#1a1a1a",
fontWeight: "800",
color: theme.colors.gray900,
letterSpacing: -0.5,
},
statsContainer: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 24,
},
statCard: {
flex: 1,
backgroundColor: "white",
borderRadius: 12,
padding: 16,
alignItems: "center",
marginHorizontal: 4,
avatarContainer: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
statValue: {
fontSize: 24,
fontWeight: "bold",
color: "#1a1a1a",
marginTop: 8,
marginBottom: 4,
avatar: {
width: 56,
height: 56,
borderRadius: 20,
borderWidth: 2,
borderColor: "#fff",
},
statLabel: {
fontSize: 12,
color: "#6b7280",
textAlign: "center",
placeholderAvatar: {
width: 56,
height: 56,
borderRadius: 20,
backgroundColor: theme.colors.primary,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#fff",
},
section: {
paddingHorizontal: 20,
marginBottom: 24,
},
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
marginBottom: 12,
fontWeight: "700",
color: theme.colors.gray900,
},
actionButton: {
seeAll: {
fontSize: 14,
color: theme.colors.primary,
fontWeight: "600",
},
activityCard: {
gap: 12,
},
recentItem: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "white",
borderRadius: 12,
padding: 16,
marginBottom: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
borderRadius: 20,
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.6)",
},
actionIcon: {
recentIconContainer: {
marginRight: 16,
},
recentIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "#f0f9ff",
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
actionContent: {
recentInfo: {
flex: 1,
},
actionTitle: {
recentTitle: {
fontSize: 16,
fontWeight: "600",
color: "#1a1a1a",
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",
color: theme.colors.gray900,
marginBottom: 4,
},
membershipDate: {
recentSubtitle: {
fontSize: 12,
color: "#9ca3af",
color: theme.colors.gray500,
},
emptyState: {
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: {
recentValue: {
fontSize: 14,
color: "#9ca3af",
textAlign: "center",
fontWeight: "600",
color: theme.colors.gray900,
},
});
+202 -242
View File
@@ -1,307 +1,267 @@
import React from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
ScrollView,
} from "react-native";
import { useUser, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
import { useUser, useClerk } from "@clerk/clerk-expo";
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() {
const { user } = useUser();
const { signOut } = useAuth();
const router = useRouter();
const { signOut } = useClerk();
const handleLogout = 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 {
await signOut();
router.replace("/(auth)/sign-in");
} catch (error) {
Alert.alert("Error", "Failed to sign out");
}
},
},
]);
const handleSignOut = async () => {
try {
await signOut();
} catch (err) {
console.error("Error signing out:", err);
}
};
if (!user) {
return (
<View style={styles.container}>
<Text>Loading...</Text>
</View>
const confirmSignOut = () => {
Alert.alert(
"Sign Out",
"Are you sure you want to sign out?",
[
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
]
);
}
};
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
{/* Profile Header */}
<View style={styles.container}>
<GradientBackground variant="primary" style={styles.header}>
<View style={styles.profileCard}>
<View style={styles.avatarContainer}>
{user.imageUrl ? (
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0)}
</Text>
</View>
{user?.imageUrl ? (
<Image source={{ uri: user.imageUrl }} style={styles.avatar} />
) : (
<View style={styles.avatar}>
<View style={styles.placeholderAvatar}>
<Ionicons name="person" size={40} color="#fff" />
</View>
)}
<View style={styles.editBadge}>
<Ionicons name="pencil" size={12} color={theme.colors.primary} />
</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>
<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>
</GradientBackground>
{/* Account Information */}
<View style={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account Information</Text>
<View style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons name="mail-outline" size={20} color="#666" />
<Text style={styles.infoLabelText}>Email</Text>
</View>
<Text style={styles.infoValue}>
{user.primaryEmailAddress?.emailAddress}
</Text>
</View>
{user.primaryPhoneNumber && (
<View style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons name="call-outline" size={20} color="#666" />
<Text style={styles.infoLabelText}>Phone</Text>
</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 style={styles.infoRow}>
<View style={styles.infoLabel}>
<Ionicons
name="shield-checkmark-outline"
size={20}
color="#666"
/>
<Text style={styles.infoLabelText}>Email Verified</Text>
</View>
<Text style={styles.infoValue}>
{user.primaryEmailAddress?.verification?.status === "verified"
? "Yes"
: "No"}
</Text>
<Text style={styles.sectionTitle}>Account</Text>
<View style={[styles.infoCard, theme.shadows.subtle]}>
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.05)']}
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>
{/* 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>
<Text style={styles.sectionTitle}>Support</Text>
<View style={[styles.infoCard, theme.shadows.subtle]}>
<TouchableOpacity style={styles.infoRow}>
<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>
{/* 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>
<AnimatedButton
title="Sign Out"
onPress={confirmSignOut}
variant="danger"
style={styles.signOutButton}
icon={<Ionicons name="log-out-outline" size={20} color="#fff" />}
/>
{/* App Version */}
<Text style={styles.version}>Version 1.0.0</Text>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
backgroundColor: theme.colors.background,
},
content: {
padding: 20,
header: {
paddingTop: 60,
paddingBottom: 30,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
alignItems: 'center',
},
profileCard: {
backgroundColor: "white",
borderRadius: 16,
padding: 24,
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
marginBottom: 24,
alignItems: 'center',
},
avatarContainer: {
position: 'relative',
marginBottom: 16,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "#2563eb",
justifyContent: "center",
alignItems: "center",
width: 100,
height: 100,
borderRadius: 50,
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
avatarText: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
placeholderAvatar: {
width: 100,
height: 100,
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: {
fontSize: 24,
fontWeight: "bold",
color: "#1a1a1a",
fontSize: theme.typography.fontSize['2xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
marginBottom: 4,
},
email: {
fontSize: 16,
color: "#666",
marginBottom: 4,
fontSize: theme.typography.fontSize.sm,
color: 'rgba(255, 255, 255, 0.8)',
marginBottom: 12,
},
phone: {
fontSize: 14,
color: "#999",
memberBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
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: {
backgroundColor: "white",
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
marginBottom: 16,
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray900,
marginBottom: 12,
marginLeft: 4,
},
infoCard: {
backgroundColor: theme.colors.white,
borderRadius: theme.borderRadius.xl,
padding: 8,
borderWidth: 1,
borderColor: theme.colors.gray100,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
flexDirection: 'row',
alignItems: 'center',
padding: 12,
},
iconContainer: {
width: 36,
height: 36,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
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,
fontSize: 16,
color: "#1a1a1a",
marginLeft: 12,
fontWeight: "500",
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray900,
fontWeight: theme.typography.fontWeight.medium,
},
logoutButton: {
backgroundColor: "#ef4444",
paddingVertical: 16,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 8,
divider: {
height: 1,
backgroundColor: theme.colors.gray100,
marginLeft: 60,
},
signOutButton: {
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: {
textAlign: "center",
fontSize: 12,
color: "#999",
marginTop: 8,
marginBottom: 20,
textAlign: 'center',
marginTop: 24,
color: theme.colors.gray400,
fontSize: theme.typography.fontSize.xs,
},
});
@@ -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__
? 'https://f0a9b87c3831.ngrok-free.app'
? 'https://221260dc4a2b.ngrok-free.app'
: 'https://your-production-url.com'
export const API_ENDPOINTS = {
@@ -18,4 +18,12 @@ export const API_ENDPOINTS = {
HISTORY: '/api/attendance/history',
},
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();
}
},
};
+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
+9 -2
View File
@@ -2,7 +2,14 @@ import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
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 * from './schema'
export * from './schema'
export { eq, and, or, desc, asc } from 'drizzle-orm'
+95
View File
@@ -145,6 +145,96 @@ export const fitnessProfiles = sqliteTable("fitness_profiles", {
.$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", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
fitnessProfileId: text("fitness_profile_id")
.notNull()
.references(() => fitnessProfiles.id, { onDelete: "cascade" }),
recommendationText: text("recommendation_text").notNull(),
activityPlan: text("activity_plan").notNull(),
dietPlan: text("diet_plan").notNull(),
status: text("status", {
enum: ["pending", "approved", "rejected"],
})
.notNull()
.default("pending"),
generatedAt: integer("generated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
approvedAt: integer("approved_at", { mode: "timestamp" }),
approvedBy: text("approved_by"), // User ID of admin/trainer
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Client = typeof clients.$inferSelect;
@@ -157,3 +247,8 @@ export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
export type FitnessProfile = typeof fitnessProfiles.$inferSelect;
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 NewRecommendation = typeof recommendations.$inferInsert;