Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3eba48b0 | ||
|
|
0ccf59344e | ||
|
|
42122ac341 | ||
|
|
4dd2ed5839 | ||
|
|
f9a588fcd6 | ||
|
|
9330f4fd05 | ||
|
|
d6683e6e5e | ||
|
|
bac7df33e8 | ||
|
|
178ad3fa90 | ||
|
|
ef9f39e564 | ||
|
|
73218402f6 | ||
|
|
c877577fba | ||
|
|
e119f0923c | ||
|
|
a65b3cac08 | ||
|
|
0825bb3d65 | ||
|
|
6740dcb18f | ||
|
|
12d6c07186 | ||
|
|
5010a579d6 | ||
|
|
2cff8eafbd | ||
|
|
275248fc35 | ||
|
|
4c2e97b66d |
No files matched your search
@@ -4,6 +4,101 @@ import { getDatabase } from "@/lib/database";
|
|||||||
import log from "@/lib/logger";
|
import log from "@/lib/logger";
|
||||||
import { ensureUserSynced } from "@/lib/sync-user";
|
import { ensureUserSynced } from "@/lib/sync-user";
|
||||||
|
|
||||||
|
const AI_LINK_PREFIX = "[AI_LINKED]";
|
||||||
|
|
||||||
|
type GoalType =
|
||||||
|
| "weight_target"
|
||||||
|
| "strength_milestone"
|
||||||
|
| "endurance_target"
|
||||||
|
| "flexibility_goal"
|
||||||
|
| "habit_building"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
interface ParsedPlanItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
goalType: GoalType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferGoalType(text: string): GoalType {
|
||||||
|
const normalized = text.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("strength") ||
|
||||||
|
normalized.includes("bench") ||
|
||||||
|
normalized.includes("squat") ||
|
||||||
|
normalized.includes("deadlift") ||
|
||||||
|
normalized.includes("weights")
|
||||||
|
) {
|
||||||
|
return "strength_milestone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("run") ||
|
||||||
|
normalized.includes("cardio") ||
|
||||||
|
normalized.includes("endurance") ||
|
||||||
|
normalized.includes("cycle")
|
||||||
|
) {
|
||||||
|
return "endurance_target";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("stretch") ||
|
||||||
|
normalized.includes("mobility") ||
|
||||||
|
normalized.includes("yoga") ||
|
||||||
|
normalized.includes("flexibility")
|
||||||
|
) {
|
||||||
|
return "flexibility_goal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("daily") ||
|
||||||
|
normalized.includes("routine") ||
|
||||||
|
normalized.includes("habit")
|
||||||
|
) {
|
||||||
|
return "habit_building";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] {
|
||||||
|
const lines = activityPlan
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.split(/\n+/)
|
||||||
|
.flatMap((line) => line.split(/(?<=[.!?])\s+(?=[A-Z0-9])/g))
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim())
|
||||||
|
.filter((line) => line.length > 10)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const uniqueLines = Array.from(new Set(lines));
|
||||||
|
|
||||||
|
return uniqueLines.map((line) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
|
||||||
|
description: line,
|
||||||
|
goalType: inferGoalType(line),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPlanItems(): ParsedPlanItem[] {
|
||||||
|
const defaults = [
|
||||||
|
"Complete 3 strength sessions this week with progressive overload.",
|
||||||
|
"Add 2 cardio sessions of 25-30 minutes for endurance.",
|
||||||
|
"Do a 10-minute mobility routine daily after training.",
|
||||||
|
];
|
||||||
|
|
||||||
|
return defaults.map((line) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
|
||||||
|
description: line,
|
||||||
|
goalType: inferGoalType(line),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { userId: clerkUserId } = await auth();
|
const { userId: clerkUserId } = await auth();
|
||||||
@@ -94,8 +189,103 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If approved, create a notification for the user
|
let pausedGoalsCount = 0;
|
||||||
|
let createdGoalsCount = 0;
|
||||||
|
|
||||||
|
// If approved, regenerate linked AI goals and create a notification for the user
|
||||||
if (status === "approved") {
|
if (status === "approved") {
|
||||||
|
try {
|
||||||
|
const existingActiveGoals = await db.getFitnessGoalsByUserId(
|
||||||
|
updatedRecommendation.userId,
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedGoals = existingActiveGoals.filter((goal) =>
|
||||||
|
goal.notes?.startsWith(AI_LINK_PREFIX),
|
||||||
|
);
|
||||||
|
|
||||||
|
pausedGoalsCount = linkedGoals.length;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
linkedGoals.map((goal) =>
|
||||||
|
db.updateFitnessGoal(goal.id, {
|
||||||
|
status: "paused",
|
||||||
|
notes: `${goal.notes || ""}\nPaused due to recommendation approval on ${new Date().toISOString()}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let planItems = parseActivityPlanToItems(
|
||||||
|
updatedRecommendation.activityPlan || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
planItems.length === 0 &&
|
||||||
|
updatedRecommendation.recommendationText
|
||||||
|
) {
|
||||||
|
planItems = parseActivityPlanToItems(
|
||||||
|
updatedRecommendation.recommendationText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planItems.length === 0) {
|
||||||
|
planItems = getDefaultPlanItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitnessProfileId =
|
||||||
|
updatedRecommendation.fitnessProfileId ||
|
||||||
|
(await db.getFitnessProfileByUserId(updatedRecommendation.userId))
|
||||||
|
?.id;
|
||||||
|
|
||||||
|
if (!fitnessProfileId) {
|
||||||
|
log.warn("No fitness profile available for AI goal creation", {
|
||||||
|
recommendationId,
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const createdGoals = await Promise.all(
|
||||||
|
planItems.map((item) =>
|
||||||
|
db.createFitnessGoal({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
fitnessProfileId,
|
||||||
|
goalType: item.goalType,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
targetValue: undefined,
|
||||||
|
currentValue: 0,
|
||||||
|
unit: undefined,
|
||||||
|
startDate: new Date(),
|
||||||
|
targetDate: undefined,
|
||||||
|
completedDate: undefined,
|
||||||
|
status: "active",
|
||||||
|
progress: 0,
|
||||||
|
priority: "medium",
|
||||||
|
notes: `${AI_LINK_PREFIX} recommendationId=${updatedRecommendation.id}; itemId=${item.id}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
createdGoalsCount = createdGoals.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Regenerated linked AI goals from approved recommendation", {
|
||||||
|
recommendationId: updatedRecommendation.id,
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
pausedGoals: pausedGoalsCount,
|
||||||
|
createdGoals: createdGoalsCount,
|
||||||
|
});
|
||||||
|
} catch (goalConversionError) {
|
||||||
|
log.error(
|
||||||
|
"Failed to regenerate linked goals for approved recommendation",
|
||||||
|
goalConversionError,
|
||||||
|
{
|
||||||
|
recommendationId,
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.createNotification({
|
await db.createNotification({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -122,6 +312,8 @@ export async function POST(req: Request) {
|
|||||||
data: updatedRecommendation,
|
data: updatedRecommendation,
|
||||||
meta: {
|
meta: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
pausedGoals: pausedGoalsCount,
|
||||||
|
createdGoals: createdGoalsCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import { buildAIContext } from "@/lib/ai/ai-context";
|
||||||
|
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
import { ensureUserSynced } from "@/lib/sync-user";
|
||||||
|
import { getUserMembershipContext } from "@/lib/membership/access";
|
||||||
|
|
||||||
|
const AI_LINK_PREFIX = "[AI_LINKED]";
|
||||||
|
|
||||||
|
interface ParsedPlanItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
goalType:
|
||||||
|
| "weight_target"
|
||||||
|
| "strength_milestone"
|
||||||
|
| "endurance_target"
|
||||||
|
| "flexibility_goal"
|
||||||
|
| "habit_building"
|
||||||
|
| "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedPlanContent {
|
||||||
|
recommendationText?: string;
|
||||||
|
activityPlan?: string;
|
||||||
|
dietPlan?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackPlan(profile: {
|
||||||
|
activityLevel?: string;
|
||||||
|
fitnessGoals?: string[] | string;
|
||||||
|
medicalConditions?: string;
|
||||||
|
}): GeneratedPlanContent {
|
||||||
|
const goals = Array.isArray(profile.fitnessGoals)
|
||||||
|
? profile.fitnessGoals
|
||||||
|
: typeof profile.fitnessGoals === "string" && profile.fitnessGoals
|
||||||
|
? [profile.fitnessGoals]
|
||||||
|
: ["general fitness"];
|
||||||
|
|
||||||
|
const primaryGoal = goals[0] || "general fitness";
|
||||||
|
const activityLevel = profile.activityLevel || "moderate";
|
||||||
|
const hasMedicalNotes = Boolean(profile.medicalConditions?.trim());
|
||||||
|
|
||||||
|
return {
|
||||||
|
recommendationText:
|
||||||
|
`Personalized starter plan focused on ${primaryGoal} with ${activityLevel} activity pacing.` +
|
||||||
|
(hasMedicalNotes
|
||||||
|
? " Medical notes detected, so keep intensity conservative and progress gradually."
|
||||||
|
: ""),
|
||||||
|
activityPlan:
|
||||||
|
"- 3 strength sessions per week (full-body, 35-45 min)\n" +
|
||||||
|
"- 2 cardio sessions per week (20-30 min brisk walk/run/cycle)\n" +
|
||||||
|
"- 10 minutes daily mobility/stretching after workouts\n" +
|
||||||
|
"- 1 full recovery day each week",
|
||||||
|
dietPlan:
|
||||||
|
"- Build meals around lean protein, vegetables, whole grains, and hydration\n" +
|
||||||
|
"- Keep portions consistent and avoid skipping meals\n" +
|
||||||
|
"- Track intake daily and adjust calories based on weekly progress",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferGoalType(text: string): ParsedPlanItem["goalType"] {
|
||||||
|
const normalized = text.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("strength") ||
|
||||||
|
normalized.includes("bench") ||
|
||||||
|
normalized.includes("squat") ||
|
||||||
|
normalized.includes("deadlift") ||
|
||||||
|
normalized.includes("weights")
|
||||||
|
) {
|
||||||
|
return "strength_milestone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("run") ||
|
||||||
|
normalized.includes("cardio") ||
|
||||||
|
normalized.includes("endurance") ||
|
||||||
|
normalized.includes("cycle")
|
||||||
|
) {
|
||||||
|
return "endurance_target";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("stretch") ||
|
||||||
|
normalized.includes("mobility") ||
|
||||||
|
normalized.includes("yoga") ||
|
||||||
|
normalized.includes("flexibility")
|
||||||
|
) {
|
||||||
|
return "flexibility_goal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("daily") ||
|
||||||
|
normalized.includes("routine") ||
|
||||||
|
normalized.includes("habit")
|
||||||
|
) {
|
||||||
|
return "habit_building";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] {
|
||||||
|
const lines = activityPlan
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.split(/\n+/)
|
||||||
|
.flatMap((line) => line.split(/(?<=[.!?])\s+(?=[A-Z0-9])/g))
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim())
|
||||||
|
.filter((line) => line.length > 10)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const uniqueLines = Array.from(new Set(lines));
|
||||||
|
|
||||||
|
return uniqueLines.map((line) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
|
||||||
|
description: line,
|
||||||
|
goalType: inferGoalType(line),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPlanItems(): ParsedPlanItem[] {
|
||||||
|
const defaults = [
|
||||||
|
"Complete 3 strength sessions this week with progressive overload.",
|
||||||
|
"Add 2 cardio sessions of 25-30 minutes for endurance.",
|
||||||
|
"Do a 10-minute mobility routine daily after training.",
|
||||||
|
];
|
||||||
|
|
||||||
|
return defaults.map((line) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: line.length > 72 ? `${line.slice(0, 69)}...` : line,
|
||||||
|
description: line,
|
||||||
|
goalType: inferGoalType(line),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonPayload(content: string): GeneratedPlanContent {
|
||||||
|
let cleanResponse = content.trim();
|
||||||
|
|
||||||
|
if (cleanResponse.startsWith("```json")) {
|
||||||
|
cleanResponse = cleanResponse
|
||||||
|
.replace(/^```json\s*/, "")
|
||||||
|
.replace(/\s*```$/, "");
|
||||||
|
} else if (cleanResponse.startsWith("```")) {
|
||||||
|
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstBrace = cleanResponse.indexOf("{");
|
||||||
|
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||||
|
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||||
|
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(cleanResponse) as GeneratedPlanContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithOpenAI(
|
||||||
|
openaiApiKey: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<GeneratedPlanContent> {
|
||||||
|
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${openaiApiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
'You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks. The response must have this exact structure: {"recommendationText": "string", "activityPlan": "string", "dietPlan": "string"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 1500,
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OpenAI failed: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return parseJsonPayload(data.choices[0].message.content as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithDeepSeek(
|
||||||
|
deepseekApiKey: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<GeneratedPlanContent> {
|
||||||
|
const response = 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: 1200,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`DeepSeek failed: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return parseJsonPayload(data.choices[0].message.content as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithOllama(
|
||||||
|
prompt: string,
|
||||||
|
): Promise<GeneratedPlanContent> {
|
||||||
|
const response = await fetch("http://localhost:11434/api/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "gemma3:latest",
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
format: "json",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Ollama failed: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return parseJsonPayload(data.response as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
const currentUser = await ensureUserSynced(userId, db);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { membershipType, features } = await getUserMembershipContext(userId);
|
||||||
|
|
||||||
|
if (membershipType === "basic") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"AI plan generation is available on Premium and VIP memberships",
|
||||||
|
membershipType,
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (features.recommendationsPerMonth > 0) {
|
||||||
|
const currentMonth = new Date();
|
||||||
|
const monthStart = new Date(
|
||||||
|
currentMonth.getFullYear(),
|
||||||
|
currentMonth.getMonth(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const monthEnd = new Date(
|
||||||
|
currentMonth.getFullYear(),
|
||||||
|
currentMonth.getMonth() + 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingRecommendations =
|
||||||
|
await db.getRecommendationsByUserId(userId);
|
||||||
|
const recommendationsThisMonth = existingRecommendations.filter(
|
||||||
|
(recommendation) =>
|
||||||
|
recommendation.generatedAt >= monthStart &&
|
||||||
|
recommendation.generatedAt < monthEnd,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (recommendationsThisMonth >= features.recommendationsPerMonth) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Your ${membershipType} plan includes ${features.recommendationsPerMonth} AI recommendation(s) per month`,
|
||||||
|
membershipType,
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await db.getFitnessProfileByUserId(userId);
|
||||||
|
if (!profile) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Complete your fitness profile before generating a plan" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt: string;
|
||||||
|
try {
|
||||||
|
const context = await buildAIContext(userId);
|
||||||
|
prompt = buildEnhancedPrompt(context);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Failed to build AI context for self-generate", {
|
||||||
|
userId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
prompt = buildBasicPrompt(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||||
|
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||||
|
|
||||||
|
let parsedResponse: GeneratedPlanContent;
|
||||||
|
let usedFallbackPlan = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (openaiApiKey) {
|
||||||
|
parsedResponse = await generateWithOpenAI(openaiApiKey, prompt);
|
||||||
|
} else if (deepseekApiKey) {
|
||||||
|
parsedResponse = await generateWithDeepSeek(deepseekApiKey, prompt);
|
||||||
|
} else {
|
||||||
|
parsedResponse = await generateWithOllama(prompt);
|
||||||
|
}
|
||||||
|
} catch (providerError) {
|
||||||
|
log.error("Self-generate provider failed", providerError, {
|
||||||
|
userId,
|
||||||
|
hasOpenAI: Boolean(openaiApiKey),
|
||||||
|
hasDeepSeek: Boolean(deepseekApiKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
parsedResponse = buildFallbackPlan({
|
||||||
|
activityLevel: profile.activityLevel,
|
||||||
|
fitnessGoals: profile.fitnessGoals,
|
||||||
|
medicalConditions: profile.medicalConditions,
|
||||||
|
});
|
||||||
|
usedFallbackPlan = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendation = await db.createRecommendation({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
fitnessProfileId: profile.id,
|
||||||
|
recommendationText: parsedResponse.recommendationText || "",
|
||||||
|
activityPlan: parsedResponse.activityPlan || "",
|
||||||
|
dietPlan: parsedResponse.dietPlan || "",
|
||||||
|
status: "approved",
|
||||||
|
generatedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingActiveGoals = await db.getFitnessGoalsByUserId(
|
||||||
|
userId,
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
const linkedGoals = existingActiveGoals.filter((goal) =>
|
||||||
|
goal.notes?.startsWith(AI_LINK_PREFIX),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
linkedGoals.map((goal) =>
|
||||||
|
db.updateFitnessGoal(goal.id, {
|
||||||
|
status: "paused",
|
||||||
|
notes: `${goal.notes || ""}\nPaused due to new AI plan generation on ${new Date().toISOString()}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let planItems = parseActivityPlanToItems(parsedResponse.activityPlan || "");
|
||||||
|
|
||||||
|
if (planItems.length === 0 && parsedResponse.recommendationText) {
|
||||||
|
planItems = parseActivityPlanToItems(parsedResponse.recommendationText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planItems.length === 0) {
|
||||||
|
planItems = getDefaultPlanItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("AI plan parsed into goal items", {
|
||||||
|
recommendationId: recommendation.id,
|
||||||
|
userId,
|
||||||
|
parsedItems: planItems.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdGoals = await Promise.all(
|
||||||
|
planItems.map((item) =>
|
||||||
|
db.createFitnessGoal({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
fitnessProfileId: profile.id,
|
||||||
|
goalType: item.goalType,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
targetValue: undefined,
|
||||||
|
currentValue: 0,
|
||||||
|
unit: undefined,
|
||||||
|
startDate: new Date(),
|
||||||
|
targetDate: undefined,
|
||||||
|
completedDate: undefined,
|
||||||
|
status: "active",
|
||||||
|
progress: 0,
|
||||||
|
priority: "medium",
|
||||||
|
notes: `${AI_LINK_PREFIX} recommendationId=${recommendation.id}; itemId=${item.id}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: recommendation,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
createdGoals: createdGoals.length,
|
||||||
|
pausedGoals: linkedGoals.length,
|
||||||
|
usedFallbackPlan,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to self-generate recommendation", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# OSX
|
|
||||||
#
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Android/IntelliJ
|
|
||||||
#
|
|
||||||
build/
|
|
||||||
.idea
|
|
||||||
.gradle
|
|
||||||
local.properties
|
|
||||||
*.iml
|
|
||||||
*.hprof
|
|
||||||
.cxx/
|
|
||||||
|
|
||||||
# Bundle artifacts
|
|
||||||
*.jsbundle
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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.fitai"
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.fitai"
|
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
|
||||||
versionCode 1
|
|
||||||
versionName "1.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 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:
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
<!-- OPTIONAL PERMISSIONS, REMOVE WHATEVER YOU DO NOT NEED -->
|
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
|
||||||
<!-- These require runtime permissions on M -->
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
|
||||||
<!-- END OPTIONAL PERMISSIONS -->
|
|
||||||
|
|
||||||
<queries>
|
|
||||||
<!-- Support checking for http(s) links via the Linking API -->
|
|
||||||
<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="false" android:theme="@style/AppTheme" android:supportsRtl="true">
|
|
||||||
<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">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 65 KiB |
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<resources>
|
|
||||||
<color name="splashscreen_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">FitAI</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
|
||||||
</style>
|
|
||||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
|
||||||
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// 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"
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information.",
|
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information.",
|
||||||
"NSUserNotificationsUsageDescription": "This app uses notifications to keep you updated on your fitness progress, recommendation approvals, and important reminders."
|
"NSUserNotificationsUsageDescription": "This app uses notifications to keep you updated on your fitness progress, recommendation approvals, and important reminders.",
|
||||||
|
"NSMotionUsageDescription": "This app uses motion data to track your daily steps and activity progress."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
@@ -27,7 +28,8 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"CAMERA",
|
"CAMERA",
|
||||||
"POST_NOTIFICATIONS",
|
"POST_NOTIFICATIONS",
|
||||||
"android.permission.CAMERA"
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.ACTIVITY_RECOGNITION"
|
||||||
],
|
],
|
||||||
"package": "com.anonymous.fitai"
|
"package": "com.anonymous.fitai"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?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>IDEDidComputeMac32BitWarning</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"expo-notifications": "~0.32.0",
|
"expo-notifications": "~0.32.0",
|
||||||
"expo-router": "~6.0.14",
|
"expo-router": "~6.0.14",
|
||||||
"expo-secure-store": "~15.0.7",
|
"expo-secure-store": "~15.0.7",
|
||||||
|
"expo-sensors": "~14.1.4",
|
||||||
"expo-status-bar": "^3.0.8",
|
"expo-status-bar": "^3.0.8",
|
||||||
"expo-web-browser": "^15.0.10",
|
"expo-web-browser": "^15.0.10",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -7604,6 +7605,19 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-sensors": {
|
||||||
|
"version": "14.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-14.1.4.tgz",
|
||||||
|
"integrity": "sha512-KHROi5C8dhXedMwx7fZ5eyv9p382F5XOIex4a+GpdOTL3OY4xyk08kt7x64FtMeeoT87gYD3mb9LrBpHyNubkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-server": {
|
"node_modules/expo-server": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"expo-notifications": "~0.32.0",
|
"expo-notifications": "~0.32.0",
|
||||||
"expo-router": "~6.0.14",
|
"expo-router": "~6.0.14",
|
||||||
"expo-secure-store": "~15.0.7",
|
"expo-secure-store": "~15.0.7",
|
||||||
|
"expo-sensors": "~14.1.4",
|
||||||
"expo-status-bar": "^3.0.8",
|
"expo-status-bar": "^3.0.8",
|
||||||
"expo-web-browser": "^15.0.10",
|
"expo-web-browser": "^15.0.10",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ export interface Recommendation {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecommendationMeta {
|
||||||
|
usedFallbackPlan?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecommendationApiEnvelope {
|
||||||
|
success?: boolean;
|
||||||
|
data?: Recommendation;
|
||||||
|
meta?: RecommendationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerateRecommendationRequest {
|
export interface GenerateRecommendationRequest {
|
||||||
userId: string;
|
userId: string;
|
||||||
useExternalModel?: boolean;
|
useExternalModel?: boolean;
|
||||||
@@ -109,3 +119,36 @@ export async function approveRecommendation(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI recommendation for the authenticated client user
|
||||||
|
*/
|
||||||
|
export async function generateSelfRecommendation(
|
||||||
|
token: string | null,
|
||||||
|
): Promise<Recommendation> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<RecommendationApiEnvelope>(
|
||||||
|
`${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`,
|
||||||
|
{},
|
||||||
|
withAuth(token),
|
||||||
|
);
|
||||||
|
return parseApiData<Recommendation>(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
const responseError = error.response?.data as
|
||||||
|
| { error?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (responseError?.error) {
|
||||||
|
throw new Error(responseError.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate recommendation: ${error.response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,12 +83,6 @@ export default function TabLayout() {
|
|||||||
title: "Plans",
|
title: "Plans",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
|
||||||
name="attendance"
|
|
||||||
options={{
|
|
||||||
title: "Attendance",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profile"
|
name="profile"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
ActivityIndicator,
|
|
||||||
ScrollView,
|
|
||||||
Alert,
|
|
||||||
} from "react-native";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useAuth } from "@clerk/clerk-expo";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useTheme } from "../../contexts/ThemeContext";
|
|
||||||
import { MinimalCard } from "../../components/MinimalCard";
|
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
|
||||||
import { MinimalButton } from "../../components/MinimalButton";
|
|
||||||
import { Badge } from "../../components/Badge";
|
|
||||||
import { IconContainer } from "../../components/IconContainer";
|
|
||||||
import { attendanceApi, Attendance } from "../../api/attendance";
|
|
||||||
import { AttendanceCalendar } from "../../components/AttendanceCalendar";
|
|
||||||
import { useStatistics } from "../../contexts/StatisticsContext";
|
|
||||||
import { getErrorMessage } from "../../utils/error-helpers";
|
|
||||||
import log from "../../utils/logger";
|
|
||||||
|
|
||||||
export default function AttendanceScreen() {
|
|
||||||
const { getToken, userId } = useAuth();
|
|
||||||
const { colors, typography } = useTheme();
|
|
||||||
const { clearCache: clearStatisticsCache } = useStatistics();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
|
||||||
const [history, setHistory] = useState<Attendance[]>([]);
|
|
||||||
|
|
||||||
const fetchAttendance = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const token = await getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
log.debug("Fetching attendance history");
|
|
||||||
const data = await attendanceApi.getHistory(token);
|
|
||||||
setHistory(data);
|
|
||||||
|
|
||||||
// Check if there's an active check-in (latest one has no checkOutTime)
|
|
||||||
if (data.length > 0 && !data[0].checkOutTime) {
|
|
||||||
setActiveCheckIn(data[0]);
|
|
||||||
} else {
|
|
||||||
setActiveCheckIn(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to fetch attendance", error);
|
|
||||||
Alert.alert("Error", "Failed to load attendance data");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAttendance();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCheckIn = async () => {
|
|
||||||
try {
|
|
||||||
const token = await getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
await attendanceApi.checkIn("gym", token);
|
|
||||||
clearStatisticsCache();
|
|
||||||
fetchAttendance();
|
|
||||||
Alert.alert("Success", "Checked in successfully!");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
log.error("Failed to check in", error);
|
|
||||||
Alert.alert("Error", getErrorMessage(error, "Failed to check in"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckOut = async () => {
|
|
||||||
try {
|
|
||||||
const token = await getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
await attendanceApi.checkOut(token);
|
|
||||||
clearStatisticsCache();
|
|
||||||
fetchAttendance();
|
|
||||||
Alert.alert("Success", "Checked out successfully!");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
log.error("Failed to check out", error);
|
|
||||||
Alert.alert("Error", getErrorMessage(error, "Failed to check out"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && !history.length) {
|
|
||||||
return (
|
|
||||||
<View style={[styles.centered, { backgroundColor: colors.background }]}>
|
|
||||||
<ActivityIndicator size="large" color={colors.primary} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={[styles.container, { backgroundColor: colors.background }]}
|
|
||||||
contentContainerStyle={styles.content}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text
|
|
||||||
style={[typography.h1, { color: colors.textPrimary, fontSize: 32 }]}
|
|
||||||
>
|
|
||||||
Attendance
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.body,
|
|
||||||
{ color: colors.textSecondary, marginTop: 8 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{activeCheckIn
|
|
||||||
? "You're crushing it today!"
|
|
||||||
: history.length === 0
|
|
||||||
? "Ready to start your fitness journey?"
|
|
||||||
: "Track your gym visits and build streaks!"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Check In/Out Section */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
{activeCheckIn ? (
|
|
||||||
<MinimalCard variant="bordered" style={styles.activeCard}>
|
|
||||||
<View style={styles.activeHeader}>
|
|
||||||
<View style={styles.activeHeaderLeft}>
|
|
||||||
<IconContainer
|
|
||||||
variant="colored"
|
|
||||||
backgroundColor={`${colors.success}20`}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="checkmark-circle"
|
|
||||||
size={28}
|
|
||||||
color={colors.success}
|
|
||||||
/>
|
|
||||||
</IconContainer>
|
|
||||||
<View style={{ marginLeft: 12 }}>
|
|
||||||
<Text style={[typography.h3, { color: colors.textPrimary }]}>
|
|
||||||
✅ Currently Checked In
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.caption,
|
|
||||||
{ color: colors.textTertiary, marginTop: 2 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Since{" "}
|
|
||||||
{new Date(activeCheckIn.checkInTime).toLocaleTimeString(
|
|
||||||
[],
|
|
||||||
{
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<MinimalButton
|
|
||||||
title="Check Out"
|
|
||||||
onPress={handleCheckOut}
|
|
||||||
variant="danger"
|
|
||||||
size="lg"
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
/>
|
|
||||||
</MinimalCard>
|
|
||||||
) : (
|
|
||||||
<MinimalButton
|
|
||||||
title="💪 Check In"
|
|
||||||
onPress={handleCheckIn}
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Attendance Calendar */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<SectionHeader title="📅 Calendar" />
|
|
||||||
<MinimalCard variant="default" style={{ borderRadius: 20 }}>
|
|
||||||
<AttendanceCalendar attendanceRecords={history} />
|
|
||||||
</MinimalCard>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Recent History */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<SectionHeader title="📊 Recent History" />
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<MinimalCard variant="default" style={{ borderRadius: 20 }}>
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={{ fontSize: 64 }}>📍</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.bodyEmphasis,
|
|
||||||
{ color: colors.textPrimary, marginTop: 16 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
No attendance history yet
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.body,
|
|
||||||
{
|
|
||||||
color: colors.textSecondary,
|
|
||||||
marginTop: 8,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Check in to start building your streak! 🔥
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</MinimalCard>
|
|
||||||
) : (
|
|
||||||
<View style={styles.historyList}>
|
|
||||||
{history.slice(0, 10).map((record, index) => {
|
|
||||||
const checkIn = new Date(record.checkInTime);
|
|
||||||
const checkOut = record.checkOutTime
|
|
||||||
? new Date(record.checkOutTime)
|
|
||||||
: null;
|
|
||||||
const duration = checkOut
|
|
||||||
? Math.round((checkOut.getTime() - checkIn.getTime()) / 60000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MinimalCard
|
|
||||||
key={index}
|
|
||||||
variant="bordered"
|
|
||||||
style={{ borderRadius: 16 }}
|
|
||||||
>
|
|
||||||
<View style={styles.historyItem}>
|
|
||||||
<View style={styles.historyLeft}>
|
|
||||||
<IconContainer
|
|
||||||
variant="colored"
|
|
||||||
backgroundColor={
|
|
||||||
checkOut ? `${colors.success}20` : `${colors.info}20`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={checkOut ? "checkmark-done" : "time"}
|
|
||||||
size={20}
|
|
||||||
color={checkOut ? colors.success : colors.info}
|
|
||||||
/>
|
|
||||||
</IconContainer>
|
|
||||||
<View style={{ marginLeft: 12, flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={[typography.h3, { color: colors.textPrimary }]}
|
|
||||||
>
|
|
||||||
{checkIn.toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
typography.caption,
|
|
||||||
{ color: colors.textTertiary, marginTop: 2 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{checkIn.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
{checkOut &&
|
|
||||||
` - ${checkOut.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{duration && (
|
|
||||||
<Badge
|
|
||||||
label={`${duration}m`}
|
|
||||||
variant="neutral"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</MinimalCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom Spacer */}
|
|
||||||
<View style={{ height: 100 }} />
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
centered: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
paddingBottom: 20,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingTop: 60,
|
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
activeCard: {
|
|
||||||
padding: 20,
|
|
||||||
borderRadius: 20,
|
|
||||||
},
|
|
||||||
activeHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
activeHeaderLeft: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: 40,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
historyList: {
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
historyItem: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
historyLeft: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -22,6 +22,7 @@ import { GoalProgressCard } from "../../components/GoalProgressCard";
|
|||||||
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
||||||
import { WeeklyProgressChart } from "../../components/WeeklyProgressChart";
|
import { WeeklyProgressChart } from "../../components/WeeklyProgressChart";
|
||||||
import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart";
|
import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart";
|
||||||
|
import { useMembership } from "../../hooks/useMembership";
|
||||||
import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals";
|
import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals";
|
||||||
import { useStatistics } from "../../contexts/StatisticsContext";
|
import { useStatistics } from "../../contexts/StatisticsContext";
|
||||||
import { useFitnessGoals } from "../../contexts/FitnessGoalsContext";
|
import { useFitnessGoals } from "../../contexts/FitnessGoalsContext";
|
||||||
@@ -45,15 +46,24 @@ export default function GoalsScreen() {
|
|||||||
deleteGoal,
|
deleteGoal,
|
||||||
clearCache: clearGoalsCache,
|
clearCache: clearGoalsCache,
|
||||||
} = useFitnessGoals();
|
} = useFitnessGoals();
|
||||||
const { clearCache: clearRecommendationsCache } = useRecommendations();
|
const {
|
||||||
|
recommendations,
|
||||||
|
clearCache: clearRecommendationsCache,
|
||||||
|
refetchRecommendations,
|
||||||
|
generateSelfPlan,
|
||||||
|
} = useRecommendations();
|
||||||
|
const { membershipType, features } = useMembership();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const [showAnalytics, setShowAnalytics] = useState(false);
|
const [showAnalytics, setShowAnalytics] = useState(false);
|
||||||
|
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
|
||||||
|
const [showFullPlan, setShowFullPlan] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
await refetchGoals();
|
await refetchGoals();
|
||||||
|
await refetchRecommendations();
|
||||||
await refetchStatistics();
|
await refetchStatistics();
|
||||||
}, [refetchGoals, refetchStatistics]);
|
}, [refetchGoals, refetchRecommendations, refetchStatistics]);
|
||||||
|
|
||||||
const clearClerkCache = async () => {
|
const clearClerkCache = async () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -136,6 +146,76 @@ export default function GoalsScreen() {
|
|||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const latestApprovedRecommendation = [...recommendations]
|
||||||
|
.filter((rec) => rec.status === "approved")
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime(),
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const isGoalAiAligned = (goal: FitnessGoal) => {
|
||||||
|
if (goal.notes?.startsWith("[AI_LINKED]")) return true;
|
||||||
|
if (!latestApprovedRecommendation?.activityPlan) return false;
|
||||||
|
|
||||||
|
const planText = latestApprovedRecommendation.activityPlan.toLowerCase();
|
||||||
|
const title = goal.title.toLowerCase();
|
||||||
|
const description = (goal.description || "").toLowerCase();
|
||||||
|
|
||||||
|
const content = `${title} ${description}`;
|
||||||
|
|
||||||
|
if (goal.goalType === "strength_milestone") {
|
||||||
|
return planText.includes("strength") || planText.includes("weight");
|
||||||
|
}
|
||||||
|
if (goal.goalType === "endurance_target") {
|
||||||
|
return (
|
||||||
|
planText.includes("cardio") ||
|
||||||
|
planText.includes("endurance") ||
|
||||||
|
planText.includes("run")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (goal.goalType === "flexibility_goal") {
|
||||||
|
return planText.includes("stretch") || planText.includes("mobility");
|
||||||
|
}
|
||||||
|
if (goal.goalType === "habit_building") {
|
||||||
|
return planText.includes("habit") || planText.includes("daily");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
planText.includes(content.split(" ")[0] || "") ||
|
||||||
|
content
|
||||||
|
.split(" ")
|
||||||
|
.some((word) => word.length > 4 && planText.includes(word))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateAiPlan = async () => {
|
||||||
|
if (!features.recommendationsPerMonth || membershipType === "basic") {
|
||||||
|
Alert.alert(
|
||||||
|
"Premium Feature",
|
||||||
|
"AI plan generation is available on Premium and VIP memberships.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsGeneratingPlan(true);
|
||||||
|
await generateSelfPlan();
|
||||||
|
await Promise.all([refetchRecommendations(), refetchGoals()]);
|
||||||
|
Alert.alert(
|
||||||
|
"Plan Ready",
|
||||||
|
"Your new activity plan is ready and active goals were added.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to generate AI activity plan.";
|
||||||
|
Alert.alert("Could not generate plan", message);
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingPlan(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -352,6 +432,89 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
{/* Active Goals */}
|
{/* Active Goals */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
|
<MinimalCard variant="elevated" style={styles.aiPlanCard}>
|
||||||
|
<View style={styles.aiPlanHeader}>
|
||||||
|
<View style={styles.aiPlanHeaderLeft}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.aiPlanIcon,
|
||||||
|
{ backgroundColor: `${colors.primary}15` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="sparkles" size={20} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={[typography.h3, { color: colors.textPrimary }]}>
|
||||||
|
AI Activity Plan
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
typography.caption,
|
||||||
|
{ color: colors.textTertiary, marginTop: 2 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{latestApprovedRecommendation
|
||||||
|
? `Generated ${new Date(latestApprovedRecommendation.generatedAt).toLocaleDateString()}`
|
||||||
|
: "Generate a plan aligned to your fitness profile"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Badge
|
||||||
|
variant={membershipType === "basic" ? "neutral" : "success"}
|
||||||
|
label={membershipType.toUpperCase()}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{latestApprovedRecommendation ? (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
typography.body,
|
||||||
|
{ color: colors.textSecondary, marginTop: 14 },
|
||||||
|
]}
|
||||||
|
numberOfLines={showFullPlan ? undefined : 4}
|
||||||
|
>
|
||||||
|
{latestApprovedRecommendation.activityPlan}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowFullPlan((prev) => !prev)}
|
||||||
|
style={styles.showPlanButton}
|
||||||
|
>
|
||||||
|
<Text style={[typography.caption, { color: colors.primary }]}>
|
||||||
|
{showFullPlan ? "Show less" : "View full plan"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
typography.body,
|
||||||
|
{ color: colors.textSecondary, marginTop: 14 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
No AI activity plan yet. Generate one to get personalized
|
||||||
|
guidance.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MinimalButton
|
||||||
|
title={
|
||||||
|
membershipType === "basic"
|
||||||
|
? "Upgrade to Generate Plan"
|
||||||
|
: latestApprovedRecommendation
|
||||||
|
? "Regenerate Plan"
|
||||||
|
: "Generate Plan"
|
||||||
|
}
|
||||||
|
onPress={handleGenerateAiPlan}
|
||||||
|
disabled={isGeneratingPlan}
|
||||||
|
loading={isGeneratingPlan}
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: 14 }}
|
||||||
|
/>
|
||||||
|
</MinimalCard>
|
||||||
|
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={`Active Goals (${activeGoals.length})`}
|
title={`Active Goals (${activeGoals.length})`}
|
||||||
subtitle="Keep pushing forward!"
|
subtitle="Keep pushing forward!"
|
||||||
@@ -386,6 +549,7 @@ export default function GoalsScreen() {
|
|||||||
<GoalProgressCard
|
<GoalProgressCard
|
||||||
key={goal.id}
|
key={goal.id}
|
||||||
goal={goal}
|
goal={goal}
|
||||||
|
aiAligned={isGoalAiAligned(goal)}
|
||||||
onComplete={() => handleCompleteGoal(goal)}
|
onComplete={() => handleCompleteGoal(goal)}
|
||||||
onDelete={() => handleDeleteGoal(goal.id)}
|
onDelete={() => handleDeleteGoal(goal.id)}
|
||||||
/>
|
/>
|
||||||
@@ -509,6 +673,33 @@ const styles = StyleSheet.create({
|
|||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: "rgba(0,0,0,0.05)",
|
borderTopColor: "rgba(0,0,0,0.05)",
|
||||||
},
|
},
|
||||||
|
aiPlanCard: {
|
||||||
|
padding: 18,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
aiPlanHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
aiPlanHeaderLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
aiPlanIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
showPlanButton: {
|
||||||
|
marginTop: 8,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
chartSection: {
|
chartSection: {
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
AppState,
|
AppState,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useUser } from "@clerk/clerk-expo";
|
import { useAuth, useUser } from "@clerk/clerk-expo";
|
||||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
@@ -27,6 +27,8 @@ import { AddWaterModal } from "../../components/AddWaterModal";
|
|||||||
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
||||||
import { ActivityRing } from "../../components/ActivityRing";
|
import { ActivityRing } from "../../components/ActivityRing";
|
||||||
import { useMembership } from "../../hooks/useMembership";
|
import { useMembership } from "../../hooks/useMembership";
|
||||||
|
import { attendanceApi, type Attendance } from "../../api/attendance";
|
||||||
|
import { useDailySteps } from "../../hooks/useDailySteps";
|
||||||
import {
|
import {
|
||||||
checkInsToActivities,
|
checkInsToActivities,
|
||||||
completedGoalsToActivities,
|
completedGoalsToActivities,
|
||||||
@@ -74,7 +76,15 @@ const getRandomMotivation = () => {
|
|||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { getToken } = useAuth();
|
||||||
const { colors, typography } = useTheme();
|
const { colors, typography } = useTheme();
|
||||||
|
const {
|
||||||
|
steps,
|
||||||
|
goal: stepsGoal,
|
||||||
|
loading: stepsLoading,
|
||||||
|
supported: stepsSupported,
|
||||||
|
permissionGranted: stepsPermissionGranted,
|
||||||
|
} = useDailySteps();
|
||||||
const { features, membershipType } = useMembership();
|
const { features, membershipType } = useMembership();
|
||||||
const { refetchStatistics, forceRefresh, statistics, loading } =
|
const { refetchStatistics, forceRefresh, statistics, loading } =
|
||||||
useStatistics();
|
useStatistics();
|
||||||
@@ -85,6 +95,9 @@ export default function HomeScreen() {
|
|||||||
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
|
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
|
||||||
const [calories, setCalories] = useState(0);
|
const [calories, setCalories] = useState(0);
|
||||||
const [waterIntake, setWaterIntake] = useState(0);
|
const [waterIntake, setWaterIntake] = useState(0);
|
||||||
|
const [activeWorkoutSession, setActiveWorkoutSession] =
|
||||||
|
useState<Attendance | null>(null);
|
||||||
|
const [workoutActionLoading, setWorkoutActionLoading] = useState(false);
|
||||||
const [motivationalMessage, setMotivationalMessage] = useState(
|
const [motivationalMessage, setMotivationalMessage] = useState(
|
||||||
"Let's crush it today! 💪",
|
"Let's crush it today! 💪",
|
||||||
);
|
);
|
||||||
@@ -181,14 +194,78 @@ export default function HomeScreen() {
|
|||||||
}, getMillisecondsUntilNextMidnight() + 50);
|
}, getMillisecondsUntilNextMidnight() + 50);
|
||||||
}, [reconcileDailyMetrics]);
|
}, [reconcileDailyMetrics]);
|
||||||
|
|
||||||
|
const fetchActiveWorkoutSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) {
|
||||||
|
setActiveWorkoutSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await attendanceApi.getHistory(token);
|
||||||
|
if (history.length > 0 && !history[0].checkOutTime) {
|
||||||
|
setActiveWorkoutSession(history[0]);
|
||||||
|
} else {
|
||||||
|
setActiveWorkoutSession(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setActiveWorkoutSession(null);
|
||||||
|
}
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
void reconcileDailyMetrics();
|
void reconcileDailyMetrics();
|
||||||
|
void fetchActiveWorkoutSession();
|
||||||
refetchStatistics();
|
refetchStatistics();
|
||||||
refetchGoals();
|
refetchGoals();
|
||||||
}, [reconcileDailyMetrics, refetchStatistics, refetchGoals]),
|
}, [
|
||||||
|
fetchActiveWorkoutSession,
|
||||||
|
reconcileDailyMetrics,
|
||||||
|
refetchStatistics,
|
||||||
|
refetchGoals,
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleWorkoutAction = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setWorkoutActionLoading(true);
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) {
|
||||||
|
Alert.alert("Sign in required", "Please sign in to log your workout.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeWorkoutSession) {
|
||||||
|
await attendanceApi.checkOut(token);
|
||||||
|
Alert.alert("Workout logged", "Session ended successfully.");
|
||||||
|
} else {
|
||||||
|
await attendanceApi.checkIn("gym", token);
|
||||||
|
Alert.alert("Workout started", "Session started successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fetchActiveWorkoutSession(),
|
||||||
|
forceRefresh(),
|
||||||
|
refetchStatistics(),
|
||||||
|
]);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to update workout session. Please try again.";
|
||||||
|
Alert.alert("Workout action failed", message);
|
||||||
|
} finally {
|
||||||
|
setWorkoutActionLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeWorkoutSession,
|
||||||
|
fetchActiveWorkoutSession,
|
||||||
|
forceRefresh,
|
||||||
|
getToken,
|
||||||
|
refetchStatistics,
|
||||||
|
]);
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
await Promise.all([forceRefresh(), refetchGoals()]);
|
await Promise.all([forceRefresh(), refetchGoals()]);
|
||||||
@@ -491,6 +568,57 @@ export default function HomeScreen() {
|
|||||||
</MinimalCard>
|
</MinimalCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<SectionHeader title="Steps" subtitle="Daily movement progress" />
|
||||||
|
<MinimalCard variant="bordered" style={styles.progressCard}>
|
||||||
|
<View style={styles.progressHeader}>
|
||||||
|
<View style={styles.progressLabelRow}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressIcon,
|
||||||
|
{ backgroundColor: `${colors.workouts}20` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 20 }}>👣</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ marginLeft: 12 }}>
|
||||||
|
<Text style={[typography.h4, { color: colors.textPrimary }]}>
|
||||||
|
Steps
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[typography.caption, { color: colors.textTertiary }]}
|
||||||
|
>
|
||||||
|
{stepsLoading
|
||||||
|
? "Loading steps..."
|
||||||
|
: !stepsSupported
|
||||||
|
? "Step tracking not supported on this device"
|
||||||
|
: !stepsPermissionGranted
|
||||||
|
? "Enable motion access in settings"
|
||||||
|
: steps >= stepsGoal
|
||||||
|
? "Daily step goal reached!"
|
||||||
|
: `${Math.max(0, stepsGoal - steps)} steps remaining`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[typography.h3, { color: colors.workouts }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
adjustsFontSizeToFit
|
||||||
|
minimumFontScale={0.7}
|
||||||
|
>
|
||||||
|
{stepsLoading ? "--" : `${steps}/${stepsGoal}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={Math.min(steps / stepsGoal, 1)}
|
||||||
|
color={colors.workouts}
|
||||||
|
height={12}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
</MinimalCard>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -499,11 +627,17 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
<View style={styles.quickActionsGrid}>
|
<View style={styles.quickActionsGrid}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => console.log("Log workout")}
|
onPress={handleWorkoutAction}
|
||||||
|
disabled={workoutActionLoading}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
style={[
|
style={[
|
||||||
styles.quickActionCard,
|
styles.quickActionCard,
|
||||||
{ backgroundColor: colors.primary },
|
{
|
||||||
|
backgroundColor: activeWorkoutSession
|
||||||
|
? colors.warning
|
||||||
|
: colors.primary,
|
||||||
|
opacity: workoutActionLoading ? 0.7 : 1,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -512,12 +646,16 @@ export default function HomeScreen() {
|
|||||||
{ backgroundColor: "rgba(255,255,255,0.2)" },
|
{ backgroundColor: "rgba(255,255,255,0.2)" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name="barbell" size={28} color={colors.white} />
|
<Ionicons
|
||||||
|
name={activeWorkoutSession ? "stop-circle" : "barbell"}
|
||||||
|
size={28}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[typography.h4, { color: colors.white, marginTop: 12 }]}
|
style={[typography.h4, { color: colors.white, marginTop: 12 }]}
|
||||||
>
|
>
|
||||||
Workout
|
{activeWorkoutSession ? "End Workout" : "Start Workout"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -525,7 +663,16 @@ export default function HomeScreen() {
|
|||||||
{ color: "rgba(255,255,255,0.7)", marginTop: 4 },
|
{ color: "rgba(255,255,255,0.7)", marginTop: 4 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Log your session
|
{activeWorkoutSession
|
||||||
|
? `In session since ${new Date(
|
||||||
|
activeWorkoutSession.checkInTime,
|
||||||
|
).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}`
|
||||||
|
: workoutActionLoading
|
||||||
|
? "Updating session..."
|
||||||
|
: "Log your session"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -931,7 +1078,12 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
progressLabelRow: { flexDirection: "row", alignItems: "center" },
|
progressLabelRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
progressIcon: {
|
progressIcon: {
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { theme } from "../styles/theme";
|
|
||||||
|
|
||||||
interface AttendanceRecord {
|
|
||||||
id: string;
|
|
||||||
checkInTime: string;
|
|
||||||
checkOutTime?: string | null;
|
|
||||||
duration?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AttendanceCalendarProps {
|
|
||||||
attendanceRecords: AttendanceRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttendanceCalendar({
|
|
||||||
attendanceRecords,
|
|
||||||
}: AttendanceCalendarProps) {
|
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
||||||
const [calendarDays, setCalendarDays] = useState<
|
|
||||||
Array<{ date: Date | null; hasAttendance: boolean; isToday: boolean }>
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
generateCalendar(currentMonth);
|
|
||||||
}, [currentMonth, attendanceRecords]);
|
|
||||||
|
|
||||||
const generateCalendar = (month: Date) => {
|
|
||||||
const year = month.getFullYear();
|
|
||||||
const monthIndex = month.getMonth();
|
|
||||||
|
|
||||||
// Get first day of month and number of days
|
|
||||||
const firstDay = new Date(year, monthIndex, 1);
|
|
||||||
const lastDay = new Date(year, monthIndex + 1, 0);
|
|
||||||
const daysInMonth = lastDay.getDate();
|
|
||||||
const startingDayOfWeek = firstDay.getDay();
|
|
||||||
|
|
||||||
// Create attendance lookup set
|
|
||||||
const attendanceDates = new Set(
|
|
||||||
attendanceRecords.map((record) =>
|
|
||||||
new Date(record.checkInTime).toDateString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const today = new Date().toDateString();
|
|
||||||
|
|
||||||
// Build calendar array
|
|
||||||
const days: Array<{
|
|
||||||
date: Date | null;
|
|
||||||
hasAttendance: boolean;
|
|
||||||
isToday: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// Add empty cells for days before month starts
|
|
||||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
||||||
days.push({ date: null, hasAttendance: false, isToday: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add days of the month
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
const date = new Date(year, monthIndex, day);
|
|
||||||
const dateString = date.toDateString();
|
|
||||||
days.push({
|
|
||||||
date,
|
|
||||||
hasAttendance: attendanceDates.has(dateString),
|
|
||||||
isToday: dateString === today,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCalendarDays(days);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPreviousMonth = () => {
|
|
||||||
setCurrentMonth(
|
|
||||||
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToNextMonth = () => {
|
|
||||||
setCurrentMonth(
|
|
||||||
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const monthName = currentMonth.toLocaleDateString("en-US", {
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={[theme.colors.white, theme.colors.gray50]}
|
|
||||||
style={[styles.card, theme.shadows.medium]}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.title}>Attendance Calendar</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Month Navigation */}
|
|
||||||
<View style={styles.monthNav}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={goToPreviousMonth}
|
|
||||||
style={styles.navButton}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-back"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.primary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.monthText}>{monthName}</Text>
|
|
||||||
<TouchableOpacity onPress={goToNextMonth} style={styles.navButton}>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-forward"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.primary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Day Headers */}
|
|
||||||
<View style={styles.dayHeaders}>
|
|
||||||
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
|
|
||||||
<Text key={day} style={styles.dayHeader}>
|
|
||||||
{day}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Calendar Grid */}
|
|
||||||
<View style={styles.calendarGrid}>
|
|
||||||
{calendarDays.map((day, index) => (
|
|
||||||
<View key={index} style={styles.dayCell}>
|
|
||||||
{day.date ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dayContent,
|
|
||||||
day.isToday && styles.todayContent,
|
|
||||||
day.hasAttendance && styles.attendanceContent,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.dayText,
|
|
||||||
day.isToday && styles.todayText,
|
|
||||||
day.hasAttendance && styles.attendanceText,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{day.date.getDate()}
|
|
||||||
</Text>
|
|
||||||
{day.hasAttendance && <View style={styles.attendanceDot} />}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.emptyCell} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<View style={styles.legend}>
|
|
||||||
<View style={styles.legendItem}>
|
|
||||||
<View style={styles.legendDotAttendance} />
|
|
||||||
<Text style={styles.legendText}>Attended</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.legendItem}>
|
|
||||||
<View style={styles.legendDotToday} />
|
|
||||||
<Text style={styles.legendText}>Today</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: theme.typography.fontSize.xl,
|
|
||||||
fontWeight: theme.typography.fontWeight.bold,
|
|
||||||
color: theme.colors.gray800,
|
|
||||||
},
|
|
||||||
monthNav: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 16,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
},
|
|
||||||
navButton: {
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
monthText: {
|
|
||||||
fontSize: theme.typography.fontSize.lg,
|
|
||||||
fontWeight: theme.typography.fontWeight.semibold,
|
|
||||||
color: theme.colors.gray700,
|
|
||||||
},
|
|
||||||
dayHeaders: {
|
|
||||||
flexDirection: "row",
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
dayHeader: {
|
|
||||||
flex: 1,
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: theme.typography.fontSize.xs,
|
|
||||||
fontWeight: theme.typography.fontWeight.semibold,
|
|
||||||
color: theme.colors.gray500,
|
|
||||||
},
|
|
||||||
calendarGrid: {
|
|
||||||
flexDirection: "row",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
},
|
|
||||||
dayCell: {
|
|
||||||
width: `${100 / 7}%`,
|
|
||||||
aspectRatio: 1,
|
|
||||||
padding: 2,
|
|
||||||
},
|
|
||||||
dayContent: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
todayContent: {
|
|
||||||
backgroundColor: theme.colors.primaryLight,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.primary,
|
|
||||||
},
|
|
||||||
attendanceContent: {
|
|
||||||
backgroundColor: theme.colors.successLight,
|
|
||||||
},
|
|
||||||
emptyCell: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
dayText: {
|
|
||||||
fontSize: theme.typography.fontSize.sm,
|
|
||||||
color: theme.colors.gray700,
|
|
||||||
fontWeight: theme.typography.fontWeight.medium,
|
|
||||||
},
|
|
||||||
todayText: {
|
|
||||||
color: theme.colors.white,
|
|
||||||
fontWeight: theme.typography.fontWeight.bold,
|
|
||||||
},
|
|
||||||
attendanceText: {
|
|
||||||
color: theme.colors.white,
|
|
||||||
fontWeight: theme.typography.fontWeight.bold,
|
|
||||||
},
|
|
||||||
attendanceDot: {
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 2,
|
|
||||||
width: 4,
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: theme.colors.white,
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 20,
|
|
||||||
marginTop: 16,
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: theme.colors.gray200,
|
|
||||||
},
|
|
||||||
legendItem: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
legendDotAttendance: {
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor: theme.colors.successLight,
|
|
||||||
},
|
|
||||||
legendDotToday: {
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor: theme.colors.primaryLight,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.primary,
|
|
||||||
},
|
|
||||||
legendText: {
|
|
||||||
fontSize: theme.typography.fontSize.xs,
|
|
||||||
color: theme.colors.gray600,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -49,8 +49,6 @@ export function CustomTabBar({
|
|||||||
return focused ? "home" : "home-outline";
|
return focused ? "home" : "home-outline";
|
||||||
case "goals":
|
case "goals":
|
||||||
return focused ? "trophy" : "trophy-outline";
|
return focused ? "trophy" : "trophy-outline";
|
||||||
case "attendance":
|
|
||||||
return focused ? "calendar" : "calendar-outline";
|
|
||||||
case "recommendations":
|
case "recommendations":
|
||||||
return focused ? "sparkles" : "sparkles-outline";
|
return focused ? "sparkles" : "sparkles-outline";
|
||||||
case "profile":
|
case "profile":
|
||||||
@@ -66,8 +64,6 @@ export function CustomTabBar({
|
|||||||
return "Home";
|
return "Home";
|
||||||
case "goals":
|
case "goals":
|
||||||
return "Goals";
|
return "Goals";
|
||||||
case "attendance":
|
|
||||||
return "Attendance";
|
|
||||||
case "recommendations":
|
case "recommendations":
|
||||||
return "Plans";
|
return "Plans";
|
||||||
case "profile":
|
case "profile":
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface GoalProgressCardProps {
|
|||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
aiAligned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GoalProgressCard({
|
export function GoalProgressCard({
|
||||||
@@ -27,6 +28,7 @@ export function GoalProgressCard({
|
|||||||
onPress,
|
onPress,
|
||||||
onComplete,
|
onComplete,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
aiAligned = false,
|
||||||
}: GoalProgressCardProps) {
|
}: GoalProgressCardProps) {
|
||||||
const { colors, typography } = useTheme();
|
const { colors, typography } = useTheme();
|
||||||
const isCompleted = goal.status === "completed";
|
const isCompleted = goal.status === "completed";
|
||||||
@@ -277,6 +279,10 @@ export function GoalProgressCard({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{aiAligned && !isCompleted && (
|
||||||
|
<Badge variant="info" label="AI-ALIGNED" size="sm" />
|
||||||
|
)}
|
||||||
|
|
||||||
{isCompleted && goal.completedDate && (
|
{isCompleted && goal.completedDate && (
|
||||||
<Text style={[typography.caption, { color: colors.success }]}>
|
<Text style={[typography.caption, { color: colors.success }]}>
|
||||||
Completed {new Date(goal.completedDate).toLocaleDateString()}
|
Completed {new Date(goal.completedDate).toLocaleDateString()}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useUser, useAuth } from "@clerk/clerk-expo";
|
|||||||
import {
|
import {
|
||||||
getRecommendations,
|
getRecommendations,
|
||||||
generateRecommendation,
|
generateRecommendation,
|
||||||
|
generateSelfRecommendation,
|
||||||
type Recommendation,
|
type Recommendation,
|
||||||
type GenerateRecommendationRequest,
|
type GenerateRecommendationRequest,
|
||||||
} from "../api/recommendations";
|
} from "../api/recommendations";
|
||||||
@@ -23,6 +24,7 @@ interface RecommendationsContextValue {
|
|||||||
generateNewRecommendation: (
|
generateNewRecommendation: (
|
||||||
data: GenerateRecommendationRequest,
|
data: GenerateRecommendationRequest,
|
||||||
) => Promise<Recommendation>;
|
) => Promise<Recommendation>;
|
||||||
|
generateSelfPlan: () => Promise<Recommendation>;
|
||||||
clearCache: () => void;
|
clearCache: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,18 @@ export function RecommendationsProvider({
|
|||||||
[user?.id, getToken],
|
[user?.id, getToken],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const generateSelfPlan = useCallback(async (): Promise<Recommendation> => {
|
||||||
|
if (!user?.id) throw new Error("User not authenticated");
|
||||||
|
|
||||||
|
const token = await getToken();
|
||||||
|
const recommendation = await generateSelfRecommendation(token);
|
||||||
|
|
||||||
|
setRecommendations((prev) => [recommendation, ...prev]);
|
||||||
|
setLastFetchTime(Date.now());
|
||||||
|
|
||||||
|
return recommendation;
|
||||||
|
}, [user?.id, getToken]);
|
||||||
|
|
||||||
const clearCache = useCallback(() => {
|
const clearCache = useCallback(() => {
|
||||||
setRecommendations([]);
|
setRecommendations([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -133,6 +147,7 @@ export function RecommendationsProvider({
|
|||||||
error,
|
error,
|
||||||
refetchRecommendations,
|
refetchRecommendations,
|
||||||
generateNewRecommendation,
|
generateNewRecommendation,
|
||||||
|
generateSelfPlan,
|
||||||
clearCache,
|
clearCache,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
|
import { Pedometer } from "expo-sensors";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
|
|
||||||
|
interface DailyStepsState {
|
||||||
|
steps: number;
|
||||||
|
goal: number;
|
||||||
|
loading: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
permissionGranted: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DAILY_STEPS_GOAL = 8000;
|
||||||
|
|
||||||
|
function startOfToday(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function millisecondsUntilNextMidnight(): number {
|
||||||
|
const now = new Date();
|
||||||
|
const nextMidnight = new Date(now);
|
||||||
|
nextMidnight.setHours(24, 0, 0, 0);
|
||||||
|
return Math.max(1000, nextMidnight.getTime() - now.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDailySteps(): DailyStepsState {
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
const [steps, setSteps] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [supported, setSupported] = useState(true);
|
||||||
|
const [permissionGranted, setPermissionGranted] = useState(true);
|
||||||
|
const pedometerSubscriptionRef = useRef<ReturnType<
|
||||||
|
typeof Pedometer.watchStepCount
|
||||||
|
> | null>(null);
|
||||||
|
const midnightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const fetchDailySteps = useCallback(async () => {
|
||||||
|
if (!isSignedIn) {
|
||||||
|
setSteps(0);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const isAvailable = await Pedometer.isAvailableAsync();
|
||||||
|
setSupported(Boolean(isAvailable));
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
setSteps(0);
|
||||||
|
setPermissionGranted(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = startOfToday();
|
||||||
|
const end = new Date();
|
||||||
|
const result = await Pedometer.getStepCountAsync(start, end);
|
||||||
|
|
||||||
|
setSteps(Math.max(0, result.steps ?? 0));
|
||||||
|
setPermissionGranted(true);
|
||||||
|
} catch {
|
||||||
|
setPermissionGranted(false);
|
||||||
|
setSteps(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isSignedIn]);
|
||||||
|
|
||||||
|
const resetAtMidnight = useCallback(() => {
|
||||||
|
if (midnightTimerRef.current) {
|
||||||
|
clearTimeout(midnightTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
midnightTimerRef.current = setTimeout(() => {
|
||||||
|
void fetchDailySteps();
|
||||||
|
resetAtMidnight();
|
||||||
|
}, millisecondsUntilNextMidnight() + 100);
|
||||||
|
}, [fetchDailySteps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchDailySteps();
|
||||||
|
}, [fetchDailySteps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pedometerSubscriptionRef.current) {
|
||||||
|
pedometerSubscriptionRef.current.remove();
|
||||||
|
pedometerSubscriptionRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSignedIn || !supported || !permissionGranted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pedometerSubscriptionRef.current = Pedometer.watchStepCount(
|
||||||
|
(result: { steps: number }) => {
|
||||||
|
setSteps((prev) => Math.max(0, prev + Math.max(0, result.steps ?? 0)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pedometerSubscriptionRef.current) {
|
||||||
|
pedometerSubscriptionRef.current.remove();
|
||||||
|
pedometerSubscriptionRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isSignedIn, permissionGranted, supported]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener("change", (state) => {
|
||||||
|
if (state === "active") {
|
||||||
|
void fetchDailySteps();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resetAtMidnight();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
if (midnightTimerRef.current) {
|
||||||
|
clearTimeout(midnightTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchDailySteps, resetAtMidnight]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
goal: DEFAULT_DAILY_STEPS_GOAL,
|
||||||
|
loading,
|
||||||
|
supported,
|
||||||
|
permissionGranted,
|
||||||
|
refresh: fetchDailySteps,
|
||||||
|
};
|
||||||
|
}
|
||||||