Compare commits

..
16 Commits
Author SHA1 Message Date
dimitar a74fdcbbf4 playing around with build 2025-12-01 18:57:26 +01:00
dimitar 24ed0b6190 pedometar
problem with expogo, should be sorted before build
2025-11-28 18:08:52 +01:00
dimitar b0d751540e ngrok 2025-11-26 03:45:52 +01:00
dimitar 06237579f0 fix with stackview 2025-11-26 03:10:39 +01:00
dimitar c038a54f2e fitness profileand personal details
fix
2025-11-26 02:59:53 +01:00
dimitar b014734f33 db up 2025-11-26 02:41:44 +01:00
dimitar fe98c13206 db permisions fixed 2025-11-26 02:36:19 +01:00
dimitar febcdc111e redesign completed
alot of work ahead
2025-11-26 02:31:46 +01:00
dimitar 73d2c4c1ed claude take :( 2025-11-26 01:57:33 +01:00
dimitar fc12cecd30 db up 2025-11-26 01:34:10 +01:00
dimitar 803c205994 context and goals definition added 2025-11-26 01:30:15 +01:00
dimitar 28b5b52a8f context extended
maybe need polishing
2025-11-26 01:13:11 +01:00
dimitar 684481fd2c db consolidation done 2025-11-26 00:54:27 +01:00
dimitar bf532ba35d deepseek integrated succesfully 2025-11-24 19:25:34 +01:00
dimitar 0896dd46d1 ai rec refinement 2025-11-24 19:08:58 +01:00
dimitar e9ba9e2700 recommendatio edit added 2025-11-24 18:36:04 +01:00
118 changed files with 8715 additions and 1124 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# DeepSeek AI API Key
# Get your API key from https://platform.deepseek.com/
DEEPSEEK_API_KEY=your_deepseek_api_key_here
Regular → Executable
BIN
View File
Binary file not shown.
@@ -0,0 +1,49 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '../data/fitai.db');
const db = new Database(dbPath);
console.log('Creating fitness_goals table...');
try {
db.exec(`
CREATE TABLE IF NOT EXISTS fitness_goals (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
fitness_profile_id TEXT,
goal_type TEXT NOT NULL CHECK(goal_type IN ('weight_target', 'strength_milestone', 'endurance_target', 'flexibility_goal', 'habit_building', 'custom')),
title TEXT NOT NULL,
description TEXT,
target_value REAL,
current_value REAL,
unit TEXT,
start_date INTEGER NOT NULL,
target_date INTEGER,
completed_date INTEGER,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'abandoned', 'paused')),
progress REAL DEFAULT 0,
priority TEXT DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high')),
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles(userId) ON DELETE CASCADE
)
`);
// Create indexes for better query performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_fitness_goals_user_id ON fitness_goals(user_id);
CREATE INDEX IF NOT EXISTS idx_fitness_goals_status ON fitness_goals(status);
CREATE INDEX IF NOT EXISTS idx_fitness_goals_profile_id ON fitness_goals(fitness_profile_id);
`);
console.log('✅ fitness_goals table created successfully');
console.log('✅ Indexes created successfully');
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
} finally {
db.close();
}
@@ -1,7 +1,7 @@
const Database = require('better-sqlite3');
const 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,31 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
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 date = searchParams.get("date") || new Date().toISOString().split('T')[0];
// TODO: Fetch from database when activity_logs methods are added to IDatabase
console.log(`Fetching daily activity for user ${userId} on ${date}`);
// Return mock data for now
return NextResponse.json({
steps: 0,
calories: 0,
duration: 0,
date
});
} catch (error) {
console.error("Error fetching daily activity:", error);
return NextResponse.json(
{ error: "Failed to fetch daily activity" },
{ status: 500 }
);
}
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { steps, timestamp } = await req.json();
// TODO: Implement database persistence when activity_logs methods are added to IDatabase
console.log(`Syncing steps for user ${userId}: ${steps} steps at ${timestamp}`);
return NextResponse.json({ success: true, steps });
} catch (error) {
console.error("Error syncing steps:", error);
return NextResponse.json(
{ error: "Failed to sync steps" },
{ status: 500 }
);
}
}
@@ -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,42 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
// Return empty array if not authenticated (for testing)
if (!userId) {
return NextResponse.json([]);
}
const db = await getDatabase();
// Fetch recent fitness goals (last 5)
const recentGoals = await db.getFitnessGoalsByUserId(userId);
// Take only the 5 most recent and transform to format expected by mobile app
const activities = recentGoals.slice(0, 5).map((goal) => ({
title: goal.title,
subtitle: new Date(goal.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}),
duration: 45, // Mock duration for now
icon: goal.goalType === 'strength_milestone' ? 'barbell' :
goal.goalType === 'endurance_target' ? 'bicycle' :
'fitness',
gradient: goal.goalType === 'strength_milestone' ? 'primary' :
goal.goalType === 'endurance_target' ? 'success' :
'purple',
}));
return NextResponse.json(activities);
} catch (error) {
console.error("Error fetching recent activities:", error);
return NextResponse.json([], { status: 200 }); // Return empty array on error
}
}
@@ -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 }
);
}
}
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
// Check if profile already exists
const existingProfile = await db.getFitnessProfileByUserId(profileData.userId)
let profile
if (existingProfile) {
profile = await db.updateFitnessProfile(profileData.userId, profileData)
@@ -33,7 +33,7 @@ export async function POST(request: NextRequest) {
}
return NextResponse.json(
{
{
message: 'Fitness profile saved successfully',
profile
},
@@ -50,11 +50,12 @@ export async function POST(request: NextRequest) {
export async function GET(request: NextRequest) {
try {
const db = await getDatabase()
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
// If userId is provided, get specific profile
if (userId) {
const db = await getDatabase()
const profile = await db.getFitnessProfileByUserId(userId)
if (!profile) {
return NextResponse.json(
@@ -62,11 +63,24 @@ export async function GET(request: NextRequest) {
{ status: 404 }
)
}
return NextResponse.json({ profile })
// Return profile with mock activity data for now
// TODO: Fetch real activity data from activity_logs table
return NextResponse.json({
profile,
steps: 0,
calories: 0,
duration: 0
})
}
const profiles = await db.getAllFitnessProfiles()
return NextResponse.json({ profiles })
// For mobile app: return activity data without userId param
// This is a simplified endpoint for the mobile ActivityWidget
return NextResponse.json({
steps: 0,
calories: 0,
duration: 0
})
} catch (error) {
console.error('Get fitness profiles error:', error)
return NextResponse.json(
@@ -1,9 +1,11 @@
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 } = await req.json();
const { userId, useExternalModel } = await req.json();
if (!userId) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
@@ -21,87 +23,156 @@ export async function POST(req: Request) {
);
}
// Construct prompt for Ollama
const prompt = `
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.fitnessGoals.join(", ")}
- 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."
}
`;
// Call Ollama
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest", // Make sure this model is pulled
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 AI service" },
{ status: 500 }
);
// 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);
}
const aiData = await ollamaResponse.json();
console.log("Raw AI Response:", aiData.response);
let parsedResponse;
try {
// Helper to clean up the response
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*```$/, "");
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 }
);
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
console.log("Using DeepSeek AI model...");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
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 }
);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
// Fallback if model doesn't return perfect JSON despite instruction
console.error("Failed to parse AI response:", aiData.response);
return NextResponse.json(
{ error: "Invalid response format from AI model" },
{ 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, // Using userId as ID for now since it's 1:1
fitnessProfileId: profile.userId,
type: 'ai_plan',
content: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
@@ -28,16 +28,22 @@ export async function GET(request: Request) {
}
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
if (currentUserId !== targetUserId) {
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
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)
+66 -2
View File
@@ -26,6 +26,7 @@ export default function RecommendationsPage() {
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [useExternalModel, setUseExternalModel] = useState(false);
useEffect(() => {
fetchData();
@@ -56,7 +57,7 @@ export default function RecommendationsPage() {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
body: JSON.stringify({ userId, useExternalModel }),
});
if (!res.ok) {
@@ -98,6 +99,41 @@ export default function RecommendationsPage() {
}
};
const handleEdit = async (rec: Recommendation) => {
const newContent = prompt("Edit Advice:", rec.content);
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
if (newContent === null || newActivityPlan === null || newDietPlan === null) {
// User cancelled one of the prompts
return;
}
try {
const res = await fetch("/api/recommendations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: rec.id,
content: newContent,
activityPlan: newActivityPlan,
dietPlan: newDietPlan,
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update recommendation: ${errorData.error || 'Unknown error'}`);
} else {
alert("Recommendation updated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error("Error updating recommendation:", error);
alert("Failed to update recommendation.");
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
@@ -108,7 +144,29 @@ export default function RecommendationsPage() {
return (
<div className="container mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-8">AI Recommendations</h1>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">AI Recommendations</h1>
{/* Model Selection Toggle */}
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
<span className="text-sm font-medium text-gray-700">
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
</span>
<button
onClick={() => setUseExternalModel(!useExternalModel)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${useExternalModel ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${useExternalModel ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-xs text-gray-500">
{useExternalModel ? "External" : "Local"}
</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Generate Section */}
@@ -171,6 +229,12 @@ export default function RecommendationsPage() {
</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"
+50 -13
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
LayoutDashboard,
Users,
@@ -9,22 +10,59 @@ import {
CreditCard,
Settings,
LogOut,
Brain
Brain,
} from "lucide-react";
import { UserButton, useUser } from "@clerk/nextjs";
const menuItems = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
{ icon: Users, label: "Users", href: "/users" },
{ icon: Brain, label: "AI Recommendations", href: "/recommendations" },
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
{ icon: CreditCard, label: "Payments", href: "/payments" },
{ icon: Settings, label: "Settings", href: "/settings" },
];
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">
@@ -38,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"
}`}
: "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>
);
})}
+88
View File
@@ -0,0 +1,88 @@
import { getDatabase } from '@/lib/database';
import type { FitnessGoal } from '@/lib/database/types';
// Import types from database package
import type {
FitnessProfile as DBFitnessProfile,
Recommendation as DBRecommendation,
} from '@fitai/database';
export interface AIContext {
profile: DBFitnessProfile;
activeGoals: FitnessGoal[];
completedGoals: FitnessGoal[];
recentRecommendations: DBRecommendation[];
progressSummary: {
goalsCompleted: number;
goalsActive: number;
averageProgress: number;
};
}
/**
* Build comprehensive AI context for a user
* Aggregates fitness profile, goals, and recommendation history
*/
export async function buildAIContext(userId: string): Promise<AIContext> {
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
throw new Error(`Fitness profile not found for user ${userId}`);
}
// Fetch all fitness goals
const allGoals = await db.getFitnessGoalsByUserId(userId);
// Separate active and completed goals
const activeGoals = allGoals.filter((g) => g.status === 'active');
const completedGoals = allGoals.filter((g) => g.status === 'completed');
// Get all recommendations
const allRecommendations = await db.getRecommendationsByUserId(userId);
// Filter recent recommendations (last 7 days)
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const recentRecommendations = allRecommendations
.filter((r) => new Date(r.createdAt) > sevenDaysAgo)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5);
// Calculate progress summary
const progressSummary = {
goalsCompleted: completedGoals.length,
goalsActive: activeGoals.length,
averageProgress:
activeGoals.length > 0
? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) / activeGoals.length
: 0,
};
return {
profile,
activeGoals,
completedGoals,
recentRecommendations,
progressSummary,
};
}
/**
* Format goal for display in AI prompt
*/
export function formatGoalForPrompt(goal: FitnessGoal): string {
const progress = goal.progress || 0;
const current = goal.currentValue || 0;
const target = goal.targetValue;
const unit = goal.unit || '';
if (target) {
return `${goal.title}: ${current}/${target} ${unit} (${progress.toFixed(1)}% complete)`;
}
return `${goal.title}: ${progress.toFixed(1)}% complete`;
}
+91
View File
@@ -0,0 +1,91 @@
import type { AIContext } from './ai-context';
import { formatGoalForPrompt } from './ai-context';
/**
* Build enhanced AI prompt with comprehensive user context
* Includes profile, goals, progress, and recommendation history
*/
export function buildEnhancedPrompt(context: AIContext): string {
const { profile, activeGoals, completedGoals, recentRecommendations, progressSummary } = context;
// Build goals section
const activeGoalsText =
activeGoals.length > 0
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join('\n')
: '- No active goals set';
const completedGoalsText =
completedGoals.length > 0
? completedGoals.slice(0, 3).map((g) => g.title).join(', ')
: 'None yet';
// Build context about recent recommendations
const recommendationContextText =
recentRecommendations.length > 0
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
: 'This is a new user or they haven\'t received recent recommendations. Provide comprehensive guidance.';
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
## User Profile
- Height: ${profile.height || 'Not specified'} cm
- Weight: ${profile.weight || 'Not specified'} kg
- Age: ${profile.age || 'Not specified'}
- Gender: ${profile.gender || 'Not specified'}
- Primary Goal: ${profile.fitnessGoal || 'General fitness'}
- Activity Level: ${profile.activityLevel || 'Not specified'}
- Medical Conditions: ${profile.medicalConditions || 'None'}
- Allergies: ${profile.allergies || 'None'}
- Injuries: ${profile.injuries || 'None'}
## Active Goals (${activeGoals.length})
${activeGoalsText}
## Recent Progress
- Goals Completed: ${progressSummary.goalsCompleted}
- Active Goals: ${progressSummary.goalsActive}
- Average Progress on Active Goals: ${progressSummary.averageProgress.toFixed(1)}%
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ''}
## Recommendation History
${recommendationContextText}
## Task
Generate a personalized daily recommendation that:
1. Acknowledges their progress on active goals
2. Provides specific, actionable steps toward goal completion
3. Builds on previous recommendations without being repetitive
4. Adapts to their current progress level
5. Takes into account their medical conditions, allergies, and injuries
Respond in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
{
"recommendationText": "Personalized motivation and progress acknowledgment (2-3 sentences)",
"activityPlan": "Specific workout plan aligned with active goals (detailed, 3-5 exercises or activities)",
"dietPlan": "Nutrition plan supporting their goals and restrictions (detailed, include meals and portions)"
}`;
}
/**
* Build basic prompt for users without complete context
* Fallback when goals or other data is missing
*/
export function buildBasicPrompt(profile: any): string {
return `You are a professional fitness trainer and nutritionist.
Generate a detailed daily recommendation for a user with the following profile:
- Height: ${profile.height} cm
- Weight: ${profile.weight} kg
- Age: ${profile.age}
- Gender: ${profile.gender}
- Goal: ${profile.fitnessGoal}
- Activity Level: ${profile.activityLevel}
- Medical Conditions: ${profile.medicalConditions || "None"}
- Injuries: ${profile.injuries || "None"}
Please provide the response in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
{
"recommendationText": "General advice and motivation for today.",
"activityPlan": "Detailed workout or activity plan for today.",
"dietPlan": "Detailed meal plan for today."
}`;
}
+168
View File
@@ -614,6 +614,174 @@ export class SQLiteDatabase implements IDatabase {
}
}
// Fitness Goals operations
async createFitnessGoal(goalData: Omit<import('./types').FitnessGoal, 'createdAt' | 'updatedAt'>): Promise<import('./types').FitnessGoal> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const goal: import('./types').FitnessGoal = {
...goalData,
createdAt: now,
updatedAt: now
}
const stmt = this.db.prepare(
`INSERT INTO fitness_goals (
id, user_id, fitness_profile_id, goal_type, title, description,
target_value, current_value, unit, start_date, target_date,
completed_date, status, progress, priority, notes, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
goal.id,
goal.userId,
goal.fitnessProfileId || null,
goal.goalType,
goal.title,
goal.description || null,
goal.targetValue || null,
goal.currentValue || null,
goal.unit || null,
goal.startDate.toISOString(),
goal.targetDate ? goal.targetDate.toISOString() : null,
goal.completedDate ? goal.completedDate.toISOString() : null,
goal.status,
goal.progress,
goal.priority,
goal.notes || null,
goal.createdAt.toISOString(),
goal.updatedAt.toISOString()
)
return goal
}
async getFitnessGoalById(id: string): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM fitness_goals WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToFitnessGoal(row) : null
}
async getFitnessGoalsByUserId(userId: string, status?: string): Promise<import('./types').FitnessGoal[]> {
if (!this.db) throw new Error('Database not connected')
let query = 'SELECT * FROM fitness_goals WHERE user_id = ?'
const params: any[] = [userId]
if (status) {
query += ' AND status = ?'
params.push(status)
}
query += ' ORDER BY created_at DESC'
const stmt = this.db.prepare(query)
const rows = stmt.all(...params)
return rows.map(row => this.mapRowToFitnessGoal(row))
}
async updateFitnessGoal(id: string, updates: Partial<import('./types').FitnessGoal>): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'createdAt')
if (fields.length === 0) return this.getFitnessGoalById(id)
// Map camelCase to snake_case for database columns
const columnMap: Record<string, string> = {
userId: 'user_id',
fitnessProfileId: 'fitness_profile_id',
goalType: 'goal_type',
targetValue: 'target_value',
currentValue: 'current_value',
startDate: 'start_date',
targetDate: 'target_date',
completedDate: 'completed_date',
updatedAt: 'updated_at'
}
const setClause = fields.map(field => `${columnMap[field] || field} = ?`).join(', ')
const values = fields.map(field => {
const val = (updates as any)[field]
return val instanceof Date ? val.toISOString() : val
})
values.push(new Date().toISOString()) // updatedAt
values.push(id)
const stmt = this.db.prepare(`UPDATE fitness_goals SET ${setClause}, updated_at = ? WHERE id = ?`)
stmt.run(values)
return this.getFitnessGoalById(id)
}
async deleteFitnessGoal(id: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM fitness_goals WHERE id = ?')
const result = stmt.run(id)
return (result.changes || 0) > 0
}
async updateGoalProgress(id: string, currentValue: number): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
// Get the goal to calculate progress
const goal = await this.getFitnessGoalById(id)
if (!goal) return null
let progress = goal.progress
if (goal.targetValue && goal.targetValue > 0) {
progress = Math.min(100, (currentValue / goal.targetValue) * 100)
}
const stmt = this.db.prepare(
'UPDATE fitness_goals SET current_value = ?, progress = ?, updated_at = ? WHERE id = ?'
)
stmt.run(currentValue, progress, new Date().toISOString(), id)
return this.getFitnessGoalById(id)
}
async completeGoal(id: string): Promise<import('./types').FitnessGoal | null> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const stmt = this.db.prepare(
'UPDATE fitness_goals SET status = ?, progress = ?, completed_date = ?, updated_at = ? WHERE id = ?'
)
stmt.run('completed', 100, now.toISOString(), now.toISOString(), id)
return this.getFitnessGoalById(id)
}
private mapRowToFitnessGoal(row: any): import('./types').FitnessGoal {
return {
id: row.id,
userId: row.user_id,
fitnessProfileId: row.fitness_profile_id,
goalType: row.goal_type,
title: row.title,
description: row.description,
targetValue: row.target_value,
currentValue: row.current_value,
unit: row.unit,
startDate: new Date(row.start_date),
targetDate: row.target_date ? new Date(row.target_date) : undefined,
completedDate: row.completed_date ? new Date(row.completed_date) : undefined,
status: row.status,
progress: row.progress,
priority: row.priority,
notes: row.notes,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at)
}
}
async getDashboardStats(): Promise<{
totalUsers: number;
activeClients: number;
+35
View File
@@ -61,6 +61,27 @@ export interface Recommendation {
approvedBy?: string;
}
export interface FitnessGoal {
id: string;
userId: string;
fitnessProfileId?: string;
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom";
title: string;
description?: string;
targetValue?: number;
currentValue?: number;
unit?: string;
startDate: Date;
targetDate?: Date;
completedDate?: Date;
status: "active" | "completed" | "abandoned" | "paused";
progress: number;
priority: "low" | "medium" | "high";
notes?: string;
createdAt: Date;
updatedAt: Date;
}
// Database Interface - allows us to swap implementations
export interface IDatabase {
// Connection management
@@ -121,6 +142,20 @@ export interface IDatabase {
): 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;
+16
View File
@@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle
+182
View File
@@ -0,0 +1,182 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.anonymous.fitai'
defaultConfig {
applicationId 'com.anonymous.fitai'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -0,0 +1,38 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fitai"/>
<data android:scheme="exp+fitai"/>
</intent-filter>
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,61 @@
package com.anonymous.fitai
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}
@@ -0,0 +1,56 @@
package com.anonymous.fitai
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -0,0 +1 @@
<resources/>
@@ -0,0 +1,6 @@
<resources>
<color name="splashscreen_background">#ffffff</color>
<color name="iconBackground">#ffffff</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">FitAI</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
</resources>
@@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</resources>
+24
View File
@@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
+67
View File
@@ -0,0 +1,67 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
android.minSdkVersion=26
+39
View File
@@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'FitAI'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
+41 -6
View File
@@ -14,22 +14,57 @@
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"permissions": [
"android.permission.ACTIVITY_RECOGNITION"
],
"package": "com.anonymous.fitai"
},
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSMotionUsageDescription": "This app uses the pedometer to track your steps."
},
"bundleIdentifier": "com.anonymous.fitai"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 26
}
}
],
"expo-router",
"expo-font"
"expo-font",
[
"react-native-health-connect",
{
"permissions": [
{
"accessType": "read",
"recordType": "Steps"
},
{
"accessType": "read",
"recordType": "TotalCaloriesBurned"
},
{
"accessType": "read",
"recordType": "ExerciseSession"
}
]
}
]
],
"scheme": "fitai"
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

After

Width:  |  Height:  |  Size: 97 KiB

+30
View File
@@ -0,0 +1,30 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
.xcode.env.local
# Bundle artifacts
*.jsbundle
# CocoaPods
/Pods/
@@ -0,0 +1,436 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* FitAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FitAI.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = FitAI/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = FitAI/Info.plist; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = FitAI/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = FitAI/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* FitAI-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "FitAI-Bridging-Header.h"; path = "FitAI/FitAI-Bridging-Header.h"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* FitAI */ = {
isa = PBXGroup;
children = (
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* FitAI-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
);
name = FitAI;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* FitAI */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* FitAI.app */,
);
name = Products;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = FitAI/Supporting;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* FitAI */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "FitAI" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = FitAI;
productName = FitAI;
productReference = 13B07F961A680F5B00A75B9A /* FitAI.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "FitAI" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* FitAI */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-FitAI-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-FitAI/Pods-FitAI-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FitAI/Pods-FitAI-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = FitAI/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "com.anonymous.fitai";
PRODUCT_NAME = "FitAI";
SWIFT_OBJC_BRIDGING_HEADER = "FitAI/FitAI-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
TARGETED_DEVICE_FAMILY = "1,2";
CODE_SIGN_ENTITLEMENTS = FitAI/FitAI.entitlements;
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = FitAI/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "com.anonymous.fitai";
PRODUCT_NAME = "FitAI";
SWIFT_OBJC_BRIDGING_HEADER = "FitAI/FitAI-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
TARGETED_DEVICE_FAMILY = "1,2";
CODE_SIGN_ENTITLEMENTS = FitAI/FitAI.entitlements;
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "FitAI" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "FitAI" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "FitAI.app"
BlueprintName = "FitAI"
ReferencedContainer = "container:FitAI.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "FitAITests.xctest"
BlueprintName = "FitAITests"
ReferencedContainer = "container:FitAI.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "FitAI.app"
BlueprintName = "FitAI"
ReferencedContainer = "container:FitAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "FitAI.app"
BlueprintName = "FitAI"
ReferencedContainer = "container:FitAI.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+70
View File
@@ -0,0 +1,70 @@
import Expo
import React
import ReactAppDependencyProvider
@UIApplicationMain
public class AppDelegate: ExpoAppDelegate {
var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
bindReactNativeFactory(factory)
#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
#endif
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// Linking API
public override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
}
// Universal Links
public override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
}
}
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
// Extension point for config-plugins
override func sourceURL(for bridge: RCTBridge) -> URL? {
// needed to return the correct URL for expo-dev-client.
bridge.bundleURL ?? bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
@@ -0,0 +1,3 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "App-Icon-1024x1024@1x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
}
@@ -0,0 +1,20 @@
{
"colors": [
{
"color": {
"components": {
"alpha": "1.000",
"blue": "1.00000000000000",
"green": "1.00000000000000",
"red": "1.00000000000000"
},
"color-space": "srgb"
},
"idiom": "universal"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
@@ -0,0 +1,23 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "image@2x.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "image@3x.png",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

+93
View File
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FitAI</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>fitai</string>
<string>com.anonymous.fitai</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+fitai</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your microphone</string>
<key>NSMotionUsageDescription</key>
<string>This app uses the pedometer to track your steps.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24053.1"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<scene sceneID="EXPO-SCENE-1">
<objects>
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
<constraints>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="83fcb9b545b870ba44c24f0feeb116490c499c52"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="61d16215e44b98e39d0a2c74fdbfaaa22601b12c"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="f934da460e9ab5acae3ad9987d5b676a108796c1"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="d6a0be88096b36fb132659aa90203d39139deda9"/>
</constraints>
<color key="backgroundColor" name="SplashScreenBackground"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="0.0"/>
</scene>
</scenes>
<resources>
<image name="SplashScreenLegacy" width="414" height="736"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<namedColor name="SplashScreenBackground">
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
</namedColor>
</resources>
</document>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
</dict>
</plist>
+60
View File
@@ -0,0 +1,60 @@
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
def ccache_enabled?(podfile_properties)
# Environment variable takes precedence
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
# Fall back to Podfile properties
podfile_properties['apple.ccacheEnabled'] == 'true'
end
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
prepare_react_native_project!
target 'FitAI' do
use_expo_modules!
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
config_command = [
'npx',
'expo-modules-autolinking',
'react-native-config',
'--json',
'--platform',
'ios'
]
end
config = use_native_modules!(config_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => ccache_enabled?(podfile_properties),
)
end
end
+6
View File
@@ -0,0 +1,6 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"ios.forceStaticLinking": "[]",
"apple.privacyManifestAggregationEnabled": "true"
}
+344
View File
@@ -11,6 +11,7 @@
"@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0",
@@ -18,22 +19,29 @@
"axios": "^1.6.0",
"expo": "~54.0.23",
"expo-auth-session": "^7.0.8",
"expo-build-properties": "~1.0.9",
"expo-camera": "~17.0.0",
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-dev-client": "^6.0.18",
"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",
"expo-secure-store": "~15.0.7",
"expo-sensors": "~15.0.7",
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.47.0",
"react-native": "0.81.5",
"react-native-health-connect": "^3.5.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.0",
"react-native-web": "^0.21.2",
"zod": "^3.22.0"
},
@@ -4029,6 +4037,29 @@
}
}
},
"node_modules/@react-native-community/datetimepicker": {
"version": "8.4.4",
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": ">=52.0.0",
"react": "*",
"react-native": "*",
"react-native-windows": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"react-native-windows": {
"optional": true
}
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
@@ -5571,6 +5602,12 @@
"node": ">=0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -6243,6 +6280,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -6468,6 +6555,61 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -6555,6 +6697,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",
@@ -7040,6 +7194,31 @@
"react-native": "*"
}
},
"node_modules/expo-build-properties": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-1.0.9.tgz",
"integrity": "sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-build-properties/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/expo-camera": {
"version": "17.0.9",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
@@ -7086,6 +7265,56 @@
"expo": "*"
}
},
"node_modules/expo-dev-client": {
"version": "6.0.18",
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.18.tgz",
"integrity": "sha512-8QKWvhsoZpMkecAMlmWoRHnaTNiPS3aO7E42spZOMjyiaNRJMHZsnB8W2b63dt3Yg3oLyskLAoI8IOmnqVX8vA==",
"license": "MIT",
"dependencies": {
"expo-dev-launcher": "6.0.18",
"expo-dev-menu": "7.0.17",
"expo-dev-menu-interface": "2.0.0",
"expo-manifests": "~1.0.9",
"expo-updates-interface": "~2.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-launcher": {
"version": "6.0.18",
"resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.18.tgz",
"integrity": "sha512-JTtcIfNvHO9PTdRJLmHs+7HJILXXZjF95jxgzu6hsJrgsTg/AZDtEsIt/qa6ctEYQTqrLdsLDgDhiXVel3AoQA==",
"license": "MIT",
"dependencies": {
"expo-dev-menu": "7.0.17",
"expo-manifests": "~1.0.9"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-menu": {
"version": "7.0.17",
"resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.17.tgz",
"integrity": "sha512-NIu7TdaZf+A8+DROa6BB6lDfxjXxwaD+Q8QbNSVa0E0x6yl3P0ZJ80QbD2cCQeBzlx3Ufd3hNhczQWk4+A29HQ==",
"license": "MIT",
"dependencies": {
"expo-dev-menu-interface": "2.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-menu-interface": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz",
"integrity": "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.19",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.19.tgz",
@@ -7110,6 +7339,32 @@
"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-json-utils": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
"license": "MIT"
},
"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",
@@ -7124,6 +7379,19 @@
"react-native": "*"
}
},
"node_modules/expo-manifests": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.9.tgz",
"integrity": "sha512-5uVgvIo0o+xBcEJiYn4uVh72QSIqyHePbYTWXYa4QamXd+AmGY/yWmtHaNqCqjsPLCwXyn4OxPr7jXJCeTWLow==",
"license": "MIT",
"dependencies": {
"@expo/config": "~12.0.10",
"expo-json-utils": "~0.15.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.22",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz",
@@ -7265,6 +7533,19 @@
"expo": "*"
}
},
"node_modules/expo-sensors": {
"version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-15.0.7.tgz",
"integrity": "sha512-TGUxRx/Ss7KGgfWo453YF64ENucw6oYryPiu/8I3ZZuf114xQPRxAbsZohPLaVUUGuaUyWbDsb0eRsmuKUzBnQ==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-server": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz",
@@ -7287,6 +7568,15 @@
"react-native": "*"
}
},
"node_modules/expo-updates-interface": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-web-browser": {
"version": "15.0.9",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.9.tgz",
@@ -9842,6 +10132,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -10439,6 +10735,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",
@@ -11425,6 +11733,27 @@
}
}
},
"node_modules/react-native-health-connect": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-native-health-connect/-/react-native-health-connect-3.5.0.tgz",
"integrity": "sha512-lIfiRps+jjDs83SApQ3AzBzU2cITwn8nPRl3v+52imWJPVw7qltAXkduioOfqFqOQJuEOwd1Wei3qtMTYbTaag==",
"license": "MIT",
"workspaces": [
"example",
"docs"
],
"engines": {
"node": ">= 16.0.0"
},
"funding": {
"url": "https://github.com/matinzd/react-native-health-connect?sponsor=1"
},
"peerDependencies": {
"@expo/config-plugins": ">= 6.0.2",
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
@@ -11460,6 +11789,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",
+10 -2
View File
@@ -5,8 +5,8 @@
"private": true,
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"build": "expo build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
@@ -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",
@@ -24,22 +25,29 @@
"axios": "^1.6.0",
"expo": "~54.0.23",
"expo-auth-session": "^7.0.8",
"expo-build-properties": "~1.0.9",
"expo-camera": "~17.0.0",
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-dev-client": "^6.0.18",
"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",
"expo-secure-store": "~15.0.7",
"expo-sensors": "~15.0.7",
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.47.0",
"react-native": "0.81.5",
"react-native-health-connect": "^3.5.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.0",
"react-native-web": "^0.21.2",
"zod": "^3.22.0"
},
+22 -38
View File
@@ -1,7 +1,7 @@
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "@clerk/clerk-expo";
import { useEffect } from "react";
import { CustomTabBar } from "../../components/CustomTabBar";
export default function TabLayout() {
const { isSignedIn, isLoaded } = useAuth();
@@ -25,56 +25,40 @@ export default function TabLayout() {
return (
<Tabs
tabBar={(props) => <CustomTabBar {...props} />}
screenOptions={{
tabBarActiveTintColor: "#2563eb",
tabBarInactiveTintColor: "#6b7280",
headerShown: true,
headerStyle: {
backgroundColor: "#fff",
},
headerTitleStyle: {
fontWeight: "600",
},
headerShown: false, // We'll use custom headers in screens or layout
tabBarShowLabel: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
headerTitle: "FitAI",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="attendance"
options={{
title: "Attendance",
headerTitle: "Attendance",
tabBarIcon: ({ color, size }) => (
<Ionicons name="calendar" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
headerTitle: "My Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="goals"
options={{
title: "Goals",
headerTitle: "Fitness Goals",
tabBarIcon: ({ color, size }) => (
<Ionicons name="trophy" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="recommendations"
options={{
title: "AI",
}}
/>
<Tabs.Screen
name="attendance"
options={{
title: "Attendance",
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
}}
/>
</Tabs>
+195 -71
View File
@@ -1,15 +1,39 @@
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useAuth } from '@clerk/clerk-expo'
import { LinearGradient } from 'expo-linear-gradient'
import { Ionicons } from '@expo/vector-icons'
import { attendanceApi, Attendance } from '../../api/attendance'
import { theme } from '../../styles/theme'
import { Animated } from 'react-native'
export default function AttendanceScreen() {
const { getToken, userId } = useAuth()
const [loading, setLoading] = useState(true)
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
const [history, setHistory] = useState<Attendance[]>([])
const pulseAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
if (activeCheckIn) {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
)
pulse.start()
return () => pulse.stop()
}
}, [activeCheckIn])
const fetchAttendance = async () => {
try {
@@ -70,42 +94,100 @@ export default function AttendanceScreen() {
if (loading && !history.length) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#000" />
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
)
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Attendance</Text>
<Text style={styles.subtitle}>Track your gym visits</Text>
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<Text style={styles.title}>Attendance</Text>
<Text style={styles.subtitle}>Track your gym visits</Text>
</LinearGradient>
<View style={styles.actionContainer}>
{activeCheckIn ? (
<View style={styles.activeCard}>
<Text style={styles.activeText}>Currently Checked In</Text>
<Text style={styles.timeText}>
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
<TouchableOpacity style={styles.checkOutButton} onPress={handleCheckOut}>
<Text style={styles.buttonText}>Check Out</Text>
<LinearGradient
colors={['rgba(16, 185, 129, 0.15)', 'rgba(5, 150, 105, 0.1)']}
style={[styles.activeCard, theme.shadows.medium]}
>
<View style={styles.activeCardContent}>
<View style={styles.activeIconContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.activeIcon}
>
<Ionicons name="checkmark-circle" size={32} color="#fff" />
</LinearGradient>
</View>
<View style={styles.activeTextContainer}>
<Text style={styles.activeText}>Currently Checked In</Text>
<Text style={styles.timeText}>
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
</View>
<TouchableOpacity onPress={handleCheckOut} activeOpacity={0.8}>
<LinearGradient
colors={theme.gradients.danger}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.checkOutButton, theme.shadows.medium]}
>
<Ionicons name="log-out-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>Check Out</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
) : (
<TouchableOpacity style={styles.checkInButton} onPress={handleCheckIn}>
<Text style={styles.buttonText}>Check In</Text>
</TouchableOpacity>
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<TouchableOpacity onPress={handleCheckIn} activeOpacity={0.8}>
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.checkInButton, theme.shadows.glow]}
>
<Ionicons name="log-in-outline" size={24} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.checkInButtonText}>Check In</Text>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
)}
</View>
<Text style={styles.sectionTitle}>Recent History</Text>
{history.map((item) => (
<View key={item.id} style={styles.historyItem}>
<View>
<Text style={styles.dateText}>
{new Date(item.checkInTime).toLocaleDateString()}
</Text>
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
<LinearGradient
key={item.id}
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
style={[styles.historyItem, theme.shadows.medium]}
>
<View style={styles.historyLeft}>
<View style={styles.historyIconContainer}>
<LinearGradient
colors={item.checkOutTime ? theme.gradients.success : theme.gradients.primary}
style={styles.historyIcon}
>
<Ionicons
name={item.checkOutTime ? "checkmark" : "time-outline"}
size={16}
color="#fff"
/>
</LinearGradient>
</View>
<View>
<Text style={styles.dateText}>
{new Date(item.checkInTime).toLocaleDateString()}
</Text>
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
</View>
</View>
<View style={styles.timeContainer}>
<Text style={styles.historyTime}>
@@ -117,7 +199,7 @@ export default function AttendanceScreen() {
</Text>
)}
</View>
</View>
</LinearGradient>
))}
</ScrollView>
)
@@ -126,105 +208,147 @@ export default function AttendanceScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: theme.colors.background,
},
content: {
padding: 20,
paddingBottom: 20,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
paddingTop: 60,
paddingBottom: 24,
paddingHorizontal: 24,
marginBottom: 24,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
title: {
fontSize: 28,
fontWeight: 'bold',
fontSize: theme.typography.fontSize['3xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
marginBottom: 4,
color: '#1a1a1a',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 24,
fontSize: theme.typography.fontSize.base,
color: 'rgba(255, 255, 255, 0.9)',
},
actionContainer: {
marginBottom: 32,
paddingHorizontal: 20,
},
checkInButton: {
backgroundColor: '#000',
paddingVertical: 16,
borderRadius: 12,
paddingVertical: 20,
paddingHorizontal: 24,
borderRadius: theme.borderRadius.xl,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
justifyContent: 'center',
},
checkInButtonText: {
color: theme.colors.white,
fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.semibold,
},
checkOutButton: {
backgroundColor: '#ff3b30',
paddingVertical: 16,
borderRadius: 12,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
},
buttonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
color: theme.colors.white,
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.semibold,
},
activeCard: {
backgroundColor: '#fff',
padding: 20,
borderRadius: 16,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: '#e0e0e0',
borderColor: 'rgba(16, 185, 129, 0.2)',
},
activeCardContent: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
activeIconContainer: {
marginRight: 16,
},
activeIcon: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
},
activeTextContainer: {
flex: 1,
},
activeText: {
fontSize: 18,
fontWeight: '600',
color: '#2e7d32',
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray900,
marginBottom: 4,
},
timeText: {
fontSize: 14,
color: '#666',
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray600,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.semibold,
marginBottom: 16,
color: '#1a1a1a',
paddingHorizontal: 20,
color: theme.colors.gray900,
},
historyItem: {
backgroundColor: '#fff',
padding: 16,
borderRadius: 12,
borderRadius: theme.borderRadius.xl,
marginBottom: 12,
marginHorizontal: 20,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.1)',
},
historyLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
historyIconContainer: {
marginRight: 4,
},
historyIcon: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
dateText: {
fontSize: 16,
fontWeight: '500',
color: '#1a1a1a',
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray900,
},
typeText: {
fontSize: 12,
color: '#666',
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray600,
marginTop: 2,
},
timeContainer: {
alignItems: 'flex-end',
},
historyTime: {
fontSize: 14,
color: '#444',
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray700,
},
})
+233 -215
View File
@@ -1,210 +1,251 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
Alert,
} from "react-native";
import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
content: string;
status: "pending" | "completed";
createdAt: string;
}
import React, { useState, useCallback, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Animated, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../../styles/theme';
import { GoalProgressCard } from '../../components/GoalProgressCard';
import { GoalCreationModal } from '../../components/GoalCreationModal';
import { useUser, useAuth } from "@clerk/clerk-expo";
import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from '../../services/fitnessGoals';
import { useFocusEffect } from 'expo-router';
export default function GoalsScreen() {
const { userId, getToken } = useAuth();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const { user } = useUser();
const { getToken } = useAuth();
const [goals, setGoals] = useState<FitnessGoal[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const fabScale = useRef(new Animated.Value(1)).current;
const fetchRecommendations = async () => {
if (!userId) return;
const loadGoals = useCallback(async () => {
if (!user?.id) return;
try {
const token = await getToken();
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
} else {
console.error("Failed to fetch recommendations");
}
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
setGoals(loadedGoals);
} catch (error) {
console.error("Error fetching recommendations:", error);
} finally {
setLoading(false);
setRefreshing(false);
console.error('Error loading goals:', error);
}
};
}, [user?.id]); // Removed getToken from dependencies
useEffect(() => {
fetchRecommendations();
}, [userId]);
useFocusEffect(
useCallback(() => {
loadGoals();
}, [loadGoals])
);
const onRefresh = () => {
const onRefresh = async () => {
setRefreshing(true);
fetchRecommendations();
await loadGoals();
setRefreshing(false);
};
const toggleStatus = async (id: string, currentStatus: string) => {
const newStatus = currentStatus === "pending" ? "completed" : "pending";
// Optimistic update
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: newStatus } : r)
);
try {
const token = await getToken();
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
id,
status: newStatus,
}),
});
if (!response.ok) {
// Revert on failure
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
}
} catch (error) {
console.error(error);
// Revert on error
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
}
const handleCreateGoal = async (newGoal: CreateGoalData) => {
const token = await getToken();
await fitnessGoalsService.createGoal(newGoal, token);
await loadGoals();
setIsModalVisible(false);
};
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term"
) => {
const items = recommendations.filter((r) => r.type === type);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{items.length === 0 ? (
<Text style={styles.emptyText}>No goals set yet.</Text>
) : (
items.map((rec) => (
<TouchableOpacity
key={rec.id}
style={[
styles.card,
rec.status === "completed" && styles.cardCompleted,
]}
onPress={() => toggleStatus(rec.id, rec.status)}
>
<View style={styles.checkbox}>
{rec.status === "completed" && (
<Ionicons name="checkmark" size={16} color="#fff" />
)}
</View>
<View style={styles.cardContent}>
<Text
style={[
styles.cardText,
rec.status === "completed" && styles.cardTextCompleted,
]}
>
{rec.content}
</Text>
<Text style={styles.dateText}>
{new Date(rec.createdAt).toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
))
)}
</View>
);
const handleCompleteGoal = async (goal: FitnessGoal) => {
const token = await getToken();
await fitnessGoalsService.completeGoal(goal.id, token);
await loadGoals();
};
if (loading && !refreshing) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
);
}
const handleDeleteGoal = async (goalId: string) => {
const token = await getToken();
await fitnessGoalsService.deleteGoal(goalId, token);
await loadGoals();
};
const activeGoals = goals.filter(g => g.status === 'active');
const completedGoals = goals.filter(g => g.status === 'completed');
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>My Goals</Text>
<Text style={styles.headerSubtitle}>
Track your fitness journey progress
</Text>
</View>
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
}
>
<LinearGradient
colors={theme.gradients.primary}
style={styles.header}
>
<View>
<Text style={styles.headerTitle}>Fitness Goals</Text>
<Text style={styles.headerSubtitle}>
Track your fitness journey progress
</Text>
</View>
</LinearGradient>
{renderSection("Short Term Goals", "short_term")}
{renderSection("Medium Term Goals", "medium_term")}
{renderSection("Long Term Goals", "long_term")}
{/* Stats Summary */}
{goals.length > 0 && (
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{activeGoals.length}</Text>
<Text style={styles.statLabel}>Active</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{completedGoals.length}</Text>
<Text style={styles.statLabel}>Completed</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{activeGoals.length > 0
? Math.round(
activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
activeGoals.length
)
: 0}%
</Text>
<Text style={styles.statLabel}>Avg Progress</Text>
</View>
</View>
)}
<View style={styles.footer} />
</ScrollView>
{/* Active Goals */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Active Goals ({activeGoals.length})
</Text>
{activeGoals.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="flag-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyText}>No active goals yet</Text>
<Text style={styles.emptySubtext}>
Tap the + button to create your first goal
</Text>
</View>
) : (
activeGoals.map((goal) => (
<GoalProgressCard
key={goal.id}
goal={goal}
onComplete={() => handleCompleteGoal(goal)}
onDelete={() => handleDeleteGoal(goal.id)}
/>
))
)}
</View>
{/* Completed Goals */}
{completedGoals.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Completed Goals ({completedGoals.length})
</Text>
{completedGoals.map((goal) => (
<GoalProgressCard
key={goal.id}
goal={goal}
onDelete={() => handleDeleteGoal(goal.id)}
/>
))}
</View>
)}
<View style={styles.footer} />
</ScrollView>
{/* Floating Action Button */}
<Animated.View style={[styles.fabContainer, { transform: [{ scale: fabScale }] }]}>
<TouchableOpacity
onPress={() => setIsModalVisible(true)}
onPressIn={() => {
Animated.spring(fabScale, {
toValue: 0.9,
friction: 8,
tension: 100,
useNativeDriver: true,
}).start();
}}
onPressOut={() => {
Animated.spring(fabScale, {
toValue: 1,
friction: 8,
tension: 100,
useNativeDriver: true,
}).start();
}}
activeOpacity={0.9}
>
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.fab}
>
<Ionicons name="add" size={28} color="#fff" />
</LinearGradient>
</TouchableOpacity>
</Animated.View>
{/* Create Goal Modal */}
<GoalCreationModal
visible={isModalVisible}
onClose={() => setIsModalVisible(false)}
onSubmit={handleCreateGoal}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f3f4f6",
backgroundColor: theme.colors.background,
},
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
scrollContent: {
paddingBottom: 20,
},
header: {
padding: 20,
backgroundColor: "#fff",
padding: 24,
paddingTop: 60,
paddingBottom: 24,
marginBottom: 10,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
headerTitle: {
fontSize: 28,
fontWeight: "bold",
color: "#111827",
fontSize: theme.typography.fontSize['3xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
},
headerSubtitle: {
fontSize: 16,
color: "#6b7280",
fontSize: theme.typography.fontSize.base,
color: "rgba(255, 255, 255, 0.9)",
marginTop: 4,
},
statsContainer: {
flexDirection: "row",
padding: 16,
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: theme.colors.white,
padding: 16,
borderRadius: theme.borderRadius.xl,
alignItems: 'center',
...theme.shadows.medium,
borderWidth: 1,
borderColor: "rgba(59, 130, 246, 0.1)",
},
statValue: {
fontSize: theme.typography.fontSize['2xl'],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.primary,
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: "#6b7280",
fontWeight: "500",
},
section: {
padding: 20,
paddingTop: 10,
@@ -215,58 +256,35 @@ const styles = StyleSheet.create({
color: "#374151",
marginBottom: 12,
},
emptyText: {
fontStyle: "italic",
color: "#9ca3af",
},
card: {
backgroundColor: "#fff",
padding: 16,
borderRadius: 12,
marginBottom: 12,
flexDirection: "row",
alignItems: "flex-start",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
cardCompleted: {
backgroundColor: "#f0fdf4", // light green
borderColor: "#bbf7d0",
borderWidth: 1,
},
checkbox: {
width: 24,
height: 24,
borderRadius: 6,
borderWidth: 2,
borderColor: "#d1d5db",
marginRight: 12,
marginTop: 2,
justifyContent: "center",
emptyState: {
alignItems: "center",
backgroundColor: "#fff",
paddingVertical: 40,
},
cardContent: {
flex: 1,
},
cardText: {
emptyText: {
fontSize: 16,
color: "#1f2937",
lineHeight: 24,
fontWeight: "500",
color: "#6b7280",
marginTop: 12,
},
cardTextCompleted: {
textDecorationLine: "line-through",
color: "#9ca3af",
},
dateText: {
fontSize: 12,
emptySubtext: {
fontSize: 14,
color: "#9ca3af",
marginTop: 4,
},
footer: {
height: 40,
height: 100,
},
fabContainer: {
position: "absolute",
right: 20,
bottom: 110, // Adjusted for tab bar height
},
fab: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
...theme.shadows.glow,
},
});
+169 -254
View File
@@ -1,320 +1,235 @@
import React from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
} from "react-native";
import { useUser } from "@clerk/clerk-expo";
import { View, Text, StyleSheet, ScrollView, RefreshControl, Image } from "react-native";
import { useUser, useAuth } from "@clerk/clerk-expo";
import { LinearGradient } from "expo-linear-gradient";
import { useState, useCallback, useEffect } 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";
import { apiClient } from "../../services/apiService";
import { useStepCount } from "../../hooks/useStepCount";
export default function HomeScreen() {
const { user, isLoaded } = useUser();
const router = useRouter();
const { user } = useUser();
const { getToken } = useAuth();
const [refreshing, setRefreshing] = useState(false);
const [profile, setProfile] = useState<{ steps: number; calories: number; duration: number } | null>(null);
const [recentActivities, setRecentActivities] = useState<Array<any>>([]);
const { steps: deviceSteps, available } = useStepCount();
if (!isLoaded || !user) {
return (
<View style={styles.container}>
<Text>Loading...</Text>
</View>
);
}
const loadData = useCallback(async () => {
try {
const token = await getToken();
const fitness = await apiClient.getFitnessProfile(token);
setProfile({ steps: fitness.steps, calories: fitness.calories, duration: fitness.duration });
const recent = await apiClient.getRecentActivities(token);
setRecentActivities(recent);
} catch (e) {
console.error(e);
}
}, [getToken]);
const firstName = user.firstName || "User";
const greeting = getGreeting();
// Load data on component mount
useEffect(() => {
loadData();
}, []);
const onRefresh = useCallback(() => {
setRefreshing(true);
loadData().finally(() => setRefreshing(false));
}, [loadData]);
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good Morning";
if (hour < 18) return "Good Afternoon";
return "Good Evening";
};
// Use device steps if available, otherwise use profile steps
const displayedSteps = available ? deviceSteps : profile?.steps ?? 0;
console.log('HomeScreen Debug:', {
available,
deviceSteps,
profileSteps: profile?.steps,
displayedSteps
});
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 */}
{profile && (
<ActivityWidget steps={displayedSteps} calories={profile.calories} duration={profile.duration} />
)}
{/* 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" />
{recentActivities.map((act: any, idx: number) => (
<View key={idx} style={styles.recentItem}>
<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 as any)[act.gradient || 'primary']} style={styles.recentIcon}>
<Ionicons name={act.icon || 'barbell'} size={20} color="#fff" />
</LinearGradient>
</View>
<View style={styles.recentInfo}>
<Text style={styles.recentTitle}>{act.title}</Text>
<Text style={styles.recentSubtitle}>{act.subtitle}</Text>
</View>
<Text style={styles.recentValue}>{act.duration}m</Text>
</LinearGradient>
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>View Schedule</Text>
<Text style={styles.actionSubtitle}>
Check your upcoming classes
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="card-outline" size={24} color="#8b5cf6" />
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>Payments</Text>
<Text style={styles.actionSubtitle}>View payment history</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
))}
</View>
{/* Membership Info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Membership</Text>
<View style={styles.membershipCard}>
<View style={styles.membershipHeader}>
<Text style={styles.membershipType}>Basic Plan</Text>
<View style={styles.statusBadge}>
<Text style={styles.statusText}>Active</Text>
</View>
</View>
<Text style={styles.membershipEmail}>
{user.primaryEmailAddress?.emailAddress}
</Text>
<Text style={styles.membershipDate}>
Member since {new Date(user.createdAt!).toLocaleDateString()}
</Text>
</View>
</View>
{/* Recent Activity */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Recent Activity</Text>
<View style={styles.emptyState}>
<Ionicons name="barbell-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyStateText}>No recent activity</Text>
<Text style={styles.emptyStateSubtext}>
Check in to start tracking your workouts
</Text>
</View>
</View>
</View>
</ScrollView>
{/* Bottom Spacer for Tab Bar */}
<View style={{ height: 100 }} />
</ScrollView>
</View>
);
}
function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
backgroundColor: theme.colors.background,
},
content: {
padding: 20,
scrollContent: {
paddingTop: 60,
},
header: {
marginBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 32,
},
greeting: {
fontSize: 16,
color: "#6b7280",
color: theme.colors.gray600,
fontWeight: "500",
marginBottom: 4,
},
name: {
fontSize: 32,
fontWeight: "bold",
color: "#1a1a1a",
fontWeight: "800",
color: theme.colors.gray900,
letterSpacing: -0.5,
},
statsContainer: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 24,
},
statCard: {
flex: 1,
backgroundColor: "white",
borderRadius: 12,
padding: 16,
alignItems: "center",
marginHorizontal: 4,
avatarContainer: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
statValue: {
fontSize: 24,
fontWeight: "bold",
color: "#1a1a1a",
marginTop: 8,
marginBottom: 4,
avatar: {
width: 56,
height: 56,
borderRadius: 20,
borderWidth: 2,
borderColor: "#fff",
},
statLabel: {
fontSize: 12,
color: "#6b7280",
textAlign: "center",
placeholderAvatar: {
width: 56,
height: 56,
borderRadius: 20,
backgroundColor: theme.colors.primary,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#fff",
},
section: {
paddingHorizontal: 20,
marginBottom: 24,
},
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
marginBottom: 12,
fontWeight: "700",
color: theme.colors.gray900,
},
actionButton: {
seeAll: {
fontSize: 14,
color: theme.colors.primary,
fontWeight: "600",
},
activityCard: {
gap: 12,
},
recentItem: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "white",
borderRadius: 12,
padding: 16,
marginBottom: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
borderRadius: 20,
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.6)",
},
actionIcon: {
recentIconContainer: {
marginRight: 16,
},
recentIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "#f0f9ff",
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
actionContent: {
recentInfo: {
flex: 1,
},
actionTitle: {
recentTitle: {
fontSize: 16,
fontWeight: "600",
color: "#1a1a1a",
marginBottom: 2,
},
actionSubtitle: {
fontSize: 14,
color: "#6b7280",
},
membershipCard: {
backgroundColor: "white",
borderRadius: 12,
padding: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
membershipHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
membershipType: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
},
statusBadge: {
backgroundColor: "#dcfce7",
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 12,
fontWeight: "600",
color: "#16a34a",
},
membershipEmail: {
fontSize: 14,
color: "#6b7280",
color: theme.colors.gray900,
marginBottom: 4,
},
membershipDate: {
recentSubtitle: {
fontSize: 12,
color: "#9ca3af",
color: theme.colors.gray500,
},
emptyState: {
backgroundColor: "white",
borderRadius: 12,
padding: 32,
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
emptyStateText: {
fontSize: 16,
fontWeight: "600",
color: "#6b7280",
marginTop: 12,
marginBottom: 4,
},
emptyStateSubtext: {
recentValue: {
fontSize: 14,
color: "#9ca3af",
textAlign: "center",
fontWeight: "600",
color: theme.colors.gray900,
},
});
+202 -240
View File
@@ -1,307 +1,269 @@
import React from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
ScrollView,
} from "react-native";
import { useUser, useAuth } from "@clerk/clerk-expo";
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
import { useUser, useClerk } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
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 { signOut } = useClerk();
const router = useRouter();
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} onPress={() => router.push('/personal-details')}>
<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} onPress={() => router.push('/fitness-profile')}>
<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,
},
});
+426 -203
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react';
import {
View,
Text,
@@ -7,13 +7,15 @@ import {
TouchableOpacity,
ActivityIndicator,
Alert,
} from "react-native";
import { useRouter } from "expo-router";
import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
import { Input } from "../components/Input";
import { Picker } from "../components/Picker";
import { API_BASE_URL } from "../config/api";
TextInput,
Platform,
} from 'react-native';
import { useRouter, Stack } from 'expo-router';
import { useAuth } from '@clerk/clerk-expo';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
import { API_BASE_URL } from '../config/api';
interface FitnessProfileData {
height?: number;
@@ -27,6 +29,28 @@ interface FitnessProfileData {
injuries?: string;
}
const GENDER_OPTIONS = [
{ label: 'Male', value: 'male', icon: 'male' },
{ label: 'Female', value: 'female', icon: 'female' },
{ label: 'Other', value: 'other', icon: 'transgender' },
];
const FITNESS_GOAL_OPTIONS = [
{ label: 'Weight Loss', value: 'weight_loss', icon: 'trending-down', color: theme.colors.danger },
{ label: 'Muscle Gain', value: 'muscle_gain', icon: 'barbell', color: theme.colors.primary },
{ label: 'Endurance', value: 'endurance', icon: 'bicycle', color: theme.colors.success },
{ label: 'Flexibility', value: 'flexibility', icon: 'body', color: theme.colors.purple },
{ label: 'General Fitness', value: 'general_fitness', icon: 'fitness', color: theme.colors.warning },
];
const ACTIVITY_LEVEL_OPTIONS = [
{ label: 'Sedentary', value: 'sedentary', description: 'Little to no exercise' },
{ label: 'Light', value: 'light', description: '1-3 days/week' },
{ label: 'Moderate', value: 'moderate', description: '3-5 days/week' },
{ label: 'Active', value: 'active', description: '6-7 days/week' },
{ label: 'Very Active', value: 'very_active', description: 'Intense daily training' },
];
export default function FitnessProfileScreen() {
const router = useRouter();
const { userId, getToken } = useAuth();
@@ -34,29 +58,6 @@ export default function FitnessProfileScreen() {
const [fetchingProfile, setFetchingProfile] = useState(true);
const [profileData, setProfileData] = useState<FitnessProfileData>({});
const genderOptions = [
{ label: "Male", value: "male" },
{ label: "Female", value: "female" },
{ label: "Other", value: "other" },
{ label: "Prefer not to say", value: "prefer_not_to_say" },
];
const fitnessGoalOptions = [
{ label: "Weight Loss", value: "weight_loss" },
{ label: "Muscle Gain", value: "muscle_gain" },
{ label: "Endurance", value: "endurance" },
{ label: "Flexibility", value: "flexibility" },
{ label: "General Fitness", value: "general_fitness" },
];
const activityLevelOptions = [
{ label: "Sedentary", value: "sedentary" },
{ label: "Lightly Active", value: "lightly_active" },
{ label: "Moderately Active", value: "moderately_active" },
{ label: "Very Active", value: "very_active" },
{ label: "Extremely Active", value: "extremely_active" },
];
useEffect(() => {
fetchProfile();
}, []);
@@ -65,8 +66,7 @@ export default function FitnessProfileScreen() {
try {
setFetchingProfile(true);
const token = await getToken();
const apiUrl = `${API_BASE_URL}` || "http://localhost:3000";
const response = await fetch(`${apiUrl}/api/fitness-profile`, {
const response = await fetch(`${API_BASE_URL}/api/profile/fitness?userId=${userId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
@@ -79,17 +79,21 @@ export default function FitnessProfileScreen() {
height: data.profile.height,
weight: data.profile.weight,
age: data.profile.age,
gender: data.profile.gender || "",
fitnessGoal: data.profile.fitnessGoal || "",
activityLevel: data.profile.activityLevel || "",
medicalConditions: data.profile.medicalConditions || "",
allergies: data.profile.allergies || "",
injuries: data.profile.injuries || "",
gender: data.profile.gender || '',
fitnessGoal: Array.isArray(data.profile.fitnessGoals)
? data.profile.fitnessGoals[0]
: (typeof data.profile.fitnessGoals === 'string'
? JSON.parse(data.profile.fitnessGoals)[0]
: ''),
activityLevel: data.profile.activityLevel || '',
medicalConditions: data.profile.medicalConditions || '',
allergies: data.profile.allergies || '',
injuries: data.profile.injuries || '',
});
}
}
} catch (error) {
console.error("Error fetching profile:", error);
console.error('Error fetching profile:', error);
} finally {
setFetchingProfile(false);
}
@@ -99,28 +103,41 @@ export default function FitnessProfileScreen() {
try {
setLoading(true);
const token = await getToken();
const apiUrl = `${API_BASE_URL}/api/fitness-profile` || "http://localhost:3000";
const response = await fetch(`${apiUrl}`, {
method: "POST",
// Prepare data with userId and convert fitnessGoal to fitnessGoals array
const dataToSave = {
userId: userId,
height: profileData.height,
weight: profileData.weight,
age: profileData.age,
gender: profileData.gender,
fitnessGoals: profileData.fitnessGoal ? [profileData.fitnessGoal] : [],
activityLevel: profileData.activityLevel,
medicalConditions: profileData.medicalConditions,
allergies: profileData.allergies,
injuries: profileData.injuries,
};
const response = await fetch(`${API_BASE_URL}/api/profile/fitness`, {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(profileData),
body: JSON.stringify(dataToSave),
});
if (response.ok) {
Alert.alert("Success", "Fitness profile saved successfully!", [
{ text: "OK", onPress: () => router.back() },
Alert.alert('Success', 'Fitness profile saved successfully!', [
{ text: 'OK', onPress: () => router.back() },
]);
} else {
const error = await response.json();
Alert.alert("Error", error.message || "Failed to save profile");
Alert.alert('Error', error.error || 'Failed to save profile');
}
} catch (error) {
console.error("Error saving profile:", error);
Alert.alert("Error", "Failed to save fitness profile");
console.error('Error saving profile:', error);
Alert.alert('Error', 'Failed to save fitness profile');
} finally {
setLoading(false);
}
@@ -133,216 +150,422 @@ export default function FitnessProfileScreen() {
if (fetchingProfile) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
>
<Ionicons name="arrow-back" size={24} color="#1f2937" />
</TouchableOpacity>
<Text style={styles.headerTitle}>Fitness Profile</Text>
<View style={styles.headerSpacer} />
</View>
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.container}>
{/* Header */}
<LinearGradient colors={theme.gradients.primary} style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}>Fitness Profile</Text>
<View style={{ width: 40 }} />
</LinearGradient>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Basic Information */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Basic Information</Text>
<Input
label="Height (cm)"
value={profileData.height?.toString() || ""}
onChangeText={(text) =>
updateField("height", text ? parseFloat(text) : undefined)
}
keyboardType="decimal-pad"
placeholder="e.g., 175"
/>
<Input
label="Weight (kg)"
value={profileData.weight?.toString() || ""}
onChangeText={(text) =>
updateField("weight", text ? parseFloat(text) : undefined)
}
keyboardType="decimal-pad"
placeholder="e.g., 70"
/>
<Input
label="Age"
value={profileData.age?.toString() || ""}
onChangeText={(text) =>
updateField("age", text ? parseInt(text, 10) : undefined)
}
keyboardType="number-pad"
placeholder="e.g., 25"
/>
<Picker
label="Gender"
value={profileData.gender || ""}
onValueChange={(value) => updateField("gender", value)}
items={genderOptions}
/>
<View style={styles.card}>
<View style={styles.row}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Height (cm)</Text>
<View style={styles.inputContainer}>
<Ionicons name="resize-outline" size={20} color={theme.colors.gray400} />
<TextInput
style={styles.input}
value={profileData.height?.toString() || ''}
onChangeText={(text) =>
updateField('height', text ? parseFloat(text) : undefined)
}
keyboardType="decimal-pad"
placeholder="175"
placeholderTextColor={theme.colors.gray400}
/>
</View>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Weight (kg)</Text>
<View style={styles.inputContainer}>
<Ionicons name="scale-outline" size={20} color={theme.colors.gray400} />
<TextInput
style={styles.input}
value={profileData.weight?.toString() || ''}
onChangeText={(text) =>
updateField('weight', text ? parseFloat(text) : undefined)
}
keyboardType="decimal-pad"
placeholder="70"
placeholderTextColor={theme.colors.gray400}
/>
</View>
</View>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Age</Text>
<View style={styles.inputContainer}>
<Ionicons name="calendar-outline" size={20} color={theme.colors.gray400} />
<TextInput
style={styles.input}
value={profileData.age?.toString() || ''}
onChangeText={(text) =>
updateField('age', text ? parseInt(text, 10) : undefined)
}
keyboardType="number-pad"
placeholder="25"
placeholderTextColor={theme.colors.gray400}
/>
</View>
</View>
</View>
</View>
{/* Gender Selection */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Fitness Goals</Text>
<Picker
label="Primary Goal"
value={profileData.fitnessGoal || ""}
onValueChange={(value) => updateField("fitnessGoal", value)}
items={fitnessGoalOptions}
/>
<Picker
label="Activity Level"
value={profileData.activityLevel || ""}
onValueChange={(value) => updateField("activityLevel", value)}
items={activityLevelOptions}
/>
<Text style={styles.sectionTitle}>Gender</Text>
<View style={styles.optionsRow}>
{GENDER_OPTIONS.map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.optionCard,
profileData.gender === option.value && styles.optionCardActive,
]}
onPress={() => updateField('gender', option.value)}
>
<Ionicons
name={option.icon as any}
size={24}
color={
profileData.gender === option.value
? theme.colors.primary
: theme.colors.gray400
}
/>
<Text
style={[
styles.optionLabel,
profileData.gender === option.value && styles.optionLabelActive,
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Fitness Goal */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Health Information</Text>
<Input
label="Medical Conditions (optional)"
value={profileData.medicalConditions || ""}
onChangeText={(text) => updateField("medicalConditions", text)}
placeholder="e.g., Asthma, diabetes..."
multiline
numberOfLines={3}
style={styles.textArea}
/>
<Input
label="Allergies (optional)"
value={profileData.allergies || ""}
onChangeText={(text) => updateField("allergies", text)}
placeholder="e.g., Peanuts, latex..."
multiline
numberOfLines={3}
style={styles.textArea}
/>
<Input
label="Injuries (optional)"
value={profileData.injuries || ""}
onChangeText={(text) => updateField("injuries", text)}
placeholder="e.g., Previous knee injury..."
multiline
numberOfLines={3}
style={styles.textArea}
/>
<Text style={styles.sectionTitle}>Primary Fitness Goal</Text>
<View style={styles.card}>
{FITNESS_GOAL_OPTIONS.map((option, index) => (
<React.Fragment key={option.value}>
<TouchableOpacity
style={styles.listItem}
onPress={() => updateField('fitnessGoal', option.value)}
>
<View style={[styles.iconCircle, { backgroundColor: `${option.color}20` }]}>
<Ionicons name={option.icon as any} size={20} color={option.color} />
</View>
<Text style={styles.listItemText}>{option.label}</Text>
{profileData.fitnessGoal === option.value && (
<Ionicons name="checkmark-circle" size={24} color={theme.colors.primary} />
)}
</TouchableOpacity>
{index < FITNESS_GOAL_OPTIONS.length - 1 && <View style={styles.divider} />}
</React.Fragment>
))}
</View>
</View>
{/* Activity Level */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Activity Level</Text>
<View style={styles.card}>
{ACTIVITY_LEVEL_OPTIONS.map((option, index) => (
<React.Fragment key={option.value}>
<TouchableOpacity
style={styles.listItem}
onPress={() => updateField('activityLevel', option.value)}
>
<View style={{ flex: 1 }}>
<Text style={styles.listItemText}>{option.label}</Text>
<Text style={styles.listItemDescription}>{option.description}</Text>
</View>
{profileData.activityLevel === option.value && (
<Ionicons name="checkmark-circle" size={24} color={theme.colors.primary} />
)}
</TouchableOpacity>
{index < ACTIVITY_LEVEL_OPTIONS.length - 1 && <View style={styles.divider} />}
</React.Fragment>
))}
</View>
</View>
{/* Health Information */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Health Information (Optional)</Text>
<View style={styles.card}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Medical Conditions</Text>
<TextInput
style={[styles.textArea]}
value={profileData.medicalConditions || ''}
onChangeText={(text) => updateField('medicalConditions', text)}
placeholder="e.g., Asthma, diabetes..."
placeholderTextColor={theme.colors.gray400}
multiline
numberOfLines={3}
textAlignVertical="top"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Allergies</Text>
<TextInput
style={[styles.textArea]}
value={profileData.allergies || ''}
onChangeText={(text) => updateField('allergies', text)}
placeholder="e.g., Peanuts, latex..."
placeholderTextColor={theme.colors.gray400}
multiline
numberOfLines={3}
textAlignVertical="top"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Injuries</Text>
<TextInput
style={[styles.textArea]}
value={profileData.injuries || ''}
onChangeText={(text) => updateField('injuries', text)}
placeholder="e.g., Previous knee injury..."
placeholderTextColor={theme.colors.gray400}
multiline
numberOfLines={3}
textAlignVertical="top"
/>
</View>
</View>
</View>
</ScrollView>
{/* Save Button */}
<View style={styles.footer}>
<TouchableOpacity
style={[styles.saveButton, loading && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={20}
color="white"
/>
<Text style={styles.saveButtonText}>Save Profile</Text>
</>
)}
<LinearGradient colors={theme.gradients.primary} style={styles.saveButtonGradient}>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<Text style={styles.saveButtonText}>Save Profile</Text>
</>
)}
</LinearGradient>
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f9fafb",
backgroundColor: theme.colors.background,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#f9fafb",
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.colors.background,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingTop: 60,
paddingBottom: 16,
backgroundColor: "white",
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingBottom: 20,
paddingHorizontal: 20,
},
backButton: {
padding: 4,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
},
headerSpacer: {
width: 32,
fontSize: theme.typography.fontSize['2xl'],
fontWeight: theme.typography.fontWeight.bold,
color: '#fff',
},
scrollView: {
flex: 1,
},
content: {
scrollContent: {
padding: 20,
paddingBottom: 100,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 16,
fontWeight: "600",
color: "#1f2937",
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray900,
marginBottom: 12,
},
card: {
backgroundColor: '#fff',
borderRadius: theme.borderRadius.xl,
padding: 16,
...theme.shadows.subtle,
borderWidth: 1,
borderColor: theme.colors.gray100,
},
row: {
flexDirection: 'row',
gap: 12,
marginBottom: 16,
},
inputGroup: {
flex: 1,
marginBottom: 16,
},
label: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray700,
marginBottom: 8,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.colors.gray50,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.gray200,
paddingHorizontal: 12,
gap: 8,
},
input: {
flex: 1,
paddingVertical: 12,
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray900,
},
textArea: {
height: 80,
textAlignVertical: "top",
paddingTop: 12,
backgroundColor: theme.colors.gray50,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.gray200,
padding: 12,
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray900,
minHeight: 80,
},
optionsRow: {
flexDirection: 'row',
gap: 12,
},
optionCard: {
flex: 1,
backgroundColor: '#fff',
borderRadius: theme.borderRadius.lg,
padding: 16,
alignItems: 'center',
gap: 8,
borderWidth: 2,
borderColor: theme.colors.gray200,
...theme.shadows.subtle,
},
optionCardActive: {
borderColor: theme.colors.primary,
backgroundColor: `${theme.colors.primary}10`,
},
optionLabel: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.medium,
color: theme.colors.gray600,
},
optionLabelActive: {
color: theme.colors.primary,
fontWeight: theme.typography.fontWeight.bold,
},
listItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
gap: 12,
},
iconCircle: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
listItemText: {
flex: 1,
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.medium,
color: theme.colors.gray900,
},
listItemDescription: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray500,
marginTop: 2,
},
divider: {
height: 1,
backgroundColor: theme.colors.gray100,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: theme.colors.gray100,
...theme.shadows.medium,
},
saveButton: {
backgroundColor: "#2563eb",
borderRadius: 8,
padding: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: 8,
marginBottom: 40,
borderRadius: theme.borderRadius.lg,
overflow: 'hidden',
},
saveButtonGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
gap: 8,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonText: {
color: "white",
fontSize: 16,
fontWeight: "600",
marginLeft: 8,
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.bold,
color: '#fff',
},
});
+269
View File
@@ -0,0 +1,269 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
TextInput,
Alert,
Platform,
} from 'react-native';
import { useRouter, Stack } from 'expo-router';
import { useUser } from '@clerk/clerk-expo';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
export default function PersonalDetailsScreen() {
const router = useRouter();
const { user } = useUser();
const [loading, setLoading] = useState(false);
// Initialize with current user data
const [formData, setFormData] = useState({
firstName: user?.firstName || '',
lastName: user?.lastName || '',
email: user?.primaryEmailAddress?.emailAddress || '',
phone: user?.primaryPhoneNumber?.phoneNumber || '',
});
const handleSave = async () => {
setLoading(true);
try {
// Update user profile via Clerk
await user?.update({
firstName: formData.firstName,
lastName: formData.lastName,
});
Alert.alert('Success', 'Personal details updated successfully', [
{ text: 'OK', onPress: () => router.back() },
]);
} catch (error) {
console.error('Error updating personal details:', error);
Alert.alert('Error', 'Failed to update personal details. Please try again.');
} finally {
setLoading(false);
}
};
const updateField = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.container}>
{/* Header */}
<LinearGradient
colors={theme.gradients.primary}
style={styles.header}
>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}>Personal Details</Text>
<View style={{ width: 40 }} />
</LinearGradient>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* First Name */}
<View style={styles.field}>
<Text style={styles.label}>First Name *</Text>
<View style={styles.inputContainer}>
<Ionicons name="person-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={styles.input}
value={formData.firstName}
onChangeText={(value) => updateField('firstName', value)}
placeholder="Enter first name"
placeholderTextColor={theme.colors.gray400}
/>
</View>
</View>
{/* Last Name */}
<View style={styles.field}>
<Text style={styles.label}>Last Name *</Text>
<View style={styles.inputContainer}>
<Ionicons name="person-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={styles.input}
value={formData.lastName}
onChangeText={(value) => updateField('lastName', value)}
placeholder="Enter last name"
placeholderTextColor={theme.colors.gray400}
/>
</View>
</View>
{/* Email (Read-only) */}
<View style={styles.field}>
<Text style={styles.label}>Email</Text>
<View style={[styles.inputContainer, styles.disabledInput]}>
<Ionicons name="mail-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={[styles.input, styles.disabledText]}
value={formData.email}
editable={false}
placeholderTextColor={theme.colors.gray400}
/>
<Ionicons name="lock-closed-outline" size={16} color={theme.colors.gray400} />
</View>
<Text style={styles.helperText}>Email cannot be changed here</Text>
</View>
{/* Phone (Read-only for now) */}
<View style={styles.field}>
<Text style={styles.label}>Phone Number</Text>
<View style={[styles.inputContainer, styles.disabledInput]}>
<Ionicons name="call-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={[styles.input, styles.disabledText]}
value={formData.phone || 'Not set'}
editable={false}
placeholderTextColor={theme.colors.gray400}
/>
<Ionicons name="lock-closed-outline" size={16} color={theme.colors.gray400} />
</View>
<Text style={styles.helperText}>Phone number cannot be changed here</Text>
</View>
</ScrollView>
{/* Save Button */}
<View style={styles.footer}>
<TouchableOpacity
style={[styles.saveButton, loading && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={loading}
>
<LinearGradient
colors={theme.gradients.primary}
style={styles.saveButtonGradient}
>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<Text style={styles.saveButtonText}>
{loading ? 'Saving...' : 'Save Changes'}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingBottom: 20,
paddingHorizontal: 20,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: theme.typography.fontSize['2xl'],
fontWeight: theme.typography.fontWeight.bold,
color: '#fff',
},
content: {
flex: 1,
},
scrollContent: {
padding: 20,
paddingBottom: 100,
},
field: {
marginBottom: 24,
},
label: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray700,
marginBottom: 8,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.gray200,
paddingHorizontal: 16,
...theme.shadows.subtle,
},
inputIcon: {
marginRight: 12,
},
input: {
flex: 1,
paddingVertical: 16,
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray900,
},
disabledInput: {
backgroundColor: theme.colors.gray50,
},
disabledText: {
color: theme.colors.gray500,
},
helperText: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray500,
marginTop: 6,
marginLeft: 4,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: theme.colors.gray100,
...theme.shadows.medium,
},
saveButton: {
borderRadius: theme.borderRadius.lg,
overflow: 'hidden',
},
saveButtonGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
gap: 8,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonText: {
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.bold,
color: '#fff',
},
});
@@ -0,0 +1,157 @@
import React from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
const { width } = Dimensions.get('window');
interface ActivityWidgetProps {
steps: number;
calories: number;
duration: number; // in minutes
}
export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) {
return (
<View style={styles.container}>
<LinearGradient
colors={theme.gradients.dark}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, theme.shadows.medium]}
>
<View style={styles.header}>
<Text style={styles.title}>Daily Activity</Text>
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
</View>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<View style={[styles.iconContainer, { backgroundColor: 'rgba(59, 130, 246, 0.2)' }]}>
<Ionicons name="footsteps" size={20} color="#3b82f6" />
</View>
<Text style={styles.statValue}>{steps.toLocaleString()}</Text>
<Text style={styles.statLabel}>Steps</Text>
</View>
<View style={styles.divider} />
<View style={styles.statItem}>
<View style={[styles.iconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.2)' }]}>
<Ionicons name="flame" size={20} color="#ef4444" />
</View>
<Text style={styles.statValue}>{calories}</Text>
<Text style={styles.statLabel}>Kcal</Text>
</View>
<View style={styles.divider} />
<View style={styles.statItem}>
<View style={[styles.iconContainer, { backgroundColor: 'rgba(16, 185, 129, 0.2)' }]}>
<Ionicons name="time" size={20} color="#10b981" />
</View>
<Text style={styles.statValue}>{duration}m</Text>
<Text style={styles.statLabel}>Active</Text>
</View>
</View>
{/* Simple Bar Chart Visualization */}
<View style={styles.chartContainer}>
{[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => (
<View key={index} style={styles.barContainer}>
<LinearGradient
colors={theme.gradients.primaryVertical}
style={[styles.bar, { height: height * 60 }]}
/>
<Text style={styles.dayLabel}>
{['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]}
</Text>
</View>
))}
</View>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginHorizontal: 20,
marginBottom: 20,
},
card: {
borderRadius: 24,
padding: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#fff',
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
statItem: {
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
statValue: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
marginBottom: 2,
},
statLabel: {
fontSize: 12,
color: theme.colors.gray400,
fontWeight: '500',
},
divider: {
width: 1,
height: 40,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
chartContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
height: 80,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.1)',
},
barContainer: {
alignItems: 'center',
gap: 8,
},
bar: {
width: 6,
borderRadius: 3,
opacity: 0.8,
},
dayLabel: {
fontSize: 10,
color: theme.colors.gray500,
fontWeight: '600',
},
});
@@ -0,0 +1,154 @@
/**
* Animated Button Component
* Modern button with gradient backgrounds and smooth animations
*/
import React, { useRef } from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
Animated,
ActivityIndicator,
ViewStyle,
TextStyle,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
import { haptics } from '../utils/haptics';
interface AnimatedButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
icon?: React.ReactNode;
}
export function AnimatedButton({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
icon,
}: AnimatedButtonProps) {
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
Animated.spring(scale, {
toValue: 0.95,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
}).start();
};
const handlePress = () => {
if (!disabled && !loading) {
haptics.light();
onPress();
}
};
const getGradientColors = () => {
if (disabled) return ['#9ca3af', '#6b7280'] as const;
switch (variant) {
case 'secondary':
return theme.gradients.purple;
case 'success':
return theme.gradients.success;
case 'danger':
return theme.gradients.danger;
case 'warning':
return theme.gradients.warning;
default:
return theme.gradients.primary;
}
};
const getSizeStyles = () => {
switch (size) {
case 'sm':
return { paddingVertical: 8, paddingHorizontal: 16, fontSize: 14 };
case 'lg':
return { paddingVertical: 16, paddingHorizontal: 32, fontSize: 18 };
default:
return { paddingVertical: 12, paddingHorizontal: 24, fontSize: 16 };
}
};
const sizeStyles = getSizeStyles();
return (
<TouchableOpacity
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
disabled={disabled || loading}
activeOpacity={0.9}
style={[styles.container, style]}
>
<Animated.View style={{ transform: [{ scale }], width: '100%' }}>
<LinearGradient
colors={getGradientColors()}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.gradient,
{ paddingVertical: sizeStyles.paddingVertical, paddingHorizontal: sizeStyles.paddingHorizontal },
]}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<>
{icon && <React.Fragment>{icon}</React.Fragment>}
<Text
style={[
styles.text,
{ fontSize: sizeStyles.fontSize },
icon ? { marginLeft: 8 } : {},
textStyle,
]}
>
{title}
</Text>
</>
)}
</LinearGradient>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
gradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
...theme.shadows.medium,
},
text: {
color: '#fff',
fontWeight: '600',
textAlign: 'center',
},
});
@@ -0,0 +1,72 @@
import React, { useEffect, useRef } from 'react';
import { View, Animated, StyleSheet, Text } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import { theme } from '../styles/theme';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
interface CircularProgressProps {
size?: number;
strokeWidth?: number;
progress: number; // 0 to 100
color?: string;
backgroundColor?: string;
}
export function CircularProgress({
size = 60,
strokeWidth = 6,
progress,
color = theme.colors.primary,
backgroundColor = theme.colors.gray200,
}: CircularProgressProps) {
const animatedValue = useRef(new Animated.Value(0)).current;
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
useEffect(() => {
Animated.timing(animatedValue, {
toValue: progress,
duration: 1000,
useNativeDriver: true,
}).start();
}, [progress]);
const strokeDashoffset = animatedValue.interpolate({
inputRange: [0, 100],
outputRange: [circumference, 0],
});
return (
<View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={backgroundColor}
strokeWidth={strokeWidth}
fill="transparent"
/>
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
rotation="-90"
origin={`${size / 2}, ${size / 2}`}
/>
</Svg>
<View style={[StyleSheet.absoluteFillObject, { justifyContent: 'center', alignItems: 'center' }]}>
<Text style={{ fontSize: size * 0.25, fontWeight: 'bold', color: theme.colors.gray900 }}>
{Math.round(progress)}%
</Text>
</View>
</View>
);
}
+143
View File
@@ -0,0 +1,143 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
import { Animated } from 'react-native';
const { width } = Dimensions.get('window');
export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
return (
<View style={styles.container}>
<LinearGradient
colors={['rgba(255, 255, 255, 0.9)', 'rgba(255, 255, 255, 0.7)']}
style={[styles.tabBar, theme.shadows.medium]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
>
{state.routes.map((route, index) => {
const { options } = descriptors[route.key];
const isFocused = state.index === index;
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
const getIconName = (routeName: string, focused: boolean): keyof typeof Ionicons.glyphMap => {
switch (routeName) {
case 'index':
return focused ? 'home' : 'home-outline';
case 'goals':
return focused ? 'trophy' : 'trophy-outline';
case 'attendance':
return focused ? 'calendar' : 'calendar-outline';
case 'recommendations':
return focused ? 'sparkles' : 'sparkles-outline';
case 'profile':
return focused ? 'person' : 'person-outline';
default:
return 'ellipse-outline';
}
};
// Animation for scale
const scaleValue = React.useRef(new Animated.Value(1)).current;
React.useEffect(() => {
Animated.spring(scaleValue, {
toValue: isFocused ? 1.2 : 1,
useNativeDriver: true,
friction: 8,
}).start();
}, [isFocused]);
return (
<TouchableOpacity
key={index}
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={options.tabBarAccessibilityLabel}
testID={(options as any).tabBarTestID}
onPress={onPress}
style={styles.tabItem}
activeOpacity={0.7}
>
<Animated.View style={{ transform: [{ scale: scaleValue }] }}>
{isFocused ? (
<LinearGradient
colors={theme.gradients.primary}
style={styles.iconContainer}
>
<Ionicons
name={getIconName(route.name, true)}
size={20}
color="#fff"
/>
</LinearGradient>
) : (
<Ionicons
name={getIconName(route.name, false)}
size={24}
color={theme.colors.gray500}
/>
)}
</Animated.View>
</TouchableOpacity>
);
})}
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
alignItems: 'center',
paddingBottom: Platform.OS === 'ios' ? 30 : 20,
pointerEvents: 'box-none',
},
tabBar: {
flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 35,
height: 70,
width: width - 40,
justifyContent: 'space-around',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
paddingHorizontal: 10,
},
tabItem: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
});
+223
View File
@@ -0,0 +1,223 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, Dimensions, ScrollView, TouchableOpacity, Animated } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
import { CircularProgress } from './CircularProgress';
import type { FitnessGoal } from '../services/fitnessGoals';
const { width } = Dimensions.get('window');
const CARD_WIDTH = width * 0.8;
const SPACING = 20;
interface GoalCarouselProps {
goals: FitnessGoal[];
onGoalPress: (goal: FitnessGoal) => void;
}
export function GoalCarousel({ goals, onGoalPress }: GoalCarouselProps) {
const scrollX = useRef(new Animated.Value(0)).current;
if (goals.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No active goals</Text>
</View>
);
}
return (
<View>
<Animated.ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + SPACING}
decelerationRate="fast"
contentContainerStyle={styles.scrollContent}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { x: scrollX } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
>
{goals.map((goal, index) => {
const inputRange = [
(index - 1) * (CARD_WIDTH + SPACING),
index * (CARD_WIDTH + SPACING),
(index + 1) * (CARD_WIDTH + SPACING),
];
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.9, 1, 0.9],
extrapolate: 'clamp',
});
const opacity = scrollX.interpolate({
inputRange,
outputRange: [0.7, 1, 0.7],
extrapolate: 'clamp',
});
return (
<TouchableOpacity
key={goal.id}
activeOpacity={0.9}
onPress={() => onGoalPress(goal)}
>
<Animated.View
style={[
styles.cardContainer,
{ transform: [{ scale }], opacity },
]}
>
<LinearGradient
colors={['#ffffff', '#f9fafb']}
style={[styles.card, theme.shadows.medium]}
>
<View style={styles.cardHeader}>
<View style={styles.iconContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.iconGradient}
>
<Ionicons name="trophy" size={24} color="#fff" />
</LinearGradient>
</View>
<View style={styles.priorityBadge}>
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
</View>
</View>
<Text style={styles.title} numberOfLines={1}>{goal.title}</Text>
<Text style={styles.description} numberOfLines={2}>{goal.description}</Text>
<View style={styles.progressContainer}>
<CircularProgress
progress={goal.progress || 0}
size={80}
strokeWidth={8}
color={theme.colors.primary}
/>
<View style={styles.progressDetails}>
<Text style={styles.progressValue}>
{goal.currentValue} / {goal.targetValue}
</Text>
<Text style={styles.progressUnit}>{goal.unit}</Text>
</View>
</View>
<View style={styles.footer}>
<Text style={styles.date}>
Target: {goal.targetDate ? new Date(goal.targetDate).toLocaleDateString() : 'No date'}
</Text>
</View>
</LinearGradient>
</Animated.View>
</TouchableOpacity>
);
})}
</Animated.ScrollView>
</View>
);
}
const styles = StyleSheet.create({
scrollContent: {
paddingHorizontal: (width - CARD_WIDTH) / 2,
paddingVertical: 20,
},
emptyContainer: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: theme.colors.gray500,
fontSize: 16,
},
cardContainer: {
width: CARD_WIDTH,
marginRight: SPACING,
},
card: {
borderRadius: 24,
padding: 24,
height: 320,
justifyContent: 'space-between',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
},
iconContainer: {
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
iconGradient: {
width: 48,
height: 48,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
priorityBadge: {
backgroundColor: 'rgba(59, 130, 246, 0.1)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
priorityText: {
color: theme.colors.primary,
fontSize: 12,
fontWeight: '700',
},
title: {
fontSize: 24,
fontWeight: '800',
color: theme.colors.gray900,
marginBottom: 8,
},
description: {
fontSize: 14,
color: theme.colors.gray500,
marginBottom: 24,
lineHeight: 20,
},
progressContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 24,
},
progressDetails: {
alignItems: 'flex-end',
},
progressValue: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
},
progressUnit: {
fontSize: 14,
color: theme.colors.gray500,
fontWeight: '500',
},
footer: {
borderTopWidth: 1,
borderTopColor: theme.colors.gray100,
paddingTop: 16,
},
date: {
fontSize: 12,
color: theme.colors.gray400,
fontWeight: '500',
},
});
@@ -0,0 +1,426 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TextInput,
TouchableOpacity,
ScrollView,
Platform,
Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import type { CreateGoalData } from '../services/fitnessGoals';
interface GoalCreationModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (goalData: CreateGoalData) => Promise<void>;
}
const GOAL_TYPES = [
{ value: 'weight_target', label: 'Weight Target' },
{ value: 'strength_milestone', label: 'Strength Milestone' },
{ value: 'endurance_target', label: 'Endurance Target' },
{ value: 'flexibility_goal', label: 'Flexibility Goal' },
{ value: 'habit_building', label: 'Habit Building' },
{ value: 'custom', label: 'Custom Goal' },
] as const;
const PRIORITIES = [
{ value: 'low', label: 'Low', color: '#10b981' },
{ value: 'medium', label: 'Medium', color: '#f59e0b' },
{ value: 'high', label: 'High', color: '#ef4444' },
] as const;
export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationModalProps) {
const [goalType, setGoalType] = useState<CreateGoalData['goalType']>('weight_target');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [targetValue, setTargetValue] = useState('');
const [currentValue, setCurrentValue] = useState('');
const [unit, setUnit] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [targetDate, setTargetDate] = useState<Date | undefined>();
const [showDatePicker, setShowDatePicker] = useState(false);
const [submitting, setSubmitting] = useState(false);
const resetForm = () => {
setGoalType('weight_target');
setTitle('');
setDescription('');
setTargetValue('');
setCurrentValue('');
setUnit('');
setPriority('medium');
setTargetDate(undefined);
};
const handleSubmit = async () => {
if (!title.trim()) {
Alert.alert('Error', 'Please enter a goal title');
return;
}
setSubmitting(true);
try {
const goalData: CreateGoalData = {
goalType,
title: title.trim(),
description: description.trim() || undefined,
targetValue: targetValue ? parseFloat(targetValue) : undefined,
currentValue: currentValue ? parseFloat(currentValue) : undefined,
unit: unit.trim() || undefined,
targetDate: targetDate?.toISOString(),
priority,
};
await onSubmit(goalData);
resetForm();
onClose();
} catch (error) {
console.error('Error creating goal:', error);
Alert.alert('Error', 'Failed to create goal. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={handleClose}
>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Create Fitness Goal</Text>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#111827" />
</TouchableOpacity>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* Goal Type */}
<View style={styles.field}>
<Text style={styles.label}>Goal Type *</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.typeScroll}>
{GOAL_TYPES.map((type) => (
<TouchableOpacity
key={type.value}
style={[
styles.typeButton,
goalType === type.value && styles.typeButtonActive,
]}
onPress={() => setGoalType(type.value)}
>
<Text
style={[
styles.typeButtonText,
goalType === type.value && styles.typeButtonTextActive,
]}
>
{type.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Title */}
<View style={styles.field}>
<Text style={styles.label}>Title *</Text>
<TextInput
style={styles.input}
value={title}
onChangeText={setTitle}
placeholder="e.g., Lose 5kg"
placeholderTextColor="#9ca3af"
/>
</View>
{/* Description */}
<View style={styles.field}>
<Text style={styles.label}>Description</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={description}
onChangeText={setDescription}
placeholder="Optional description"
placeholderTextColor="#9ca3af"
multiline
numberOfLines={3}
/>
</View>
{/* Target Value & Unit */}
<View style={styles.row}>
<View style={[styles.field, styles.flex1]}>
<Text style={styles.label}>Target Value</Text>
<TextInput
style={styles.input}
value={targetValue}
onChangeText={setTargetValue}
placeholder="e.g., 70"
placeholderTextColor="#9ca3af"
keyboardType="numeric"
/>
</View>
<View style={[styles.field, styles.flex1]}>
<Text style={styles.label}>Unit</Text>
<TextInput
style={styles.input}
value={unit}
onChangeText={setUnit}
placeholder="e.g., kg"
placeholderTextColor="#9ca3af"
/>
</View>
</View>
{/* Current Value */}
<View style={styles.field}>
<Text style={styles.label}>Current Value</Text>
<TextInput
style={styles.input}
value={currentValue}
onChangeText={setCurrentValue}
placeholder="Starting value (optional)"
placeholderTextColor="#9ca3af"
keyboardType="numeric"
/>
</View>
{/* Target Date */}
<View style={styles.field}>
<Text style={styles.label}>Target Date</Text>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowDatePicker(!showDatePicker)}
>
<Text style={targetDate ? styles.dateText : styles.datePlaceholder}>
{targetDate ? targetDate.toLocaleDateString() : 'Select target date'}
</Text>
<Ionicons name={showDatePicker ? "chevron-up" : "calendar-outline"} size={20} color="#6b7280" />
</TouchableOpacity>
</View>
{showDatePicker && (
<View style={Platform.OS === 'ios' ? styles.datePickerContainer : undefined}>
<DateTimePicker
value={targetDate || new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'default'}
onChange={(event, selectedDate) => {
setShowDatePicker(Platform.OS === 'ios');
if (selectedDate) {
setTargetDate(selectedDate);
}
}}
minimumDate={new Date()}
themeVariant="light"
/>
</View>
)}
{/* Priority */}
<View style={styles.field}>
<Text style={styles.label}>Priority</Text>
<View style={styles.priorityContainer}>
{PRIORITIES.map((p) => (
<TouchableOpacity
key={p.value}
style={[
styles.priorityButton,
priority === p.value && { backgroundColor: p.color },
]}
onPress={() => setPriority(p.value)}
>
<Text
style={[
styles.priorityButtonText,
priority === p.value && styles.priorityButtonTextActive,
]}
>
{p.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.submitButton, submitting && styles.submitButtonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.submitButtonText}>
{submitting ? 'Creating...' : 'Create Goal'}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: Platform.OS === 'ios' ? 60 : 20,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
headerTitle: {
fontSize: 20,
fontWeight: '600',
color: '#111827',
},
closeButton: {
padding: 4,
},
content: {
flex: 1,
padding: 20,
},
field: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
marginBottom: 8,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
color: '#111827',
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
row: {
flexDirection: 'row',
gap: 12,
},
flex1: {
flex: 1,
},
typeScroll: {
flexGrow: 0,
},
typeButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#fff',
marginRight: 8,
},
typeButtonActive: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
typeButtonText: {
fontSize: 14,
color: '#374151',
},
typeButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
dateButton: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
},
dateText: {
fontSize: 16,
color: '#111827',
},
datePlaceholder: {
fontSize: 16,
color: '#9ca3af',
},
priorityContainer: {
flexDirection: 'row',
gap: 12,
},
priorityButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#fff',
alignItems: 'center',
},
priorityButtonText: {
fontSize: 14,
color: '#374151',
fontWeight: '500',
},
priorityButtonTextActive: {
color: '#fff',
fontWeight: '600',
},
footer: {
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
},
submitButton: {
backgroundColor: '#2563eb',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
submitButtonDisabled: {
opacity: 0.6,
},
submitButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
datePickerContainer: {
backgroundColor: '#fff',
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
marginTop: 8,
overflow: 'hidden',
},
});
@@ -0,0 +1,293 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import type { FitnessGoal } from '../services/fitnessGoals';
import { theme } from '../styles/theme';
interface GoalProgressCardProps {
goal: FitnessGoal;
onPress?: () => void;
onComplete?: () => void;
onDelete?: () => void;
}
export function GoalProgressCard({ goal, onPress, onComplete, onDelete }: GoalProgressCardProps) {
const isCompleted = goal.status === 'completed';
const progress = goal.progress || 0;
// Calculate days remaining
const daysRemaining = goal.targetDate
? Math.ceil((new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null;
const getGoalTypeIcon = (type: string) => {
switch (type) {
case 'weight_target': return 'scale-outline';
case 'strength_milestone': return 'barbell-outline';
case 'endurance_target': return 'bicycle-outline';
case 'flexibility_goal': return 'body-outline';
case 'habit_building': return 'calendar-outline';
default: return 'flag-outline';
}
};
const getPriorityGradient = (priority: string): readonly [string, string] => {
switch (priority) {
case 'high': return theme.gradients.danger;
case 'medium': return theme.gradients.warning;
case 'low': return theme.gradients.success;
default: return theme.gradients.primary;
}
};
const handleDelete = () => {
Alert.alert(
'Delete Goal',
'Are you sure you want to delete this goal?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: onDelete },
]
);
};
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
>
<LinearGradient
colors={isCompleted
? ['rgba(16, 185, 129, 0.05)', 'rgba(5, 150, 105, 0.02)'] as const
: ['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const
}
style={[
styles.card,
theme.shadows.medium,
isCompleted && styles.cardCompleted
]}
>
{/* Priority Accent Bar */}
<LinearGradient
colors={getPriorityGradient(goal.priority)}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.priorityAccent}
/>
<View style={styles.header}>
<View style={styles.titleRow}>
<LinearGradient
colors={isCompleted ? theme.gradients.success : getPriorityGradient(goal.priority)}
style={styles.iconContainer}
>
<Ionicons
name={getGoalTypeIcon(goal.goalType) as any}
size={20}
color="#fff"
/>
</LinearGradient>
<View style={styles.titleContainer}>
<Text style={[styles.title, isCompleted && styles.titleCompleted]}>
{goal.title}
</Text>
{goal.description && (
<Text style={styles.description} numberOfLines={2}>
{goal.description}
</Text>
)}
</View>
</View>
<View style={styles.actions}>
{!isCompleted && onComplete && (
<TouchableOpacity onPress={onComplete} style={styles.actionButton}>
<Ionicons name="checkmark-circle-outline" size={24} color={theme.colors.success} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
<Ionicons name="trash-outline" size={22} color={theme.colors.danger} />
</TouchableOpacity>
)}
</View>
</View>
{goal.targetValue && (
<View style={styles.progressSection}>
<View style={styles.progressInfo}>
<Text style={styles.progressText}>
{goal.currentValue || 0} / {goal.targetValue} {goal.unit || ''}
</Text>
<Text style={[styles.progressPercentage, isCompleted && { color: theme.colors.success }]}>
{progress.toFixed(0)}%
</Text>
</View>
<View style={styles.progressBarContainer}>
<LinearGradient
colors={isCompleted ? theme.gradients.success : getPriorityGradient(goal.priority)}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.progressBar,
{ width: `${Math.min(progress, 100)}%` }
]}
/>
</View>
</View>
)}
<View style={styles.footer}>
<LinearGradient
colors={getPriorityGradient(goal.priority)}
style={styles.priorityBadge}
>
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
</LinearGradient>
{daysRemaining !== null && !isCompleted && (
<Text style={[styles.daysRemaining, daysRemaining < 0 && styles.overdue]}>
{daysRemaining < 0
? `${Math.abs(daysRemaining)} days overdue`
: `${daysRemaining} days remaining`
}
</Text>
)}
{isCompleted && goal.completedDate && (
<Text style={styles.completedDate}>
Completed {new Date(goal.completedDate).toLocaleDateString()}
</Text>
)}
</View>
</LinearGradient>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: theme.borderRadius.xl,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.1)',
overflow: 'hidden',
},
cardCompleted: {
borderColor: 'rgba(16, 185, 129, 0.2)',
},
priorityAccent: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
marginLeft: 8,
},
titleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
titleContainer: {
flex: 1,
},
title: {
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray900,
marginBottom: 4,
},
titleCompleted: {
color: theme.colors.gray600,
textDecorationLine: 'line-through',
},
description: {
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray600,
lineHeight: 18,
},
actions: {
flexDirection: 'row',
gap: 8,
},
actionButton: {
padding: 4,
},
progressSection: {
marginBottom: 12,
marginLeft: 8,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
progressText: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.medium,
color: theme.colors.gray700,
},
progressPercentage: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.primary,
},
progressBarContainer: {
height: 8,
backgroundColor: theme.colors.gray200,
borderRadius: 4,
overflow: 'hidden',
},
progressBar: {
height: '100%',
borderRadius: 4,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginLeft: 8,
},
priorityBadge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: theme.borderRadius.md,
},
priorityText: {
fontSize: theme.typography.fontSize.xs,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.white,
},
daysRemaining: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray600,
fontWeight: theme.typography.fontWeight.medium,
},
overdue: {
color: theme.colors.danger,
fontWeight: theme.typography.fontWeight.semibold,
},
completedDate: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.success,
fontWeight: theme.typography.fontWeight.medium,
},
});
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more