Compare commits
14
Commits
socialLogin
...
mobileUI
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
febcdc111e | ||
|
|
73d2c4c1ed | ||
|
|
fc12cecd30 | ||
|
|
803c205994 | ||
|
|
28b5b52a8f | ||
|
|
684481fd2c | ||
|
|
bf532ba35d | ||
|
|
0896dd46d1 | ||
|
|
e9ba9e2700 | ||
|
|
3ab9908588 | ||
|
|
0183a8ea6a | ||
|
|
5d1d881f16 | ||
|
|
1b50bfd2ee | ||
|
|
6cbed4e8d1 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# DeepSeek AI API Key
|
||||
# Get your API key from https://platform.deepseek.com/
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
Binary file not shown.
@@ -0,0 +1,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
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
Generated
+800
-582
File diff suppressed because it is too large.
Load diff
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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."
|
||||
}`;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+214
@@ -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,12 +37,14 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@testing-library/react-native": "^12.4.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
@@ -4028,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",
|
||||
@@ -4624,6 +4652,16 @@
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-native": {
|
||||
"version": "0.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.73.0.tgz",
|
||||
@@ -5560,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",
|
||||
@@ -6232,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",
|
||||
@@ -6457,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",
|
||||
@@ -6544,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",
|
||||
@@ -7099,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",
|
||||
@@ -9831,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",
|
||||
@@ -10428,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",
|
||||
@@ -11449,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",
|
||||
|
||||
@@ -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,12 +43,14 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@testing-library/react-native": "^12.4.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiClient } from "./client";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
|
||||
export interface Attendance {
|
||||
id: string;
|
||||
checkInTime: string;
|
||||
checkOutTime?: string;
|
||||
type: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const attendanceApi = {
|
||||
getHistory: async (token: string): Promise<Attendance[]> => {
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.ATTENDANCE.HISTORY, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
checkIn: async (type: string, token: string): Promise<void> => {
|
||||
try {
|
||||
await apiClient.post(
|
||||
API_ENDPOINTS.ATTENDANCE.CHECK_IN,
|
||||
{ type },
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
checkOut: async (token: string): Promise<void> => {
|
||||
try {
|
||||
await apiClient.post(
|
||||
API_ENDPOINTS.ATTENDANCE.CHECK_OUT,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../config/api';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to set the auth token for a request
|
||||
export const setAuthToken = (token: string) => {
|
||||
if (token) {
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
delete apiClient.defaults.headers.common['Authorization'];
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { API_BASE_URL } from "../config/api";
|
||||
import { apiClient } from "./client";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
|
||||
export interface FitnessProfile {
|
||||
id?: string;
|
||||
@@ -22,10 +22,10 @@ export const fitnessProfileApi = {
|
||||
try {
|
||||
console.log(
|
||||
"Getting fitness profile with URL:",
|
||||
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
);
|
||||
const response = await axios.get(
|
||||
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
|
||||
const response = await apiClient.get(
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -49,8 +49,8 @@ export const fitnessProfileApi = {
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
|
||||
const response = await apiClient.put(
|
||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
@@ -74,8 +74,8 @@ export const fitnessProfileApi = {
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/api/users/${data.clientId}/fitness-profile`,
|
||||
const response = await apiClient.post(
|
||||
`${API_ENDPOINTS.USERS}/${data.clientId}/fitness-profile`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiClient } from "./client";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
|
||||
export interface FitnessProfileData {
|
||||
userId: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
age: string;
|
||||
gender: "male" | "female" | "other";
|
||||
activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
|
||||
fitnessGoals: string[];
|
||||
exerciseHabits: string;
|
||||
dietHabits: string;
|
||||
medicalConditions: string;
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
createFitnessProfile: async (
|
||||
data: FitnessProfileData,
|
||||
token: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await apiClient.post(API_ENDPOINTS.PROFILE.FITNESS, data, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
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 axios from 'axios'
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../../config/api'
|
||||
|
||||
interface Attendance {
|
||||
id: string
|
||||
checkInTime: string
|
||||
checkOutTime?: string
|
||||
type: string
|
||||
notes?: string
|
||||
}
|
||||
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 {
|
||||
setLoading(true)
|
||||
const token = await getToken()
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.HISTORY}`
|
||||
console.log('Fetching attendance from:', url)
|
||||
const response = await axios.get(url, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!token) return
|
||||
|
||||
const data: Attendance[] = response.data
|
||||
console.log('Fetching attendance history')
|
||||
const data = await attendanceApi.getHistory(token)
|
||||
setHistory(data)
|
||||
|
||||
// Check if there's an active check-in (latest one has no checkOutTime)
|
||||
@@ -52,11 +66,9 @@ export default function AttendanceScreen() {
|
||||
const handleCheckIn = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
await axios.post(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_IN}`,
|
||||
{ type: 'gym' },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!token) return
|
||||
|
||||
await attendanceApi.checkIn('gym', token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked in successfully!')
|
||||
} catch (error: any) {
|
||||
@@ -68,11 +80,9 @@ export default function AttendanceScreen() {
|
||||
const handleCheckOut = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
await axios.post(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_OUT}`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!token) return
|
||||
|
||||
await attendanceApi.checkOut(token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked out successfully!')
|
||||
} catch (error: any) {
|
||||
@@ -84,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}>
|
||||
@@ -131,7 +199,7 @@ export default function AttendanceScreen() {
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
@@ -140,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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import axios from 'axios'
|
||||
import { useAuth } from '@clerk/clerk-expo'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
|
||||
import { profileApi } from '../api/profile'
|
||||
|
||||
interface FitnessProfile {
|
||||
height: string
|
||||
@@ -31,10 +31,11 @@ export default function WelcomeScreen() {
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { getToken } = useAuth()
|
||||
|
||||
const fitnessGoalsOptions = [
|
||||
'Weight Loss',
|
||||
'Muscle Gain',
|
||||
'Muscle Gain',
|
||||
'Improve Endurance',
|
||||
'Better Flexibility',
|
||||
'General Fitness',
|
||||
@@ -67,26 +68,31 @@ export default function WelcomeScreen() {
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const token = await getToken()
|
||||
if (!token) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const user = await SecureStore.getItemAsync('user')
|
||||
if (!user) {
|
||||
throw new Error('No user found')
|
||||
}
|
||||
|
||||
const userData = JSON.parse(user)
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}`,
|
||||
|
||||
await profileApi.createFitnessProfile(
|
||||
{
|
||||
userId: userData.id,
|
||||
...profile
|
||||
}
|
||||
},
|
||||
token
|
||||
)
|
||||
|
||||
if (response.status === 201) {
|
||||
Alert.alert('Success', 'Profile completed successfully!', [
|
||||
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
|
||||
])
|
||||
}
|
||||
Alert.alert('Success', 'Profile completed successfully!', [
|
||||
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
|
||||
])
|
||||
|
||||
|
||||
} catch (error: any) {
|
||||
console.log('Profile save error:', error)
|
||||
Alert.alert('Error', error.response?.data?.error || 'Failed to save profile. Please try again.')
|
||||
@@ -103,7 +109,7 @@ export default function WelcomeScreen() {
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Basic Information</Text>
|
||||
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.inputContainer, { flex: 1, marginRight: 8 }]}>
|
||||
<Text style={styles.label}>Height (cm)</Text>
|
||||
@@ -115,7 +121,7 @@ export default function WelcomeScreen() {
|
||||
placeholder="170"
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
|
||||
<Text style={styles.label}>Weight (kg)</Text>
|
||||
<TextInput
|
||||
@@ -139,7 +145,7 @@ export default function WelcomeScreen() {
|
||||
placeholder="25"
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
|
||||
<Text style={styles.label}>Gender</Text>
|
||||
<View style={styles.genderRow}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'https://201081f0849c.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`,
|
||||
},
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export const haptics = {
|
||||
light: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
},
|
||||
medium: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
}
|
||||
},
|
||||
heavy: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||
}
|
||||
},
|
||||
success: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
},
|
||||
warning: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
}
|
||||
},
|
||||
selection: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.selectionAsync();
|
||||
}
|
||||
},
|
||||
};
|
||||
File renamed without changes.
Binary file not shown.
@@ -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
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: "./fitai.db",
|
||||
// url: "./fitai.db",
|
||||
url: "../../apps/admin/data/fitai.db",
|
||||
},
|
||||
});
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user