Compare commits

..

No commits in common. "master" and "testRep" have entirely different histories.

108 changed files with 2971 additions and 6015 deletions

Binary file not shown.

View File

@ -1,180 +1,137 @@
/**
* @jest-environment node
*/
import { POST as checkIn } from "../check-in/route";
import { POST as checkOut } from "../check-out/route";
import { GET as history } from "../history/route";
import { NextRequest } from "next/server";
import { POST as checkIn } from '../check-in/route'
import { POST as checkOut } from '../check-out/route'
import { GET as history } from '../history/route'
import { NextRequest } from 'next/server'
// Mock dependencies
jest.mock("@clerk/nextjs/server", () => ({
auth: jest.fn(() => Promise.resolve({ userId: "test_user_id" })),
currentUser: jest.fn(() =>
Promise.resolve({
id: "test_user_id",
emailAddresses: [{ emailAddress: "test@example.com" }],
}),
),
}));
jest.mock('@clerk/nextjs/server', () => ({
auth: jest.fn(() => Promise.resolve({ userId: 'test_user_id' })),
currentUser: jest.fn(() => Promise.resolve({ id: 'test_user_id', emailAddresses: [{ emailAddress: 'test@example.com' }] }))
}))
jest.mock("@/lib/sync-user", () => ({
ensureUserSynced: jest.fn(),
}));
jest.mock("@/lib/geofence", () => ({
getUserGymGeofence: jest.fn(() =>
Promise.resolve({
id: "gym_1",
name: "Test Gym",
latitude: 1,
longitude: 1,
geofenceRadiusMeters: 30,
geofenceEnabled: true,
}),
),
parseUserLocation: jest.fn(() => ({
latitude: 1,
longitude: 1,
accuracy: 10,
})),
validateGeofence: jest.fn(() => ({ ok: true })),
validateGeofenceWithFallback: jest.fn(() => ({ ok: true })),
validateCheckInGeofence: jest.fn(() => ({ ok: true })),
}));
jest.mock('@/lib/sync-user', () => ({
ensureUserSynced: jest.fn()
}))
const mockDb = {
checkIn: jest.fn(),
checkOut: jest.fn(),
getAttendanceHistory: jest.fn(),
getActiveCheckIn: jest.fn(),
getUserById: jest.fn(),
createUser: jest.fn(),
getClientByUserId: jest.fn(),
createClient: jest.fn(),
getFitnessProfileByUserId: jest.fn(),
createFitnessProfile: jest.fn(),
};
checkIn: jest.fn(),
checkOut: jest.fn(),
getAttendanceHistory: jest.fn(),
getActiveCheckIn: jest.fn(),
getUserById: jest.fn(),
createUser: jest.fn(),
getClientByUserId: jest.fn(),
createClient: jest.fn(),
getFitnessProfileByUserId: jest.fn(),
createFitnessProfile: jest.fn(),
}
jest.mock("@/lib/database", () => ({
getDatabase: jest.fn(() => Promise.resolve(mockDb)),
}));
jest.mock('@/lib/database', () => ({
getDatabase: jest.fn(() => Promise.resolve(mockDb))
}))
describe("Attendance API", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Attendance API', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("POST /api/attendance/check-in", () => {
it("should successfully check in", async () => {
mockDb.getUserById.mockResolvedValue({ id: "test_user_id" });
mockDb.getActiveCheckIn.mockResolvedValue(null);
mockDb.checkIn.mockResolvedValue({
id: "attendance_id",
userId: "test_user_id",
checkInTime: new Date(),
type: "gym",
});
describe('POST /api/attendance/check-in', () => {
it('should successfully check in', async () => {
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
mockDb.getActiveCheckIn.mockResolvedValue(null)
mockDb.checkIn.mockResolvedValue({
id: 'attendance_id',
userId: 'test_user_id',
checkInTime: new Date(),
type: 'gym'
})
const req = new NextRequest("http://localhost/api/attendance/check-in", {
method: "POST",
body: JSON.stringify({
type: "gym",
notes: "Test check-in",
location: { latitude: 1, longitude: 1, accuracy: 10 },
}),
});
const req = new NextRequest('http://localhost/api/attendance/check-in', {
method: 'POST',
body: JSON.stringify({ type: 'gym', notes: 'Test check-in' })
})
const res = await checkIn(req);
const data = await res.json();
const res = await checkIn(req)
const data = await res.json()
expect(res.status).toBe(200);
expect(data.id).toBe("attendance_id");
expect(data.userId).toBe("test_user_id");
expect(mockDb.checkIn).toHaveBeenCalledWith(
"test_user_id",
"gym",
"Test check-in",
);
});
expect(res.status).toBe(200)
expect(data.id).toBe('attendance_id')
expect(data.userId).toBe('test_user_id')
expect(mockDb.checkIn).toHaveBeenCalledWith('test_user_id', 'gym', 'Test check-in')
})
it("should fail if already checked in", async () => {
mockDb.getUserById.mockResolvedValue({ id: "test_user_id" });
mockDb.getActiveCheckIn.mockResolvedValue({ id: "existing_id" });
it('should fail if already checked in', async () => {
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
mockDb.getActiveCheckIn.mockResolvedValue({ id: 'existing_id' })
const req = new NextRequest("http://localhost/api/attendance/check-in", {
method: "POST",
body: JSON.stringify({
type: "gym",
location: { latitude: 1, longitude: 1, accuracy: 10 },
}),
});
const req = new NextRequest('http://localhost/api/attendance/check-in', {
method: 'POST',
body: JSON.stringify({ type: 'gym' })
})
const res = await checkIn(req);
const text = await res.text();
const res = await checkIn(req)
const text = await res.text()
expect(res.status).toBe(400);
expect(text).toBe("Already checked in");
});
});
expect(res.status).toBe(400)
expect(text).toBe('Already checked in')
})
})
describe("POST /api/attendance/check-out", () => {
it("should successfully check out", async () => {
mockDb.getActiveCheckIn.mockResolvedValue({ id: "attendance_id" });
mockDb.checkOut.mockResolvedValue({
id: "attendance_id",
checkOutTime: new Date(),
});
describe('POST /api/attendance/check-out', () => {
it('should successfully check out', async () => {
mockDb.getActiveCheckIn.mockResolvedValue({ id: 'attendance_id' })
mockDb.checkOut.mockResolvedValue({
id: 'attendance_id',
checkOutTime: new Date()
})
const req = new NextRequest("http://localhost/api/attendance/check-out", {
method: "POST",
body: JSON.stringify({
location: { latitude: 1, longitude: 1, accuracy: 10 },
}),
});
const req = new NextRequest('http://localhost/api/attendance/check-out', {
method: 'POST'
})
const res = await checkOut(req);
const data = await res.json();
const res = await checkOut(req)
const data = await res.json()
expect(res.status).toBe(200);
expect(data.id).toBe("attendance_id");
expect(data.checkOutTime).toBeDefined();
expect(mockDb.checkOut).toHaveBeenCalledWith("attendance_id");
});
expect(res.status).toBe(200)
expect(data.id).toBe('attendance_id')
expect(data.checkOutTime).toBeDefined()
expect(mockDb.checkOut).toHaveBeenCalledWith('attendance_id')
})
it("should fail if not checked in", async () => {
mockDb.getActiveCheckIn.mockResolvedValue(null);
it('should fail if not checked in', async () => {
mockDb.getActiveCheckIn.mockResolvedValue(null)
const req = new NextRequest("http://localhost/api/attendance/check-out", {
method: "POST",
body: JSON.stringify({
location: { latitude: 1, longitude: 1, accuracy: 10 },
}),
});
const req = new NextRequest('http://localhost/api/attendance/check-out', {
method: 'POST'
})
const res = await checkOut(req);
const text = await res.text();
const res = await checkOut(req)
const text = await res.text()
expect(res.status).toBe(404);
expect(text).toBe("No active check-in found");
});
});
expect(res.status).toBe(404)
expect(text).toBe('No active check-in found')
})
})
describe("GET /api/attendance/history", () => {
it("should return attendance history", async () => {
const historyData = [
{ id: "1", checkInTime: new Date() },
{ id: "2", checkInTime: new Date() },
];
mockDb.getUserById.mockResolvedValue({ id: "test_user_id" });
mockDb.getAttendanceHistory.mockResolvedValue(historyData);
describe('GET /api/attendance/history', () => {
it('should return attendance history', async () => {
const historyData = [
{ id: '1', checkInTime: new Date() },
{ id: '2', checkInTime: new Date() }
]
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
mockDb.getAttendanceHistory.mockResolvedValue(historyData)
const req = new NextRequest("http://localhost/api/attendance/history");
const res = await history(req);
const data = await res.json();
const req = new NextRequest('http://localhost/api/attendance/history')
const res = await history(req)
const data = await res.json()
expect(res.status).toBe(200);
expect(data).toEqual(JSON.parse(JSON.stringify(historyData))); // Handle date serialization
expect(mockDb.getAttendanceHistory).toHaveBeenCalledWith("test_user_id");
});
});
});
expect(res.status).toBe(200)
expect(data).toEqual(JSON.parse(JSON.stringify(historyData))) // Handle date serialization
expect(mockDb.getAttendanceHistory).toHaveBeenCalledWith('test_user_id')
})
})
})

View File

@ -2,12 +2,12 @@ import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import {
getUserGymGeofence,
parseUserLocation,
validateCheckInGeofence,
} from "@/lib/geofence";
import log from "@/lib/logger";
import { checkInSchema } from "@/lib/validation/schemas";
import {
validateRequestBody,
validationErrorResponse,
} from "@/lib/validation/helpers";
export async function POST(req: NextRequest) {
try {
@ -25,26 +25,8 @@ export async function POST(req: NextRequest) {
return new NextResponse("Already checked in", { status: 400 });
}
const body = await req.json().catch(() => ({}));
const body = await req.json();
const { type = "gym", notes } = body;
const fallbackRequested = Boolean(body.fallbackRequested);
const gym = await getUserGymGeofence(userId);
if (!gym) {
return NextResponse.json(
{ error: "No gym assigned for this user" },
{ status: 400 },
);
}
const location = parseUserLocation(body.location);
const geofence = validateCheckInGeofence(gym, location, fallbackRequested);
if (!geofence.ok) {
return NextResponse.json(
{ error: geofence.error },
{ status: geofence.status },
);
}
const attendance = await db.checkIn(userId, type, notes);
return NextResponse.json(attendance);

View File

@ -1,11 +1,6 @@
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import {
getUserGymGeofence,
parseUserLocation,
validateGeofenceWithFallback,
} from "@/lib/geofence";
import log from "@/lib/logger";
export async function POST(req: Request) {
@ -20,30 +15,6 @@ export async function POST(req: Request) {
return new NextResponse("No active check-in found", { status: 404 });
}
const body = await req.json().catch(() => ({}));
const fallbackRequested = Boolean(body.fallbackRequested);
const gym = await getUserGymGeofence(userId);
if (!gym) {
return NextResponse.json(
{ error: "No gym assigned for this user" },
{ status: 400 },
);
}
const location = parseUserLocation(body.location);
const geofence = validateGeofenceWithFallback(
gym,
location,
fallbackRequested,
);
if (!geofence.ok) {
return NextResponse.json(
{ error: geofence.error },
{ status: geofence.status },
);
}
const attendance = await db.checkOut(activeCheckIn.id);
return NextResponse.json(attendance);
} catch (error) {

View File

@ -1,156 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getUserMembershipContext } from "@/lib/membership/access";
import log from "@/lib/logger";
interface OpenFoodFactsProduct {
product_name?: string;
product_name_en?: string;
brands?: string;
image_url?: string;
image_front_url?: string;
serving_size?: string;
nutriments?: {
[key: string]: number | string | undefined;
"energy-kcal_serving"?: number;
"energy-kcal_100g"?: number;
proteins_serving?: number;
proteins_100g?: number;
carbohydrates_serving?: number;
carbohydrates_100g?: number;
fat_serving?: number;
fat_100g?: number;
};
}
interface OpenFoodFactsResponse {
status: number;
code: string;
product?: OpenFoodFactsProduct;
}
function normalizeBarcode(rawCode: string): string {
return rawCode.replace(/\D/g, "").trim();
}
function isSupportedBarcode(code: string): boolean {
return [8, 12, 13].includes(code.length);
}
function getNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function buildProductPayload(code: string, product: OpenFoodFactsProduct) {
const caloriesPerServing =
getNumber(product.nutriments?.["energy-kcal_serving"]) ??
getNumber(product.nutriments?.["energy-kcal_100g"]) ??
0;
const protein =
getNumber(product.nutriments?.proteins_serving) ??
getNumber(product.nutriments?.proteins_100g);
const carbs =
getNumber(product.nutriments?.carbohydrates_serving) ??
getNumber(product.nutriments?.carbohydrates_100g);
const fat =
getNumber(product.nutriments?.fat_serving) ??
getNumber(product.nutriments?.fat_100g);
return {
barcode: code,
name: product.product_name || product.product_name_en || "Unknown Product",
brand: product.brands || null,
imageUrl: product.image_url || product.image_front_url || null,
servingSize: product.serving_size || "1 serving",
caloriesPerServing: Math.max(0, Math.round(caloriesPerServing)),
macros: {
protein: protein ?? null,
carbs: carbs ?? null,
fat: fat ?? null,
},
source: "openfoodfacts" as const,
};
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ code: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Barcode food scan is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const { code: rawCode } = await params;
const code = normalizeBarcode(rawCode);
if (!isSupportedBarcode(code)) {
return NextResponse.json(
{ error: "Invalid barcode. Use EAN-8, UPC-A, or EAN-13 formats." },
{ status: 400 },
);
}
const response = await fetch(
`https://world.openfoodfacts.org/api/v2/product/${code}.json`,
{
headers: {
"User-Agent": "FitAI/1.0 (fitai.app)",
},
cache: "no-store",
},
);
if (!response.ok) {
log.warn("OpenFoodFacts lookup failed", {
status: response.status,
barcode: code,
});
return NextResponse.json(
{ error: "Food lookup service unavailable. Please try again." },
{ status: 503 },
);
}
const payload = (await response.json()) as OpenFoodFactsResponse;
if (payload.status !== 1 || !payload.product) {
return NextResponse.json(
{ error: "Product not found in OpenFoodFacts" },
{ status: 404 },
);
}
return NextResponse.json({
success: true,
data: buildProductPayload(code, payload.product),
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed barcode food lookup", error);
return NextResponse.json(
{ error: "Failed to lookup food barcode" },
{ status: 500 },
);
}
}

View File

@ -18,178 +18,6 @@ async function ensureGymsTable() {
updated_at INTEGER NOT NULL
)
`);
const columns = await db.all(sql`PRAGMA table_info('gyms')`);
const columnNames = new Set(
(columns as Array<{ name?: string }>)
.map((col) => col.name)
.filter(Boolean),
);
if (!columnNames.has("latitude")) {
await db.run(sql`ALTER TABLE gyms ADD COLUMN latitude REAL`);
}
if (!columnNames.has("longitude")) {
await db.run(sql`ALTER TABLE gyms ADD COLUMN longitude REAL`);
}
if (!columnNames.has("geofence_radius_meters")) {
await db.run(
sql`ALTER TABLE gyms ADD COLUMN geofence_radius_meters REAL NOT NULL DEFAULT 30`,
);
}
if (!columnNames.has("geofence_enabled")) {
await db.run(
sql`ALTER TABLE gyms ADD COLUMN geofence_enabled INTEGER NOT NULL DEFAULT 1`,
);
}
}
// PATCH /api/gyms/[id]
// Update gym details and geofence configuration
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id: gymId } = await params;
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const appDb = await getDatabase();
const currentUser = await ensureUserSynced(userId, appDb);
if (
!currentUser ||
(currentUser.role !== "superAdmin" && currentUser.role !== "admin")
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await ensureGymsTable();
const existingGym = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.id, gymId))
.get();
if (!existingGym) {
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
}
if (
currentUser.role === "admin" &&
currentUser.gymId &&
currentUser.gymId !== gymId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const latitude =
body.latitude === undefined || body.latitude === null
? null
: Number(body.latitude);
const longitude =
body.longitude === undefined || body.longitude === null
? null
: Number(body.longitude);
const geofenceRadiusMeters =
body.geofenceRadiusMeters === undefined ||
body.geofenceRadiusMeters === null
? 30
: Number(body.geofenceRadiusMeters);
const geofenceEnabled =
body.geofenceEnabled === undefined ? true : Boolean(body.geofenceEnabled);
if (
latitude !== null &&
(!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
) {
return NextResponse.json(
{ error: "latitude must be between -90 and 90" },
{ status: 400 },
);
}
if (
longitude !== null &&
(!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
) {
return NextResponse.json(
{ error: "longitude must be between -180 and 180" },
{ status: 400 },
);
}
if (!Number.isFinite(geofenceRadiusMeters) || geofenceRadiusMeters <= 0) {
return NextResponse.json(
{ error: "geofenceRadiusMeters must be a positive number" },
{ status: 400 },
);
}
await db.run(sql`
UPDATE gyms
SET
latitude = ${latitude},
longitude = ${longitude},
geofence_radius_meters = ${geofenceRadiusMeters},
geofence_enabled = ${geofenceEnabled ? 1 : 0},
updated_at = ${Math.floor(Date.now() / 1000)}
WHERE id = ${gymId}
`);
const updatedRows = await db.all(sql`
SELECT
id,
name,
location,
latitude,
longitude,
geofence_radius_meters as geofenceRadiusMeters,
geofence_enabled as geofenceEnabled,
status,
admin_user_id as adminUserId,
created_at as createdAt,
updated_at as updatedAt
FROM gyms
WHERE id = ${gymId}
LIMIT 1
`);
const updated = updatedRows?.[0]
? {
...updatedRows[0],
geofenceEnabled:
typeof (updatedRows[0] as { geofenceEnabled?: unknown })
.geofenceEnabled === "boolean"
? (updatedRows[0] as { geofenceEnabled: boolean }).geofenceEnabled
: Boolean(
(updatedRows[0] as { geofenceEnabled?: unknown })
.geofenceEnabled,
),
}
: null;
return NextResponse.json(updated);
} catch (error) {
log.error("Failed to update gym", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
// DELETE /api/gyms/[id]

View File

@ -18,33 +18,6 @@ async function ensureGymsTable() {
updated_at INTEGER NOT NULL
)
`);
const columns = await db.all(sql`PRAGMA table_info('gyms')`);
const columnNames = new Set(
(columns as Array<{ name?: string }>)
.map((col) => col.name)
.filter(Boolean),
);
if (!columnNames.has("latitude")) {
await db.run(sql`ALTER TABLE gyms ADD COLUMN latitude REAL`);
}
if (!columnNames.has("longitude")) {
await db.run(sql`ALTER TABLE gyms ADD COLUMN longitude REAL`);
}
if (!columnNames.has("geofence_radius_meters")) {
await db.run(
sql`ALTER TABLE gyms ADD COLUMN geofence_radius_meters REAL NOT NULL DEFAULT 30`,
);
}
if (!columnNames.has("geofence_enabled")) {
await db.run(
sql`ALTER TABLE gyms ADD COLUMN geofence_enabled INTEGER NOT NULL DEFAULT 1`,
);
}
}
// GET /api/gyms
@ -68,35 +41,12 @@ export async function GET() {
}
await ensureGymsTable();
let rows = (await db.all(sql`
SELECT
id,
name,
location,
latitude,
longitude,
geofence_radius_meters as geofenceRadiusMeters,
geofence_enabled as geofenceEnabled,
status,
admin_user_id as adminUserId,
created_at as createdAt,
updated_at as updatedAt
FROM gyms
WHERE status = 'active'
ORDER BY created_at DESC
`)) as Array<{
id: string;
name: string;
location: string | null;
latitude: number | null;
longitude: number | null;
geofenceRadiusMeters: number | null;
geofenceEnabled: number | boolean | null;
status: "active" | "inactive";
adminUserId: string;
createdAt: number;
updatedAt: number;
}>;
let rows = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.status, "active"))
.orderBy(sql`created_at DESC`)
.all();
if (currentUser.role !== "superAdmin") {
if (!currentUser.gymId) {
@ -105,15 +55,7 @@ export async function GET() {
rows = rows.filter((row) => row.id === currentUser.gymId);
}
return NextResponse.json(
rows.map((row) => ({
...row,
geofenceEnabled:
typeof row.geofenceEnabled === "boolean"
? row.geofenceEnabled
: Boolean(row.geofenceEnabled),
})),
);
return NextResponse.json(rows);
} catch (error) {
log.error("Failed to get gyms", error);
return new NextResponse("Internal Server Error", { status: 500 });
@ -147,21 +89,6 @@ export async function POST(req: Request) {
const name = String(body.name ?? "").trim();
const location = body.location ? String(body.location).trim() : null;
const latitude =
body.latitude === undefined || body.latitude === null
? null
: Number(body.latitude);
const longitude =
body.longitude === undefined || body.longitude === null
? null
: Number(body.longitude);
const geofenceRadiusMeters =
body.geofenceRadiusMeters === undefined ||
body.geofenceRadiusMeters === null
? 30
: Number(body.geofenceRadiusMeters);
const geofenceEnabled =
body.geofenceEnabled === undefined ? true : Boolean(body.geofenceEnabled);
let adminUserId: string | null = body.adminUserId
? String(body.adminUserId)
: null;
@ -170,33 +97,6 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (
latitude !== null &&
(!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
) {
return NextResponse.json(
{ error: "latitude must be between -90 and 90" },
{ status: 400 },
);
}
if (
longitude !== null &&
(!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
) {
return NextResponse.json(
{ error: "longitude must be between -180 and 180" },
{ status: 400 },
);
}
if (!Number.isFinite(geofenceRadiusMeters) || geofenceRadiusMeters <= 0) {
return NextResponse.json(
{ error: "geofenceRadiusMeters must be a positive number" },
{ status: 400 },
);
}
// Enforce admin ownership rules
if (currentUser.role === "admin") {
adminUserId = currentUser.id;
@ -224,33 +124,15 @@ export async function POST(req: Request) {
const nowTs = new Date();
// Use Drizzle's insert method instead of raw SQL
await db.run(sql`
INSERT INTO gyms (
id,
name,
location,
latitude,
longitude,
geofence_radius_meters,
geofence_enabled,
status,
admin_user_id,
created_at,
updated_at
) VALUES (
${id},
${name},
${location ?? null},
${latitude},
${longitude},
${geofenceRadiusMeters},
${geofenceEnabled ? 1 : 0},
${"active"},
${adminUserId!},
${Math.floor(nowTs.getTime() / 1000)},
${Math.floor(nowTs.getTime() / 1000)}
)
`);
await db.insert(gymsTable).values({
id,
name,
location: location ?? null,
status: "active",
adminUserId: adminUserId!,
createdAt: nowTs,
updatedAt: nowTs,
});
// Assign the admin to this gym immediately after creation
await db
@ -258,36 +140,11 @@ export async function POST(req: Request) {
.set({ gymId: id, updatedAt: nowTs })
.where(eq(usersTable.id, adminUserId!));
const rowsCreated = await db.all(sql`
SELECT
id,
name,
location,
latitude,
longitude,
geofence_radius_meters as geofenceRadiusMeters,
geofence_enabled as geofenceEnabled,
status,
admin_user_id as adminUserId,
created_at as createdAt,
updated_at as updatedAt
FROM gyms
WHERE id = ${id}
LIMIT 1
`);
const createdRow = rowsCreated?.[0] ?? null;
const created = createdRow
? {
...createdRow,
geofenceEnabled:
typeof (createdRow as { geofenceEnabled?: unknown })
.geofenceEnabled === "boolean"
? (createdRow as { geofenceEnabled: boolean }).geofenceEnabled
: Boolean(
(createdRow as { geofenceEnabled?: unknown }).geofenceEnabled,
),
}
: null;
const created = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.id, id))
.get();
return NextResponse.json(created, { status: 201 });
} catch (error) {
log.error("Failed to create gym", error);

View File

@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function POST(req: NextRequest) {
try {
@ -12,18 +11,6 @@ export async function POST(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.hydrationTracking) {
return NextResponse.json(
{
error:
"Hydration tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const body = await req.json();
const { date, entries, totalWater, waterGoal } = body;
@ -71,18 +58,6 @@ export async function GET(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.hydrationTracking) {
return NextResponse.json(
{
error:
"Hydration tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const date = url.searchParams.get("date");
@ -125,18 +100,6 @@ export async function DELETE(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.hydrationTracking) {
return NextResponse.json(
{
error:
"Hydration tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const id = url.searchParams.get("id");

View File

@ -1,32 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { MEMBERSHIP_FEATURES } from "@/lib/membership/features";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function GET() {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { membershipType, features } = await getUserMembershipContext(userId);
return NextResponse.json({
success: true,
data: {
membershipType,
currentFeatures: features,
plans: MEMBERSHIP_FEATURES,
},
meta: {
timestamp: new Date().toISOString(),
},
});
} catch {
return NextResponse.json(
{ error: "Failed to load membership features" },
{ status: 500 },
);
}
}

View File

@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function POST(req: NextRequest) {
try {
@ -12,18 +11,6 @@ export async function POST(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const body = await req.json();
const {
@ -72,18 +59,6 @@ export async function GET(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const date = url.searchParams.get("date");
@ -113,18 +88,6 @@ export async function DELETE(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const id = url.searchParams.get("id");

View File

@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
import { getUserMembershipContext } from "@/lib/membership/access";
export async function POST(req: NextRequest) {
try {
@ -12,18 +11,6 @@ export async function POST(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const body = await req.json();
const { date, meals, totalCalories, calorieGoal } = body;
@ -71,18 +58,6 @@ export async function GET(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const date = url.searchParams.get("date");
@ -125,18 +100,6 @@ export async function DELETE(req: NextRequest) {
const db = await getDatabase();
await ensureUserSynced(userId, db);
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Nutrition tracking is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const url = new URL(req.url);
const id = url.searchParams.get("id");

View File

@ -4,101 +4,6 @@ import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
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) {
try {
const { userId: clerkUserId } = await auth();
@ -189,103 +94,8 @@ export async function POST(req: Request) {
);
}
let pausedGoalsCount = 0;
let createdGoalsCount = 0;
// If approved, regenerate linked AI goals and create a notification for the user
// If approved, create a notification for the user
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 {
await db.createNotification({
id: crypto.randomUUID(),
@ -312,8 +122,6 @@ export async function POST(req: Request) {
data: updatedRecommendation,
meta: {
timestamp: new Date().toISOString(),
pausedGoals: pausedGoalsCount,
createdGoals: createdGoalsCount,
},
});
} catch (error) {

View File

@ -1,455 +0,0 @@
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 },
);
}
}

View File

@ -5,7 +5,6 @@ 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";
export async function POST(req: Request) {
try {
@ -50,41 +49,6 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { membershipType, features } = await getUserMembershipContext(userId);
if (features.recommendationsPerMonth === 1) {
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 >= 1) {
return NextResponse.json(
{
error:
"Basic membership includes 1 recommendation per month. Upgrade to Premium or VIP for unlimited recommendations.",
membershipType,
},
{ status: 403 },
);
}
}
if (currentUser.role !== "superAdmin") {
if (!currentUser.gymId || targetUser.gymId !== currentUser.gymId) {
return NextResponse.json(

View File

@ -1,78 +1,8 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { db, users as usersTable, eq, sql } from "@fitai/database";
import { ensureGymsGeofenceColumns } from "@/lib/geofence";
import log from "@/lib/logger";
export async function GET() {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
.get();
if (!user) {
return new NextResponse("User not found", { status: 404 });
}
if (!user.gymId) {
return NextResponse.json({ gymId: null, gym: null });
}
await ensureGymsGeofenceColumns();
const rows = await db.all(sql`
SELECT
id,
name,
location,
latitude,
longitude,
geofence_radius_meters as geofenceRadiusMeters,
geofence_enabled as geofenceEnabled,
status
FROM gyms
WHERE id = ${user.gymId}
LIMIT 1
`);
const gym = rows?.[0] as
| {
id: string;
name: string;
location: string | null;
latitude: number | null;
longitude: number | null;
geofenceRadiusMeters: number | null;
geofenceEnabled: number | boolean | null;
status: "active" | "inactive";
}
| undefined;
if (!gym || gym.status !== "active") {
return NextResponse.json({ gymId: user.gymId, gym: null });
}
return NextResponse.json({
gymId: user.gymId,
gym: {
...gym,
geofenceEnabled:
typeof gym.geofenceEnabled === "boolean"
? gym.geofenceEnabled
: Boolean(gym.geofenceEnabled),
},
});
} catch (error) {
log.error("Failed to fetch current user gym", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
/**
* PATCH /api/users/gym
* Body: { gymId: string | null }

View File

@ -17,7 +17,6 @@ import {
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
import log from "@/lib/logger";
import { MEMBERSHIP_FEATURES } from "@/lib/membership/features";
interface Backup {
name: string;
@ -29,10 +28,6 @@ interface Gym {
id: string;
name: string;
location?: string | null;
latitude?: number | null;
longitude?: number | null;
geofenceRadiusMeters?: number | null;
geofenceEnabled?: boolean;
status: "active" | "inactive";
adminUserId: string;
createdAt?: number;
@ -76,11 +71,6 @@ export default function SettingsPage() {
const [gymStats, setGymStats] = useState<GymStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [deletingGym, setDeletingGym] = useState(false);
const [savingGeofence, setSavingGeofence] = useState(false);
const [geofenceLatitude, setGeofenceLatitude] = useState("");
const [geofenceLongitude, setGeofenceLongitude] = useState("");
const [geofenceRadiusMeters, setGeofenceRadiusMeters] = useState("30");
const [geofenceEnabled, setGeofenceEnabled] = useState(true);
// Create Gym modal state
const [showCreateGym, setShowCreateGym] = useState(false);
@ -195,87 +185,6 @@ export default function SettingsPage() {
const handleSelectGym = async (gym: Gym | null) => {
setSelectedGym(gym);
setGymStats(null);
if (gym) {
setGeofenceLatitude(
gym.latitude !== null && gym.latitude !== undefined
? String(gym.latitude)
: "",
);
setGeofenceLongitude(
gym.longitude !== null && gym.longitude !== undefined
? String(gym.longitude)
: "",
);
setGeofenceRadiusMeters(String(gym.geofenceRadiusMeters ?? 30));
setGeofenceEnabled(gym.geofenceEnabled ?? true);
}
};
const handleSaveGeofence = async () => {
if (!selectedGym) return;
const latitude =
geofenceLatitude.trim() === "" ? null : Number(geofenceLatitude);
const longitude =
geofenceLongitude.trim() === "" ? null : Number(geofenceLongitude);
const radius = Number(geofenceRadiusMeters);
if (
latitude !== null &&
(!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
) {
setGymMessage({
type: "error",
text: "Latitude must be between -90 and 90",
});
return;
}
if (
longitude !== null &&
(!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
) {
setGymMessage({
type: "error",
text: "Longitude must be between -180 and 180",
});
return;
}
if (!Number.isFinite(radius) || radius <= 0) {
setGymMessage({
type: "error",
text: "Radius must be a positive number",
});
return;
}
setSavingGeofence(true);
setGymMessage(null);
try {
const response = await axios.patch(`/api/gyms/${selectedGym.id}`, {
latitude,
longitude,
geofenceRadiusMeters: radius,
geofenceEnabled,
});
setGymMessage({ type: "success", text: "Geofence settings updated" });
const updatedGym = response.data as Gym;
setSelectedGym(updatedGym);
setGyms((prev) =>
prev.map((gym) => (gym.id === updatedGym.id ? updatedGym : gym)),
);
} catch (error) {
log.error("Failed to update geofence settings", error);
setGymMessage({
type: "error",
text: "Failed to update geofence settings",
});
} finally {
setSavingGeofence(false);
}
};
const handleDeleteGym = async (gymId: string) => {
@ -565,91 +474,6 @@ export default function SettingsPage() {
{selectedGym.status}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Geofence</p>
<p className="font-medium">
{selectedGym.geofenceEnabled === false
? "Disabled"
: `${selectedGym.geofenceRadiusMeters ?? 30}m`}
</p>
</div>
</div>
{/* Geofence Settings */}
<div className="p-4 border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-slate-700">
Attendance Geofence
</h5>
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={geofenceEnabled}
onChange={(e) => setGeofenceEnabled(e.target.checked)}
/>
Enabled
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1">
Latitude
</label>
<input
type="number"
step="any"
value={geofenceLatitude}
onChange={(e) => setGeofenceLatitude(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
placeholder="e.g. 37.7749"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">
Longitude
</label>
<input
type="number"
step="any"
value={geofenceLongitude}
onChange={(e) => setGeofenceLongitude(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
placeholder="e.g. -122.4194"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">
Radius (meters)
</label>
<input
type="number"
min="1"
value={geofenceRadiusMeters}
onChange={(e) =>
setGeofenceRadiusMeters(e.target.value)
}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-slate-500">
Default radius is 30m and geofence is enabled by default.
</p>
<Button
size="sm"
onClick={handleSaveGeofence}
disabled={savingGeofence}
>
{savingGeofence ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Save Geofence"
)}
</Button>
</div>
</div>
{/* Stats */}
@ -734,109 +558,6 @@ export default function SettingsPage() {
</div>
</div>
)}
{/* Membership Feature Access */}
<div className="mt-6">
<h5 className="text-sm font-medium text-slate-700 mb-2">
Membership Feature Access
</h5>
<div className="overflow-x-auto border rounded-lg">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 font-semibold text-slate-900">
Feature
</th>
<th className="px-4 py-3 font-semibold text-slate-900">
Basic
</th>
<th className="px-4 py-3 font-semibold text-slate-900">
Premium
</th>
<th className="px-4 py-3 font-semibold text-slate-900">
VIP
</th>
</tr>
</thead>
<tbody className="divide-y">
<tr>
<td className="px-4 py-3 text-slate-700">
Recommendations per month
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.recommendationsPerMonth}
</td>
<td className="px-4 py-3 text-slate-700">
Unlimited
</td>
<td className="px-4 py-3 text-slate-700">
Unlimited
</td>
</tr>
<tr>
<td className="px-4 py-3 text-slate-700">
Nutrition tracking
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.nutritionTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.premium.nutritionTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.vip.nutritionTracking
? "Yes"
: "No"}
</td>
</tr>
<tr>
<td className="px-4 py-3 text-slate-700">
Hydration tracking
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.hydrationTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.premium.hydrationTracking
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.vip.hydrationTracking
? "Yes"
: "No"}
</td>
</tr>
<tr>
<td className="px-4 py-3 text-slate-700">
Advanced statistics
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.basic.advancedStatistics
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.premium.advancedStatistics
? "Yes"
: "No"}
</td>
<td className="px-4 py-3 text-slate-700">
{MEMBERSHIP_FEATURES.vip.advancedStatistics
? "Yes"
: "No"}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-slate-50 rounded-lg">

View File

@ -1,277 +0,0 @@
import { db, eq, sql, users } from "@fitai/database";
export const DEFAULT_GEOFENCE_RADIUS_METERS = 30;
export const MAX_LOCATION_ACCURACY_METERS = 50;
export const MAX_FALLBACK_ACCURACY_MARGIN_METERS = 120;
export interface UserLocation {
latitude: number;
longitude: number;
accuracy: number;
}
export async function ensureGymsGeofenceColumns(): Promise<void> {
const rows = await db.all(sql`PRAGMA table_info('gyms')`);
const columns = new Set(
(rows as Array<{ name?: string }>).map((row) => row.name).filter(Boolean),
);
if (!columns.has("latitude")) {
await db.run(sql`ALTER TABLE gyms ADD COLUMN latitude REAL`);
}
if (!columns.has("longitude")) {
await db.run(sql`ALTER TABLE gyms ADD COLUMN longitude REAL`);
}
if (!columns.has("geofence_radius_meters")) {
await db.run(
sql`ALTER TABLE gyms ADD COLUMN geofence_radius_meters REAL NOT NULL DEFAULT 30`,
);
}
if (!columns.has("geofence_enabled")) {
await db.run(
sql`ALTER TABLE gyms ADD COLUMN geofence_enabled INTEGER NOT NULL DEFAULT 1`,
);
}
}
interface GymGeofenceConfig {
id: string;
name: string;
latitude: number | null;
longitude: number | null;
geofenceRadiusMeters: number | null;
geofenceEnabled: boolean | null;
}
export async function getUserGymGeofence(
userId: string,
): Promise<GymGeofenceConfig | null> {
await ensureGymsGeofenceColumns();
const user = await db.select().from(users).where(eq(users.id, userId)).get();
if (!user?.gymId) {
return null;
}
const rows = await db.all(sql`
SELECT
id,
name,
latitude,
longitude,
geofence_radius_meters as geofenceRadiusMeters,
geofence_enabled as geofenceEnabled
FROM gyms
WHERE id = ${user.gymId}
LIMIT 1
`);
const gym = rows?.[0] as
| {
id: string;
name: string;
latitude: number | null;
longitude: number | null;
geofenceRadiusMeters: number | null;
geofenceEnabled: number | boolean | null;
}
| undefined;
if (!gym) {
return null;
}
return {
id: gym.id,
name: gym.name,
latitude: gym.latitude,
longitude: gym.longitude,
geofenceRadiusMeters: gym.geofenceRadiusMeters,
geofenceEnabled:
typeof gym.geofenceEnabled === "boolean"
? gym.geofenceEnabled
: gym.geofenceEnabled === null
? null
: Boolean(gym.geofenceEnabled),
};
}
export function parseUserLocation(payload: unknown): UserLocation | null {
if (!payload || typeof payload !== "object") {
return null;
}
const raw = payload as Record<string, unknown>;
const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude);
const accuracy = Number(raw.accuracy);
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
!Number.isFinite(accuracy)
) {
return null;
}
return { latitude, longitude, accuracy };
}
export function validateGeofence(
gym: GymGeofenceConfig,
location: UserLocation | null,
): { ok: true } | { ok: false; status: number; error: string } {
const geofenceEnabled = gym.geofenceEnabled ?? true;
if (!geofenceEnabled) {
return { ok: true };
}
if (!location) {
return {
ok: false,
status: 400,
error: "Location is required for gym check-in/check-out",
};
}
if (location.accuracy > MAX_LOCATION_ACCURACY_METERS) {
return {
ok: false,
status: 400,
error: `Location accuracy too low (${Math.round(location.accuracy)}m). Move to an open area and try again.`,
};
}
if (gym.latitude === null || gym.longitude === null) {
return {
ok: false,
status: 400,
error: "Gym geofence is enabled but gym coordinates are not configured",
};
}
const radius = gym.geofenceRadiusMeters ?? DEFAULT_GEOFENCE_RADIUS_METERS;
const distanceMeters = haversineDistanceMeters(
gym.latitude,
gym.longitude,
location.latitude,
location.longitude,
);
if (distanceMeters > radius) {
return {
ok: false,
status: 403,
error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, allowed ${Math.round(radius)}m).`,
};
}
return { ok: true };
}
export function validateGeofenceWithFallback(
gym: GymGeofenceConfig,
location: UserLocation | null,
fallbackRequested: boolean,
): { ok: true } | { ok: false; status: number; error: string } {
const geofenceEnabled = gym.geofenceEnabled ?? true;
if (!geofenceEnabled) {
return { ok: true };
}
if (!location) {
return {
ok: false,
status: 400,
error: "Location is required for gym check-in/check-out",
};
}
if (gym.latitude === null || gym.longitude === null) {
return {
ok: false,
status: 400,
error: "Gym geofence is enabled but gym coordinates are not configured",
};
}
const radius = gym.geofenceRadiusMeters ?? DEFAULT_GEOFENCE_RADIUS_METERS;
const distanceMeters = haversineDistanceMeters(
gym.latitude,
gym.longitude,
location.latitude,
location.longitude,
);
if (location.accuracy <= MAX_LOCATION_ACCURACY_METERS) {
if (distanceMeters > radius) {
return {
ok: false,
status: 403,
error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, allowed ${Math.round(radius)}m).`,
};
}
return { ok: true };
}
if (!fallbackRequested) {
return {
ok: false,
status: 400,
error: `Location accuracy too low (${Math.round(location.accuracy)}m). Move to an open area and try again.`,
};
}
const fallbackMargin = Math.min(
location.accuracy,
MAX_FALLBACK_ACCURACY_MARGIN_METERS,
);
const fallbackAllowedDistance = radius + fallbackMargin;
if (distanceMeters > fallbackAllowedDistance) {
return {
ok: false,
status: 403,
error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, fallback allowed ${Math.round(fallbackAllowedDistance)}m).`,
};
}
return { ok: true };
}
export function validateCheckInGeofence(
gym: GymGeofenceConfig,
location: UserLocation | null,
fallbackRequested: boolean,
): { ok: true } | { ok: false; status: number; error: string } {
return validateGeofenceWithFallback(gym, location, fallbackRequested);
}
function haversineDistanceMeters(
latitude1: number,
longitude1: number,
latitude2: number,
longitude2: number,
): number {
const earthRadiusMeters = 6371000;
const dLat = toRadians(latitude2 - latitude1);
const dLng = toRadians(longitude2 - longitude1);
const lat1Rad = toRadians(latitude1);
const lat2Rad = toRadians(latitude2);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.sin(dLng / 2) *
Math.sin(dLng / 2) *
Math.cos(lat1Rad) *
Math.cos(lat2Rad);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusMeters * c;
}
function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}

View File

@ -1,26 +0,0 @@
import { getDatabase } from "@/lib/database";
import { getMembershipFeatures } from "./features";
export async function getUserMembershipContext(userId: string): Promise<{
membershipType: "basic" | "premium" | "vip";
features: ReturnType<typeof getMembershipFeatures>;
}> {
const db = await getDatabase();
const user = await db.getUserById(userId);
if (!user || user.role !== "client") {
const membershipType = "vip" as const;
return {
membershipType,
features: getMembershipFeatures(membershipType),
};
}
const client = await db.getClientByUserId(userId);
const membershipType = client?.membershipType ?? "basic";
return {
membershipType,
features: getMembershipFeatures(membershipType),
};
}

View File

@ -1,35 +0,0 @@
import type { MembershipType } from "@/lib/validation/schemas";
export interface MembershipFeatures {
recommendationsPerMonth: number;
hydrationTracking: boolean;
nutritionTracking: boolean;
advancedStatistics: boolean;
}
export const MEMBERSHIP_FEATURES: Record<MembershipType, MembershipFeatures> = {
basic: {
recommendationsPerMonth: 1,
hydrationTracking: false,
nutritionTracking: false,
advancedStatistics: false,
},
premium: {
recommendationsPerMonth: -1,
hydrationTracking: true,
nutritionTracking: true,
advancedStatistics: true,
},
vip: {
recommendationsPerMonth: -1,
hydrationTracking: true,
nutritionTracking: true,
advancedStatistics: true,
},
};
export function getMembershipFeatures(
membershipType: MembershipType,
): MembershipFeatures {
return MEMBERSHIP_FEATURES[membershipType];
}

View File

@ -34,6 +34,7 @@ export default clerkMiddleware(async (auth, req) => {
// For API routes, let the route handler check auth
// This allows API routes to handle both web sessions and mobile Bearer tokens
if (isApiRoute(req)) {
log.debug("API route, auth will be checked in handler");
return;
}

16
apps/mobile/android/.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View File

@ -0,0 +1,182 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.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
}
}

Binary file not shown.

View File

@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@ -0,0 +1,29 @@
<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>

View File

@ -0,0 +1,61 @@
package com.anonymous.fitai
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View File

@ -0,0 +1,56 @@
package com.anonymous.fitai
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<resources>
<color name="splashscreen_background">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">FitAI</string>
</resources>

View File

@ -0,0 +1,8 @@
<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>

View File

@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

View File

@ -0,0 +1,61 @@
# 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

View File

@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'FitAI'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)

View File

@ -16,14 +16,7 @@
"supportsTablet": true,
"infoPlist": {
"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.",
"NSMotionUsageDescription": "This app uses motion data to track your daily steps and activity progress.",
"NSLocationWhenInUseUsageDescription": "This app uses your location to verify you are at your gym when checking in and checking out.",
"NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses your location in the background to automatically start and end workouts when you enter or leave your gym geofence."
},
"bundleIdentifier": "com.anonymous.fitai",
"config": {
"usesNonExemptEncryption": false
"NSUserNotificationsUsageDescription": "This app uses notifications to keep you updated on your fitness progress, recommendation approvals, and important reminders."
}
},
"android": {
@ -34,11 +27,7 @@
"permissions": [
"CAMERA",
"POST_NOTIFICATIONS",
"android.permission.CAMERA",
"android.permission.ACTIVITY_RECOGNITION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_BACKGROUND_LOCATION"
"android.permission.CAMERA"
],
"package": "com.anonymous.fitai"
},
@ -49,15 +38,6 @@
"expo-router",
"expo-font",
"expo-barcode-scanner",
[
"expo-location",
{
"locationWhenInUsePermission": "Allow FitAI to use your location to verify gym check-ins and check-outs.",
"locationAlwaysAndWhenInUsePermission": "Allow FitAI to use your location in the background to automatically start and end workouts at your gym.",
"isIosBackgroundLocationEnabled": true,
"isAndroidBackgroundLocationEnabled": true
}
],
[
"expo-notifications",
{

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -1,19 +1,11 @@
try {
require("react-native-gesture-handler/jestSetup");
} catch {
// Package may be absent in minimal test environments
}
import 'react-native-gesture-handler/jestSetup'
jest.mock(
"react-native-reanimated",
() => {
const Reanimated = require("react-native-reanimated/mock");
Reanimated.default.call = () => {};
return Reanimated;
},
{ virtual: true },
);
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock')
Reanimated.default.call = () => {}
return Reanimated
})
jest.mock("@expo/vector-icons", () => ({
Ionicons: "Ionicons",
}));
jest.mock('@expo/vector-icons', () => ({
Ionicons: 'Ionicons',
}))

View File

@ -29,13 +29,10 @@
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0",
"expo-location": "~19.0.7",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
"expo-sensors": "~14.1.4",
"expo-status-bar": "^3.0.8",
"expo-task-manager": "~14.0.8",
"expo-web-browser": "^15.0.10",
"react": "19.1.0",
"react-dom": "^19.1.0",
@ -7466,15 +7463,6 @@
"react-native": "*"
}
},
"node_modules/expo-location": {
"version": "19.0.8",
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz",
"integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.22",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz",
@ -7616,19 +7604,6 @@
"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": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz",
@ -7651,19 +7626,6 @@
"react-native": "*"
}
},
"node_modules/expo-task-manager": {
"version": "14.0.9",
"resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-14.0.9.tgz",
"integrity": "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==",
"license": "MIT",
"dependencies": {
"unimodules-app-loader": "~6.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-web-browser": {
"version": "15.0.10",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
@ -13457,12 +13419,6 @@
"node": ">=4"
}
},
"node_modules/unimodules-app-loader": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz",
"integrity": "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==",
"license": "MIT"
},
"node_modules/unique-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",

View File

@ -35,12 +35,9 @@
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0",
"expo-location": "~19.0.7",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-task-manager": "~14.0.8",
"expo-secure-store": "~15.0.7",
"expo-sensors": "~14.1.4",
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.10",
"react": "19.1.0",

View File

@ -1,55 +0,0 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { gymsApi } from "../gyms";
import { apiClient, withAuth } from "../client";
jest.mock("../client", () => ({
apiClient: {
get: jest.fn(),
patch: jest.fn(),
},
withAuth: jest.fn((token?: string | null) =>
token ? { headers: { Authorization: `Bearer ${token}` } } : {},
),
}));
describe("gymsApi", () => {
const getMock = apiClient.get as any;
const patchMock = apiClient.patch as any;
beforeEach(() => {
jest.clearAllMocks();
});
it("returns array payload from getGyms", async () => {
getMock.mockResolvedValue({
data: [{ id: "gym_1", name: "Gym One" }],
});
const result = await gymsApi.getGyms("token_1");
expect(result).toEqual([{ id: "gym_1", name: "Gym One" }]);
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("returns nested data payload from getGyms", async () => {
getMock.mockResolvedValue({
data: { data: [{ id: "gym_2", name: "Gym Two" }] },
});
const result = await gymsApi.getGyms(null);
expect(result).toEqual([{ id: "gym_2", name: "Gym Two" }]);
});
it("patches selected gym for current user", async () => {
patchMock.mockResolvedValue({});
await gymsApi.updateUserGym("gym_2", "token_2");
expect(apiClient.patch).toHaveBeenCalledWith(
"/api/users/gym",
{ gymId: "gym_2" },
expect.any(Object),
);
});
});

View File

@ -1,120 +0,0 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
deleteNotification,
fetchNotifications,
fetchUnreadCount,
markAllAsRead,
markAsRead,
savePushToken,
} from "../notifications";
import { apiClient, withAuth } from "../client";
jest.mock("../client", () => ({
apiClient: {
get: jest.fn(),
put: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
},
withAuth: jest.fn((token?: string | null) =>
token ? { headers: { Authorization: `Bearer ${token}` } } : {},
),
}));
describe("notifications api", () => {
const getMock = apiClient.get as any;
const putMock = apiClient.put as any;
const postMock = apiClient.post as any;
const deleteMock = apiClient.delete as any;
beforeEach(() => {
jest.clearAllMocks();
});
it("returns notifications and normalizes createdAt", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: [
{
id: "n_1",
userId: "u_1",
title: "Hi",
message: "Welcome",
type: "system",
read: false,
createdAt: "2026-03-29T10:00:00.000Z",
},
],
},
});
const result = await fetchNotifications("token_1");
expect(result).toHaveLength(1);
expect(result[0].createdAt).toBeInstanceOf(Date);
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("returns unread count from response wrapper", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: { count: 3 },
},
});
const count = await fetchUnreadCount(null);
expect(count).toBe(3);
});
it("marks notification as read", async () => {
putMock.mockResolvedValue({
data: {
success: true,
data: {
id: "n_1",
userId: "u_1",
title: "Hi",
message: "Welcome",
type: "system",
read: true,
createdAt: "2026-03-29T10:00:00.000Z",
},
},
});
const result = await markAsRead("n_1", "token_1");
expect(result.read).toBe(true);
expect(putMock).toHaveBeenCalledWith(
"/api/notifications/n_1",
{},
expect.any(Object),
);
});
it("calls mark-all, delete and save-token endpoints", async () => {
postMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await markAllAsRead("token_1");
await deleteNotification("n_2", "token_1");
await savePushToken("expo-token", "android", "token_1");
expect(postMock).toHaveBeenCalledWith(
"/api/notifications/mark-all-read",
{},
expect.any(Object),
);
expect(deleteMock).toHaveBeenCalledWith(
"/api/notifications/n_2",
expect.any(Object),
);
expect(postMock).toHaveBeenCalledWith(
"/api/notifications/save-token",
{ expoPushToken: "expo-token", deviceType: "android" },
expect.any(Object),
);
});
});

View File

@ -1,71 +0,0 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
approveRecommendation,
generateRecommendation,
getRecommendations,
} from "../recommendations";
import { apiClient, withAuth } from "../client";
jest.mock("../client", () => ({
apiClient: {
get: jest.fn(),
post: jest.fn(),
},
withAuth: jest.fn((token?: string | null) =>
token ? { headers: { Authorization: `Bearer ${token}` } } : {},
),
}));
describe("recommendations api", () => {
const getMock = apiClient.get as any;
const postMock = apiClient.post as any;
beforeEach(() => {
jest.clearAllMocks();
});
it("returns normalized list from standardized response", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: [{ id: "rec_1", status: "pending" }],
},
});
const result = await getRecommendations("user_1", "token_1");
expect(result).toEqual([{ id: "rec_1", status: "pending" }]);
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("returns recommendation from standardized response for generate", async () => {
postMock.mockResolvedValue({
data: {
success: true,
data: { id: "rec_2", status: "pending" },
},
});
const result = await generateRecommendation({ userId: "user_1" }, null);
expect(result).toEqual({ id: "rec_2", status: "pending" });
});
it("sends approval payload without approvedBy", async () => {
postMock.mockResolvedValue({
data: {
success: true,
data: { id: "rec_3", status: "approved" },
},
});
const result = await approveRecommendation("rec_3", "token_3");
expect(result).toEqual({ id: "rec_3", status: "approved" });
expect(apiClient.post).toHaveBeenCalledWith(
"/api/recommendations/approve",
{ recommendationId: "rec_3", status: "approved" },
expect.any(Object),
);
});
});

View File

@ -1,88 +1,47 @@
import { apiClient } from "./client";
import { API_ENDPOINTS } from "../config/api";
import { isAxiosError } from "axios";
export interface Attendance {
id: string;
checkInTime: string;
checkOutTime?: string;
type: string;
notes?: string;
}
export interface AttendanceLocationPayload {
latitude: number;
longitude: number;
accuracy: number;
id: string;
checkInTime: string;
checkOutTime?: string;
type: string;
notes?: string;
}
export const attendanceApi = {
getHistory: async (token: string): Promise<Attendance[]> => {
try {
const response = await apiClient.get(API_ENDPOINTS.ATTENDANCE.HISTORY, {
headers: { Authorization: `Bearer ${token}` },
});
return response.data;
} catch (error) {
throw new Error(
getAttendanceErrorMessage(error, "Failed to load attendance history."),
);
}
},
getHistory: async (token: string): Promise<Attendance[]> => {
try {
const response = await apiClient.get(API_ENDPOINTS.ATTENDANCE.HISTORY, {
headers: { Authorization: `Bearer ${token}` },
});
return response.data;
} catch (error) {
throw error;
}
},
checkIn: async (
type: string,
token: string,
location: AttendanceLocationPayload,
fallbackRequested = false,
): Promise<void> => {
try {
await apiClient.post(
API_ENDPOINTS.ATTENDANCE.CHECK_IN,
{ type, location, fallbackRequested },
{ headers: { Authorization: `Bearer ${token}` } },
);
} catch (error) {
throw new Error(
getAttendanceErrorMessage(error, "Failed to start workout."),
);
}
},
checkIn: async (type: string, token: string): Promise<void> => {
try {
await apiClient.post(
API_ENDPOINTS.ATTENDANCE.CHECK_IN,
{ type },
{ headers: { Authorization: `Bearer ${token}` } },
);
} catch (error) {
throw error;
}
},
checkOut: async (
token: string,
location: AttendanceLocationPayload,
fallbackRequested = false,
): Promise<void> => {
try {
await apiClient.post(
API_ENDPOINTS.ATTENDANCE.CHECK_OUT,
{ location, fallbackRequested },
{ headers: { Authorization: `Bearer ${token}` } },
);
} catch (error) {
throw new Error(
getAttendanceErrorMessage(error, "Failed to end workout."),
);
}
},
checkOut: async (token: string): Promise<void> => {
try {
await apiClient.post(
API_ENDPOINTS.ATTENDANCE.CHECK_OUT,
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
} catch (error) {
throw error;
}
},
};
function getAttendanceErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const payload = error.response?.data;
if (typeof payload === "string" && payload.trim()) {
return payload;
}
if (payload && typeof payload === "object") {
const message = (payload as { error?: unknown }).error;
if (typeof message === "string" && message.trim()) {
return message;
}
}
}
return fallback;
}

View File

@ -1,52 +1,18 @@
import axios, { type AxiosRequestConfig } from "axios";
import { API_BASE_URL } from "../config/api";
import log from "../utils/logger";
import axios from 'axios';
import { API_BASE_URL } from '../config/api';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 15000,
headers: {
"Content-Type": "application/json",
},
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) {
log.warn("API unauthorized response", { url: error.config?.url });
} else if (status === 403) {
log.warn("API forbidden response", { url: error.config?.url });
} else if (status && status >= 500) {
log.error("API server error", error, {
status,
url: error.config?.url,
});
}
}
return Promise.reject(error);
},
);
export function withAuth(token?: string | null): AxiosRequestConfig {
if (!token) {
return {};
}
return {
baseURL: API_BASE_URL,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
};
}
});
// Helper to set the auth token for a request
export const setAuthToken = (token: string) => {
if (token) {
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
} else {
delete apiClient.defaults.headers.common.Authorization;
}
if (token) {
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete apiClient.defaults.headers.common['Authorization'];
}
};

View File

@ -1,35 +0,0 @@
import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api";
export interface ScannedFoodProduct {
barcode: string;
name: string;
brand: string | null;
imageUrl: string | null;
servingSize: string;
caloriesPerServing: number;
macros: {
protein: number | null;
carbs: number | null;
fat: number | null;
};
source: "openfoodfacts";
}
interface FoodLookupResponse {
success: boolean;
data: ScannedFoodProduct;
}
export async function lookupFoodByBarcode(
barcode: string,
token: string | null,
): Promise<ScannedFoodProduct> {
const normalized = barcode.replace(/\D/g, "");
const response = await apiClient.get<FoodLookupResponse>(
API_ENDPOINTS.FOOD.LOOKUP_BARCODE(normalized),
withAuth(token),
);
return response.data.data;
}

View File

@ -1,60 +0,0 @@
import { isAxiosError } from "axios";
import { API_ENDPOINTS } from "../config/api";
import { apiClient, withAuth } from "./client";
export interface Gym {
id: string;
name: string;
location?: string;
latitude?: number | null;
longitude?: number | null;
geofenceRadiusMeters?: number;
geofenceEnabled?: boolean;
}
export const gymsApi = {
getGyms: async (token: string | null): Promise<Gym[]> => {
try {
const response = await apiClient.get<Gym[] | { data?: Gym[] }>(
API_ENDPOINTS.GYMS,
withAuth(token),
);
const payload = response.data;
if (Array.isArray(payload)) {
return payload;
}
if (payload && Array.isArray(payload.data)) {
return payload.data;
}
return [];
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to fetch gyms: ${error.response.status}`);
}
throw new Error("Failed to fetch gyms");
}
},
updateUserGym: async (
gymId: string | null,
token: string | null,
): Promise<void> => {
try {
await apiClient.patch(
API_ENDPOINTS.USERS.GYM,
{ gymId },
withAuth(token),
);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to update gym selection: ${error.response.status}`,
);
}
throw new Error("Failed to update gym selection");
}
},
};

View File

@ -1,14 +0,0 @@
import { ApiError, handleResponse } from "./responses";
import { type ApiResponse } from "./types";
export function parseApiData<T>(payload: unknown): T {
if (Array.isArray(payload)) {
return payload as T;
}
if (payload && typeof payload === "object" && "success" in payload) {
return handleResponse(payload as ApiResponse<T>);
}
throw new ApiError("Invalid response format");
}

View File

@ -13,7 +13,3 @@ export * from "./recommendations";
export * from "./nutrition";
export * from "./hydration";
export * from "./client";
export * from "./helpers";
export * from "./membership";
export * from "./food";
export * from "./gyms";

View File

@ -1,128 +0,0 @@
import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api";
export type MembershipType = "basic" | "premium" | "vip";
export interface MembershipFeatures {
recommendationsPerMonth: number;
hydrationTracking: boolean;
nutritionTracking: boolean;
advancedStatistics: boolean;
}
const MEMBERSHIP_FEATURES: Record<MembershipType, MembershipFeatures> = {
basic: {
recommendationsPerMonth: 1,
hydrationTracking: false,
nutritionTracking: false,
advancedStatistics: false,
},
premium: {
recommendationsPerMonth: -1,
hydrationTracking: true,
nutritionTracking: true,
advancedStatistics: true,
},
vip: {
recommendationsPerMonth: -1,
hydrationTracking: true,
nutritionTracking: true,
advancedStatistics: true,
},
};
interface UsersListResponse {
success?: boolean;
data?: {
users?: Array<{
id: string;
role: string;
client?: {
membershipType?: MembershipType;
} | null;
}>;
};
users?: Array<{
id: string;
role: string;
client?: {
membershipType?: MembershipType;
} | null;
}>;
}
interface MembershipFeaturesResponse {
success: boolean;
data: {
membershipType: MembershipType;
currentFeatures: MembershipFeatures;
plans: Record<MembershipType, MembershipFeatures>;
};
}
function isMembershipType(value: unknown): value is MembershipType {
return value === "basic" || value === "premium" || value === "vip";
}
export async function getCurrentMembershipType(
userId: string,
token: string | null,
): Promise<MembershipType> {
if (!token || !userId) {
return "basic";
}
const response = await apiClient.get<UsersListResponse>(
API_ENDPOINTS.USERS.LIST,
withAuth(token),
);
const payload = response.data;
const users = payload.data?.users ?? payload.users ?? [];
const currentUser = users.find((user) => user.id === userId);
if (!currentUser || currentUser.role !== "client") {
return "vip";
}
const membershipType = currentUser.client?.membershipType;
return isMembershipType(membershipType) ? membershipType : "basic";
}
export function getMembershipFeatures(
membershipType: MembershipType,
): MembershipFeatures {
return MEMBERSHIP_FEATURES[membershipType];
}
export async function getCurrentMembershipFeaturesFromServer(
token: string | null,
): Promise<{
membershipType: MembershipType;
features: MembershipFeatures;
}> {
if (!token) {
return {
membershipType: "basic",
features: MEMBERSHIP_FEATURES.basic,
};
}
const response = await apiClient.get<MembershipFeaturesResponse>(
API_ENDPOINTS.MEMBERSHIP.FEATURES,
withAuth(token),
);
const data = response.data?.data;
if (!data) {
return {
membershipType: "basic",
features: MEMBERSHIP_FEATURES.basic,
};
}
return {
membershipType: data.membershipType,
features: data.currentFeatures,
};
}

View File

@ -1,6 +1,4 @@
import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client";
import { parseApiData } from "./helpers";
import { API_BASE_URL } from "../config/api";
export interface Notification {
id: string;
@ -12,46 +10,81 @@ export interface Notification {
createdAt: Date;
}
interface ApiResponse<T> {
success: boolean;
data: T;
meta?: {
timestamp: string;
count?: number;
};
}
/**
* Fetch all notifications for the authenticated user
*/
export async function fetchNotifications(
token: string | null,
): Promise<Notification[]> {
try {
const response = await apiClient.get("/api/notifications", withAuth(token));
const notifications = parseApiData<Notification[]>(response.data);
return notifications.map((notification) => ({
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/notifications`, {
method: "GET",
headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to fetch notifications: ${response.status} - ${errorText}`,
);
}
const result: ApiResponse<Notification[]> = await response.json();
if (result.success && result.data) {
// Convert date strings to Date objects
return result.data.map((notification) => ({
...notification,
createdAt: new Date(notification.createdAt),
}));
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to fetch notifications: ${error.response.status}`,
);
}
throw new Error("Failed to fetch notifications");
}
return [];
}
/**
* Get unread notification count
*/
export async function fetchUnreadCount(token: string | null): Promise<number> {
try {
const response = await apiClient.get(
"/api/notifications/unread-count",
withAuth(token),
);
const data = parseApiData<{ count: number }>(response.data);
return data.count;
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to fetch unread count: ${error.response.status}`);
}
throw new Error("Failed to fetch unread count");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/unread-count`,
{
method: "GET",
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to fetch unread count: ${response.status}`);
}
const result: ApiResponse<{ count: number }> = await response.json();
return result.success && result.data ? result.data.count : 0;
}
/**
@ -61,44 +94,62 @@ export async function markAsRead(
notificationId: string,
token: string | null,
): Promise<Notification> {
try {
const response = await apiClient.put(
`/api/notifications/${notificationId}`,
{},
withAuth(token),
);
const notification = parseApiData<Notification>(response.data);
return {
...notification,
createdAt: new Date(notification.createdAt),
};
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to mark notification as read: ${error.response.status}`,
);
}
throw new Error("Failed to mark notification as read");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/${notificationId}`,
{
method: "PUT",
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to mark notification as read: ${response.status}`);
}
const result: ApiResponse<Notification> = await response.json();
if (result.success && result.data) {
return {
...result.data,
createdAt: new Date(result.data.createdAt),
};
}
throw new Error("Invalid response format");
}
/**
* Mark all notifications as read
*/
export async function markAllAsRead(token: string | null): Promise<void> {
try {
await apiClient.post(
"/api/notifications/mark-all-read",
{},
withAuth(token),
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/mark-all-read`,
{
method: "POST",
headers,
},
);
if (!response.ok) {
throw new Error(
`Failed to mark all notifications as read: ${response.status}`,
);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to mark all notifications as read: ${error.response.status}`,
);
}
throw new Error("Failed to mark all notifications as read");
}
}
@ -109,18 +160,24 @@ export async function deleteNotification(
notificationId: string,
token: string | null,
): Promise<void> {
try {
await apiClient.delete(
`/api/notifications/${notificationId}`,
withAuth(token),
);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to delete notification: ${error.response.status}`,
);
}
throw new Error("Failed to delete notification");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/${notificationId}`,
{
method: "DELETE",
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to delete notification: ${response.status}`);
}
}
@ -132,16 +189,21 @@ export async function savePushToken(
deviceType: "ios" | "android",
token: string | null,
): Promise<void> {
try {
await apiClient.post(
"/api/notifications/save-token",
{ expoPushToken, deviceType },
withAuth(token),
);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to save push token: ${error.response.status}`);
}
throw new Error("Failed to save push token");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/notifications/save-token`, {
method: "POST",
headers,
body: JSON.stringify({ expoPushToken, deviceType }),
});
if (!response.ok) {
throw new Error(`Failed to save push token: ${response.status}`);
}
}

View File

@ -1,7 +1,4 @@
import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api";
import { parseApiData } from "./helpers";
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
export interface Recommendation {
id: string;
@ -18,16 +15,6 @@ export interface Recommendation {
updatedAt: string;
}
interface RecommendationMeta {
usedFallbackPlan?: boolean;
}
interface RecommendationApiEnvelope {
success?: boolean;
data?: Recommendation;
meta?: RecommendationMeta;
}
export interface GenerateRecommendationRequest {
userId: string;
useExternalModel?: boolean;
@ -45,20 +32,33 @@ export async function getRecommendations(
userId: string,
token: string | null,
): Promise<Recommendation[]> {
try {
const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
params: { userId },
...withAuth(token),
});
return parseApiData<Recommendation[]>(response.data);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to fetch recommendations: ${error.response.status}`,
);
}
throw error;
const headers: any = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
{ headers },
);
if (!response.ok) {
throw new Error(`Failed to fetch recommendations: ${response.status}`);
}
const result = await response.json();
// Handle standardized API response format
// API returns: { success: true, data: [...], meta: {...} }
if (result.success && result.data) {
return Array.isArray(result.data) ? result.data : [];
}
// Fallback for legacy format (direct array)
return Array.isArray(result) ? result : [];
}
/**
@ -72,21 +72,36 @@ export async function generateRecommendation(
data: GenerateRecommendationRequest,
token: string | null,
): Promise<Recommendation> {
try {
const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
data,
withAuth(token),
);
return parseApiData<Recommendation>(response.data);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to generate recommendation: ${error.response.status}`,
);
}
throw error;
const headers: any = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
{
method: "POST",
headers,
body: JSON.stringify(data),
},
);
if (!response.ok) {
throw new Error(`Failed to generate recommendation: ${response.status}`);
}
const result = await response.json();
// Handle standardized API response format
if (result.success && result.data) {
return result.data;
}
// Fallback for legacy format
return result;
}
/**
@ -94,61 +109,46 @@ export async function generateRecommendation(
*
* @param recommendationId - Recommendation ID
* @param token - Auth token
* @param approvedBy - User ID of the approver (optional)
* @returns The approved recommendation
*/
export async function approveRecommendation(
recommendationId: string,
token: string | null,
approvedBy?: string,
): Promise<Recommendation> {
try {
const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
{
const headers: any = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
{
method: "POST",
headers,
body: JSON.stringify({
recommendationId,
status: "approved",
},
withAuth(token),
);
return parseApiData<Recommendation>(response.data);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to approve recommendation: ${error.response.status}`,
);
}
throw error;
approvedBy,
}),
},
);
if (!response.ok) {
throw new Error(`Failed to approve recommendation: ${response.status}`);
}
}
/**
* 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;
const result = await response.json();
if (responseError?.error) {
throw new Error(responseError.error);
}
if (error.response) {
throw new Error(
`Failed to generate recommendation: ${error.response.status}`,
);
}
}
throw error;
// Handle standardized API response format
if (result.success && result.data) {
return result.data;
}
// Fallback for legacy format
return result;
}

View File

@ -1,28 +0,0 @@
import { API_ENDPOINTS } from "../config/api";
import { apiClient, withAuth } from "./client";
export interface UserGymGeofence {
id: string;
name: string;
location: string | null;
latitude: number | null;
longitude: number | null;
geofenceRadiusMeters: number | null;
geofenceEnabled: boolean;
status: "active" | "inactive";
}
export interface UserGymResponse {
gymId: string | null;
gym: UserGymGeofence | null;
}
export const userGymApi = {
getCurrentGym: async (token: string | null): Promise<UserGymResponse> => {
const response = await apiClient.get<UserGymResponse>(
API_ENDPOINTS.USERS.GYM,
withAuth(token),
);
return response.data;
},
};

View File

@ -12,21 +12,17 @@ import {
import { useUser, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { fitnessProfileApi } from "@/api/fitnessProfile";
import { gymsApi, type Gym } from "@/api/gyms";
import { useTheme } from "../../contexts/ThemeContext";
import { Input } from "../../components/Input";
import { MinimalButton } from "../../components/MinimalButton";
import { MinimalCard } from "../../components/MinimalCard";
import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence";
import { API_BASE_URL } from "@/config/api";
import log from "../../utils/logger";
export default function OnboardingScreen() {
const { user } = useUser();
const { getToken } = useAuth();
const router = useRouter();
const { colors, typography } = useTheme();
const [isSubmitting, setIsSubmitting] = useState(false);
const [gyms, setGyms] = useState<Gym[]>([]);
const [gyms, setGyms] = useState<
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
@ -35,8 +31,13 @@ export default function OnboardingScreen() {
try {
setGymsLoading(true);
const token = await getToken();
const data = await gymsApi.getGyms(token);
setGyms(data);
const res = await fetch(`${API_BASE_URL}/api/gyms`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
if (Array.isArray(data)) {
setGyms(data);
}
} catch (e) {
log.error("Failed to fetch gyms", e);
} finally {
@ -81,9 +82,13 @@ export default function OnboardingScreen() {
// If gym was selected or cleared, patch user's gym selection first
// selectedGymId: string gym id, or null to proceed without gym
try {
await gymsApi.updateUserGym(selectedGymId, token);
await syncAutoWorkoutGeofenceWithToken(token, {
requestPermissions: true,
await fetch(`${API_BASE_URL}/api/users/gym`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ gymId: selectedGymId }),
});
} catch (e) {
log.warn("Failed to update gym selection", { gymId: selectedGymId });
@ -143,53 +148,24 @@ export default function OnboardingScreen() {
const progress = calculateProgress();
return (
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
>
<Text
style={[typography.h2, { color: colors.textPrimary }, styles.title]}
>
Set Up Your Fitness Profile
</Text>
<Text
style={[
typography.body,
{ color: colors.textSecondary },
styles.subtitle,
]}
>
<ScrollView style={styles.container}>
<Text style={styles.title}>Set Up Your Fitness Profile</Text>
<Text style={styles.subtitle}>
Help us personalize your fitness journey
</Text>
{/* Progress indicator */}
<View style={styles.progressContainer}>
<View
style={[
styles.progressBarBackground,
{ backgroundColor: colors.borderLight },
]}
>
<View
style={[
styles.progressBarFill,
{ width: `${progress}%`, backgroundColor: colors.primary },
]}
/>
<View style={styles.progressBarBackground}>
<View style={[styles.progressBarFill, { width: `${progress}%` }]} />
</View>
<Text
style={[
typography.caption,
{ color: colors.textTertiary },
styles.progressText,
]}
>
{progress}% Complete
</Text>
<Text style={styles.progressText}>{progress}% Complete</Text>
</View>
<View style={styles.form}>
<Input
label="Height (cm)"
<Text style={styles.label}>Height (cm)</Text>
<TextInput
style={styles.input}
value={fitnessProfile.height}
onChangeText={(value) =>
setFitnessProfile({ ...fitnessProfile, height: value })
@ -198,8 +174,9 @@ export default function OnboardingScreen() {
placeholder="Enter height in cm"
/>
<Input
label="Weight (kg)"
<Text style={styles.label}>Weight (kg)</Text>
<TextInput
style={styles.input}
value={fitnessProfile.weight}
onChangeText={(value) =>
setFitnessProfile({ ...fitnessProfile, weight: value })
@ -208,8 +185,9 @@ export default function OnboardingScreen() {
placeholder="Enter weight in kg"
/>
<Input
label="Age"
<Text style={styles.label}>Age</Text>
<TextInput
style={styles.input}
value={fitnessProfile.age}
onChangeText={(value) =>
setFitnessProfile({ ...fitnessProfile, age: value })
@ -218,9 +196,9 @@ export default function OnboardingScreen() {
placeholder="Enter your age"
/>
<Input
label="Fitness Goals"
style={styles.textArea}
<Text style={styles.label}>Fitness Goals</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={fitnessProfile.goals}
onChangeText={(value) =>
setFitnessProfile({ ...fitnessProfile, goals: value })
@ -230,48 +208,33 @@ export default function OnboardingScreen() {
placeholder="What are your fitness goals?"
/>
<Text
style={[typography.h4, { color: colors.textPrimary }, styles.label]}
>
Fitness Level
</Text>
<Text style={styles.label}>Fitness Level</Text>
<View style={styles.buttonGroup}>
{["beginner", "intermediate", "advanced"].map((level) => (
<TouchableOpacity
key={level}
style={[
styles.segmentButton,
{
backgroundColor:
fitnessProfile.fitnessLevel === level
? colors.primary
: colors.surface,
borderColor: colors.border,
},
styles.levelButton,
fitnessProfile.fitnessLevel === level && styles.selectedButton,
]}
onPress={() => handleLevelSelect(level)}
>
<Text
style={[
typography.caption,
{
color:
fitnessProfile.fitnessLevel === level
? colors.white
: colors.textSecondary,
textTransform: "capitalize",
},
styles.levelButtonText,
fitnessProfile.fitnessLevel === level &&
styles.selectedButtonText,
]}
>
{level}
{level.charAt(0).toUpperCase() + level.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<Input
label="Medical Conditions"
style={styles.textArea}
<Text style={styles.label}>Medical Conditions</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={fitnessProfile.medicalConditions}
onChangeText={(value) =>
setFitnessProfile({ ...fitnessProfile, medicalConditions: value })
@ -281,9 +244,9 @@ export default function OnboardingScreen() {
placeholder="Any medical conditions we should know about?"
/>
<Input
label="Dietary Restrictions"
style={styles.textArea}
<Text style={styles.label}>Dietary Restrictions</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={fitnessProfile.dietaryRestrictions}
onChangeText={(value) =>
setFitnessProfile({
@ -296,47 +259,34 @@ export default function OnboardingScreen() {
placeholder="Any dietary restrictions?"
/>
<Text
style={[typography.h4, { color: colors.textPrimary }, styles.label]}
>
Preferred Workout Time
</Text>
<Text style={styles.label}>Preferred Workout Time</Text>
<View style={styles.buttonGroup}>
{["morning", "afternoon", "evening"].map((time) => (
<TouchableOpacity
key={time}
style={[
styles.segmentButton,
{
backgroundColor:
fitnessProfile.preferredWorkoutTime === time
? colors.primary
: colors.surface,
borderColor: colors.border,
},
styles.timeButton,
fitnessProfile.preferredWorkoutTime === time &&
styles.selectedButton,
]}
onPress={() => handleTimeSelect(time)}
>
<Text
style={[
typography.caption,
{
color:
fitnessProfile.preferredWorkoutTime === time
? colors.white
: colors.textSecondary,
textTransform: "capitalize",
},
styles.timeButtonText,
fitnessProfile.preferredWorkoutTime === time &&
styles.selectedButtonText,
]}
>
{time}
{time.charAt(0).toUpperCase() + time.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<Input
label="Workouts per Week"
<Text style={styles.label}>Workouts per Week</Text>
<TextInput
style={styles.input}
value={fitnessProfile.workoutFrequency}
onChangeText={(value) =>
setFitnessProfile({ ...fitnessProfile, workoutFrequency: value })
@ -345,11 +295,7 @@ export default function OnboardingScreen() {
placeholder="Number of workouts per week"
/>
<Text
style={[typography.h4, { color: colors.textPrimary }, styles.label]}
>
Select a Gym
</Text>
<Text style={styles.label}>Select a Gym</Text>
{gymsLoading ? (
<ActivityIndicator />
) : (
@ -361,24 +307,15 @@ export default function OnboardingScreen() {
<View style={{ flexDirection: "row" }}>
<TouchableOpacity
style={[
styles.segmentButton,
{
backgroundColor:
selectedGymId === null ? colors.primary : colors.surface,
borderColor: colors.border,
},
styles.levelButton,
selectedGymId === null && styles.selectedButton,
]}
onPress={() => setSelectedGymId(null)}
>
<Text
style={[
typography.caption,
{
color:
selectedGymId === null
? colors.white
: colors.textSecondary,
},
styles.levelButtonText,
selectedGymId === null && styles.selectedButtonText,
]}
>
Proceed without gym
@ -388,26 +325,15 @@ export default function OnboardingScreen() {
<TouchableOpacity
key={gym.id}
style={[
styles.segmentButton,
{
backgroundColor:
selectedGymId === gym.id
? colors.primary
: colors.surface,
borderColor: colors.border,
},
styles.levelButton,
selectedGymId === gym.id && styles.selectedButton,
]}
onPress={() => setSelectedGymId(gym.id)}
>
<Text
style={[
typography.caption,
{
color:
selectedGymId === gym.id
? colors.white
: colors.textSecondary,
},
styles.levelButtonText,
selectedGymId === gym.id && styles.selectedButtonText,
]}
>
{gym.name}
@ -418,14 +344,17 @@ export default function OnboardingScreen() {
</ScrollView>
)}
<MinimalButton
title="Complete Setup"
<TouchableOpacity
style={styles.submitButton}
onPress={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
fullWidth
size="lg"
/>
>
{isSubmitting ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.submitButtonText}>Complete Setup</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
);
@ -434,13 +363,18 @@ export default function OnboardingScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginTop: 40,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#666",
textAlign: "center",
marginBottom: 32,
},
@ -448,39 +382,91 @@ const styles = StyleSheet.create({
padding: 20,
},
label: {
marginBottom: 8,
fontSize: 14,
fontWeight: "600",
color: "#374151",
marginBottom: 4,
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
padding: 12,
marginBottom: 16,
backgroundColor: "white",
},
textArea: {
minHeight: 80,
height: 80,
textAlignVertical: "top",
marginBottom: 16,
},
buttonGroup: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
justifyContent: "space-between",
marginBottom: 16,
},
segmentButton: {
minWidth: 100,
levelButton: {
flex: 1,
backgroundColor: "#f3f4f6",
padding: 10,
borderRadius: 10,
borderWidth: 1,
borderRadius: 8,
marginHorizontal: 4,
alignItems: "center",
},
timeButton: {
flex: 1,
backgroundColor: "#f3f4f6",
padding: 10,
borderRadius: 8,
marginHorizontal: 4,
alignItems: "center",
},
selectedButton: {
backgroundColor: "#3b82f6",
},
levelButtonText: {
color: "#374151",
fontSize: 14,
fontWeight: "500",
},
timeButtonText: {
color: "#374151",
fontSize: 14,
fontWeight: "500",
},
selectedButtonText: {
color: "white",
},
submitButton: {
backgroundColor: "#3b82f6",
padding: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 24,
},
submitButtonText: {
color: "white",
fontSize: 16,
fontWeight: "600",
},
progressContainer: {
paddingHorizontal: 20,
marginBottom: 16,
},
progressBarBackground: {
height: 8,
backgroundColor: "#e5e7eb",
borderRadius: 4,
overflow: "hidden",
marginBottom: 8,
},
progressBarFill: {
height: "100%",
backgroundColor: "#3b82f6",
borderRadius: 4,
},
progressText: { textAlign: "center" },
progressText: {
fontSize: 12,
color: "#6b7280",
textAlign: "center",
},
});

View File

@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
@ -12,9 +13,6 @@ import {
ScrollView,
} from "react-native";
import { OAuthButtons } from "../../components/auth/OAuthButtons";
import { Input } from "../../components/Input";
import { MinimalButton } from "../../components/MinimalButton";
import { useTheme } from "../../contexts/ThemeContext";
import { getErrorMessage, getClerkErrorCode } from "../../utils/error-helpers";
import log from "../../utils/logger";
@ -27,7 +25,6 @@ export default function SignInScreen() {
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { colors, typography } = useTheme();
// Redirect if already signed in
useEffect(() => {
@ -80,15 +77,8 @@ export default function SignInScreen() {
if (isSignedIn) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={colors.primary} />
<Text
style={[
typography.body,
{ color: colors.textSecondary, marginTop: 12 },
]}
>
Redirecting...
</Text>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Redirecting...</Text>
</View>
);
}
@ -96,33 +86,19 @@ export default function SignInScreen() {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={[styles.container, { backgroundColor: colors.background }]}
style={styles.container}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Text
style={[typography.h1, { color: colors.textPrimary }, styles.title]}
>
Welcome Back
</Text>
<Text
style={[
typography.body,
{ color: colors.textSecondary },
styles.subtitle,
]}
>
Sign in to continue to FitAI
</Text>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Sign in to continue to FitAI</Text>
{error ? (
<View style={styles.errorContainer}>
<Text style={[typography.caption, { color: colors.danger }]}>
{error}
</Text>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
@ -136,11 +112,13 @@ export default function SignInScreen() {
<View style={styles.form}>
<View style={styles.inputContainer}>
<Input
label="Email"
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
autoCapitalize="none"
value={emailAddress}
placeholder="Enter your email"
placeholderTextColor="#999"
onChangeText={setEmailAddress}
keyboardType="email-address"
autoComplete="email"
@ -149,10 +127,12 @@ export default function SignInScreen() {
</View>
<View style={styles.inputContainer}>
<Input
label="Password"
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
placeholder="Enter your password"
placeholderTextColor="#999"
secureTextEntry={true}
onChangeText={setPassword}
autoComplete="password"
@ -160,29 +140,26 @@ export default function SignInScreen() {
/>
</View>
<MinimalButton
title="Sign In"
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={onSignInPress}
loading={loading}
disabled={loading || !emailAddress || !password}
fullWidth
size="lg"
/>
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={[typography.caption, { color: colors.textSecondary }]}>
Don't have an account?{" "}
</Text>
<Text style={styles.footerText}>Don't have an account? </Text>
<TouchableOpacity
onPress={() => router.push("/(auth)/sign-up")}
disabled={loading}
>
<Text
style={[typography.bodyEmphasis, { color: colors.primary }]}
>
Sign Up
</Text>
<Text style={styles.linkText}>Sign Up</Text>
</TouchableOpacity>
</View>
</View>
@ -194,11 +171,17 @@ export default function SignInScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
centerContent: {
justifyContent: "center",
alignItems: "center",
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: "#666",
},
scrollContent: {
flexGrow: 1,
justifyContent: "center",
@ -210,18 +193,27 @@ const styles = StyleSheet.create({
paddingVertical: 40,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#1a1a1a",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#666",
marginBottom: 32,
},
errorContainer: {
backgroundColor: "rgba(255, 59, 59, 0.08)",
backgroundColor: "#fee",
padding: 12,
borderRadius: 12,
borderRadius: 8,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#FF3B3B",
borderLeftColor: "#f44",
},
errorText: {
color: "#c00",
fontSize: 14,
},
form: {
marginBottom: 24,
@ -229,11 +221,51 @@ const styles = StyleSheet.create({
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
input: {
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: "#1a1a1a",
},
button: {
backgroundColor: "#2563eb",
paddingVertical: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 8,
},
buttonDisabled: {
backgroundColor: "#93c5fd",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
footer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
footerText: {
fontSize: 14,
color: "#666",
},
linkText: {
fontSize: 14,
color: "#2563eb",
fontWeight: "600",
},
dividerContainer: {
flexDirection: "row",
alignItems: "center",
@ -242,11 +274,11 @@ const styles = StyleSheet.create({
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#E5E5EA",
backgroundColor: "#ddd",
},
dividerText: {
marginHorizontal: 10,
color: "#8E8E93",
color: "#666",
fontSize: 14,
},
});

View File

@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
@ -12,9 +13,6 @@ import {
ScrollView,
} from "react-native";
import { OAuthButtons } from "../../components/auth/OAuthButtons";
import { Input } from "../../components/Input";
import { MinimalButton } from "../../components/MinimalButton";
import { useTheme } from "../../contexts/ThemeContext";
import { getErrorMessage, getClerkErrorCode } from "../../utils/error-helpers";
import log from "../../utils/logger";
@ -31,7 +29,6 @@ export default function SignUpScreen() {
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { colors, typography } = useTheme();
// Redirect if already signed in
useEffect(() => {
@ -104,15 +101,8 @@ export default function SignUpScreen() {
if (isSignedIn) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={colors.primary} />
<Text
style={[
typography.body,
{ color: colors.textSecondary, marginTop: 12 },
]}
>
Redirecting...
</Text>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Redirecting...</Text>
</View>
);
}
@ -128,53 +118,42 @@ export default function SignUpScreen() {
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Text
style={[
typography.h1,
{ color: colors.textPrimary },
styles.title,
]}
>
Verify Email
</Text>
<Text
style={[
typography.body,
{ color: colors.textSecondary },
styles.subtitle,
]}
>
<Text style={styles.title}>Verify Email</Text>
<Text style={styles.subtitle}>
Enter the verification code sent to {emailAddress}
</Text>
{error ? (
<View style={styles.errorContainer}>
<Text style={[typography.caption, { color: colors.danger }]}>
{error}
</Text>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
<View style={styles.form}>
<View style={styles.inputContainer}>
<Input
label="Verification Code"
<Text style={styles.label}>Verification Code</Text>
<TextInput
style={styles.input}
value={code}
placeholder="Enter verification code"
placeholderTextColor="#999"
onChangeText={setCode}
keyboardType="number-pad"
editable={!loading}
/>
</View>
<MinimalButton
title="Verify Email"
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={onVerifyPress}
loading={loading}
disabled={loading || !code}
fullWidth
size="lg"
/>
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Verify Email</Text>
)}
</TouchableOpacity>
</View>
</View>
</ScrollView>
@ -185,33 +164,19 @@ export default function SignUpScreen() {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={[styles.container, { backgroundColor: colors.background }]}
style={styles.container}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Text
style={[typography.h1, { color: colors.textPrimary }, styles.title]}
>
Create Account
</Text>
<Text
style={[
typography.body,
{ color: colors.textSecondary },
styles.subtitle,
]}
>
Sign up to get started with FitAI
</Text>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Sign up to get started with FitAI</Text>
{error ? (
<View style={styles.errorContainer}>
<Text style={[typography.caption, { color: colors.danger }]}>
{error}
</Text>
<Text style={styles.errorText}>{error}</Text>
</View>
) : null}
@ -225,10 +190,12 @@ export default function SignUpScreen() {
<View style={styles.form}>
<View style={styles.inputContainer}>
<Input
label="First Name"
<Text style={styles.label}>First Name</Text>
<TextInput
style={styles.input}
value={firstName}
placeholder="Enter your first name"
placeholderTextColor="#999"
onChangeText={setFirstName}
autoComplete="given-name"
editable={!loading}
@ -236,10 +203,12 @@ export default function SignUpScreen() {
</View>
<View style={styles.inputContainer}>
<Input
label="Last Name"
<Text style={styles.label}>Last Name</Text>
<TextInput
style={styles.input}
value={lastName}
placeholder="Enter your last name"
placeholderTextColor="#999"
onChangeText={setLastName}
autoComplete="family-name"
editable={!loading}
@ -247,11 +216,13 @@ export default function SignUpScreen() {
</View>
<View style={styles.inputContainer}>
<Input
label="Email"
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
autoCapitalize="none"
value={emailAddress}
placeholder="Enter your email"
placeholderTextColor="#999"
onChangeText={setEmailAddress}
keyboardType="email-address"
autoComplete="email"
@ -260,51 +231,42 @@ export default function SignUpScreen() {
</View>
<View style={styles.inputContainer}>
<Input
label="Password"
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
placeholder="Create a password"
placeholderTextColor="#999"
secureTextEntry={true}
onChangeText={setPassword}
autoComplete="password-new"
editable={!loading}
/>
<Text
style={[
typography.caption,
{ color: colors.textTertiary },
styles.hint,
]}
>
Must be at least 8 characters
</Text>
<Text style={styles.hint}>Must be at least 8 characters</Text>
</View>
<MinimalButton
title="Sign Up"
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={onSignUpPress}
loading={loading}
disabled={
loading || !emailAddress || !password || !firstName || !lastName
}
fullWidth
size="lg"
/>
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={[typography.caption, { color: colors.textSecondary }]}>
Already have an account?{" "}
</Text>
<Text style={styles.footerText}>Already have an account? </Text>
<TouchableOpacity
onPress={() => router.push("/(auth)/sign-in")}
disabled={loading}
>
<Text
style={[typography.bodyEmphasis, { color: colors.primary }]}
>
Sign In
</Text>
<Text style={styles.linkText}>Sign In</Text>
</TouchableOpacity>
</View>
</View>
@ -316,11 +278,17 @@ export default function SignUpScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
centerContent: {
justifyContent: "center",
alignItems: "center",
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: "#666",
},
scrollContent: {
flexGrow: 1,
justifyContent: "center",
@ -332,18 +300,27 @@ const styles = StyleSheet.create({
paddingVertical: 40,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#1a1a1a",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#666",
marginBottom: 32,
},
errorContainer: {
backgroundColor: "rgba(255, 59, 59, 0.08)",
backgroundColor: "#fee",
padding: 12,
borderRadius: 12,
borderRadius: 8,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#FF3B3B",
borderLeftColor: "#f44",
},
errorText: {
color: "#c00",
fontSize: 14,
},
form: {
marginBottom: 24,
@ -351,14 +328,56 @@ const styles = StyleSheet.create({
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#333",
marginBottom: 8,
},
input: {
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: "#1a1a1a",
},
hint: {
fontSize: 12,
color: "#999",
marginTop: 4,
},
button: {
backgroundColor: "#2563eb",
paddingVertical: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 8,
},
buttonDisabled: {
backgroundColor: "#93c5fd",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
footer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
footerText: {
fontSize: 14,
color: "#666",
},
linkText: {
fontSize: 14,
color: "#2563eb",
fontWeight: "600",
},
dividerContainer: {
flexDirection: "row",
alignItems: "center",
@ -367,11 +386,11 @@ const styles = StyleSheet.create({
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#E5E5EA",
backgroundColor: "#ddd",
},
dividerText: {
marginHorizontal: 10,
color: "#8E8E93",
color: "#666",
fontSize: 14,
},
});

View File

@ -80,7 +80,13 @@ export default function TabLayout() {
<Tabs.Screen
name="recommendations"
options={{
title: "Plans",
title: "AI",
}}
/>
<Tabs.Screen
name="attendance"
options={{
title: "Attendance",
}}
/>
<Tabs.Screen

View File

@ -0,0 +1,348 @@
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,
},
});

View File

@ -22,7 +22,6 @@ import { GoalProgressCard } from "../../components/GoalProgressCard";
import { GoalCreationModal } from "../../components/GoalCreationModal";
import { WeeklyProgressChart } from "../../components/WeeklyProgressChart";
import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart";
import { useMembership } from "../../hooks/useMembership";
import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals";
import { useStatistics } from "../../contexts/StatisticsContext";
import { useFitnessGoals } from "../../contexts/FitnessGoalsContext";
@ -46,24 +45,15 @@ export default function GoalsScreen() {
deleteGoal,
clearCache: clearGoalsCache,
} = useFitnessGoals();
const {
recommendations,
clearCache: clearRecommendationsCache,
refetchRecommendations,
generateSelfPlan,
} = useRecommendations();
const { membershipType, features } = useMembership();
const { clearCache: clearRecommendationsCache } = useRecommendations();
const [refreshing, setRefreshing] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [showAnalytics, setShowAnalytics] = useState(false);
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
const [showFullPlan, setShowFullPlan] = useState(false);
const loadData = useCallback(async () => {
await refetchGoals();
await refetchRecommendations();
await refetchStatistics();
}, [refetchGoals, refetchRecommendations, refetchStatistics]);
}, [refetchGoals, refetchStatistics]);
const clearClerkCache = async () => {
Alert.alert(
@ -146,76 +136,6 @@ export default function GoalsScreen() {
)
: 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 (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ScrollView
@ -432,89 +352,6 @@ export default function GoalsScreen() {
{/* Active Goals */}
<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
title={`Active Goals (${activeGoals.length})`}
subtitle="Keep pushing forward!"
@ -549,7 +386,6 @@ export default function GoalsScreen() {
<GoalProgressCard
key={goal.id}
goal={goal}
aiAligned={isGoalAiAligned(goal)}
onComplete={() => handleCompleteGoal(goal)}
onDelete={() => handleDeleteGoal(goal.id)}
/>
@ -673,33 +509,6 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
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: {
marginBottom: 20,
},

View File

@ -7,11 +7,8 @@ import {
Image,
Animated,
TouchableOpacity,
Alert,
AppState,
} from "react-native";
import * as Location from "expo-location";
import { useAuth, useUser } from "@clerk/clerk-expo";
import { useUser } from "@clerk/clerk-expo";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useFocusEffect } from "@react-navigation/native";
import AsyncStorage from "@react-native-async-storage/async-storage";
@ -27,10 +24,6 @@ import { TrackMealModal } from "../../components/TrackMealModal";
import { AddWaterModal } from "../../components/AddWaterModal";
import { ScanFoodModal } from "../../components/ScanFoodModal";
import { ActivityRing } from "../../components/ActivityRing";
import { useMembership } from "../../hooks/useMembership";
import { attendanceApi, type Attendance } from "../../api/attendance";
import { useDailySteps } from "../../hooks/useDailySteps";
import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence";
import {
checkInsToActivities,
completedGoalsToActivities,
@ -47,47 +40,10 @@ import {
const CALORIE_GOAL = 2000;
const WATER_GOAL = 2000;
const WORKOUT_GOAL = 3;
const MOTIVATION_KEY_PREFIX = "home-motivation";
const HOME_METRICS_KEY_PREFIX = "home-metrics";
const getLocalDateKey = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const getMillisecondsUntilNextMidnight = () => {
const now = new Date();
const nextMidnight = new Date(now);
nextMidnight.setHours(24, 0, 0, 0);
return Math.max(1000, nextMidnight.getTime() - now.getTime());
};
const getRandomMotivation = () => {
const messages = [
"Let's crush it today! 💪",
"Ready to level up? 🔥",
"You've got this! ⚡",
"Time to shine! ✨",
"Let's make it happen! 🚀",
];
return messages[Math.floor(Math.random() * messages.length)];
};
export default function HomeScreen() {
const { user } = useUser();
const { getToken } = useAuth();
const { colors, typography } = useTheme();
const {
steps,
goal: stepsGoal,
loading: stepsLoading,
supported: stepsSupported,
permissionGranted: stepsPermissionGranted,
} = useDailySteps();
const { features, membershipType } = useMembership();
const { refetchStatistics, forceRefresh, statistics, loading } =
useStatistics();
const { goals, refetchGoals } = useFitnessGoals();
@ -97,206 +53,17 @@ export default function HomeScreen() {
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
const [calories, setCalories] = useState(0);
const [waterIntake, setWaterIntake] = useState(0);
const [activeWorkoutSession, setActiveWorkoutSession] =
useState<Attendance | null>(null);
const [workoutActionLoading, setWorkoutActionLoading] = useState(false);
const [motivationalMessage, setMotivationalMessage] = useState(
"Let's crush it today! 💪",
);
const caloriesBounce = useRef(new Animated.Value(1)).current;
const waterBounce = useRef(new Animated.Value(1)).current;
const caloriesRef = useRef(0);
const waterRef = useRef(0);
const currentDateRef = useRef(getLocalDateKey());
const midnightResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
caloriesRef.current = calories;
}, [calories]);
useEffect(() => {
waterRef.current = waterIntake;
}, [waterIntake]);
const getMetricsStorageKey = useCallback(
() => `${HOME_METRICS_KEY_PREFIX}_${user?.id || "guest"}`,
[user?.id],
);
const persistDailyMetrics = useCallback(
async (nextCalories: number, nextWaterIntake: number, dateKey?: string) => {
if (!user?.id) return;
const targetDate = dateKey || currentDateRef.current;
await AsyncStorage.setItem(
getMetricsStorageKey(),
JSON.stringify({
date: targetDate,
calories: nextCalories,
waterIntake: nextWaterIntake,
}),
);
},
[getMetricsStorageKey, user?.id],
);
const reconcileDailyMetrics = useCallback(async () => {
const today = getLocalDateKey();
currentDateRef.current = today;
if (!user?.id) {
setCalories(0);
setWaterIntake(0);
return;
}
const stored = await AsyncStorage.getItem(getMetricsStorageKey());
if (!stored) {
setCalories(0);
setWaterIntake(0);
await persistDailyMetrics(0, 0, today);
return;
}
try {
const parsed = JSON.parse(stored) as {
date?: string;
calories?: number;
waterIntake?: number;
};
if (parsed.date === today) {
const nextCalories = Number(parsed.calories) || 0;
const nextWater = Number(parsed.waterIntake) || 0;
setCalories(nextCalories);
setWaterIntake(nextWater);
} else {
setCalories(0);
setWaterIntake(0);
await persistDailyMetrics(0, 0, today);
}
} catch {
setCalories(0);
setWaterIntake(0);
await persistDailyMetrics(0, 0, today);
}
}, [getMetricsStorageKey, persistDailyMetrics, user?.id]);
const scheduleMidnightReset = useCallback(() => {
if (midnightResetTimerRef.current) {
clearTimeout(midnightResetTimerRef.current);
}
midnightResetTimerRef.current = setTimeout(() => {
void reconcileDailyMetrics();
scheduleMidnightReset();
}, getMillisecondsUntilNextMidnight() + 50);
}, [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(
useCallback(() => {
void reconcileDailyMetrics();
void fetchActiveWorkoutSession();
refetchStatistics();
refetchGoals();
}, [
fetchActiveWorkoutSession,
reconcileDailyMetrics,
refetchStatistics,
refetchGoals,
]),
}, [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;
}
await syncAutoWorkoutGeofenceWithToken(token, {
requestPermissions: true,
});
const permission = await Location.requestForegroundPermissionsAsync();
if (permission.status !== "granted") {
Alert.alert(
"Location required",
"Location access is required to check in and check out.",
);
return;
}
const position = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
const locationPayload = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy ?? 999,
};
if (activeWorkoutSession) {
const fallbackRequested = locationPayload.accuracy > 50;
await attendanceApi.checkOut(token, locationPayload, fallbackRequested);
Alert.alert("Workout logged", "Session ended successfully.");
} else {
const fallbackRequested = locationPayload.accuracy > 50;
await attendanceApi.checkIn(
"gym",
token,
locationPayload,
fallbackRequested,
);
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 () => {
setRefreshing(true);
await Promise.all([forceRefresh(), refetchGoals()]);
@ -310,16 +77,23 @@ export default function HomeScreen() {
return "Good Evening";
};
const getMotivationalMessage = () => {
const messages = [
"Let's crush it today! 💪",
"Ready to level up? 🔥",
"You've got this! ⚡",
"Time to shine! ✨",
"Let's make it happen! 🚀",
];
return messages[Math.floor(Math.random() * messages.length)];
};
const handleSaveMeal = (meal: {
type: string;
name: string;
calories: number;
}) => {
setCalories((prev) => {
const next = prev + meal.calories;
void persistDailyMetrics(next, waterRef.current);
return next;
});
setCalories((prev) => prev + meal.calories);
setTrackMealModalVisible(false);
Animated.sequence([
Animated.timing(caloriesBounce, {
@ -336,11 +110,7 @@ export default function HomeScreen() {
};
const handleAddWater = (amount: number) => {
setWaterIntake((prev) => {
const next = prev + amount;
void persistDailyMetrics(caloriesRef.current, next);
return next;
});
setWaterIntake((prev) => prev + amount);
setAddWaterModalVisible(false);
Animated.sequence([
Animated.timing(waterBounce, {
@ -356,81 +126,59 @@ export default function HomeScreen() {
]).start();
};
const handleResetCalories = () => {
setCalories(0);
void persistDailyMetrics(0, waterRef.current);
};
const handleResetWater = () => {
setWaterIntake(0);
void persistDailyMetrics(caloriesRef.current, 0);
};
const handleResetCalories = () => setCalories(0);
const handleResetWater = () => setWaterIntake(0);
const handleAddScannedFood = (scannedCalories: number) => {
setCalories((prev) => {
const next = prev + scannedCalories;
void persistDailyMetrics(next, waterRef.current);
return next;
});
setCalories((prev) => prev + scannedCalories);
setScanFoodModalVisible(false);
};
const resetAllCounters = async () => {
setCalories(0);
setWaterIntake(0);
const today = new Date().toDateString();
await AsyncStorage.setItem("lastResetDate", today);
await AsyncStorage.removeItem(`calories_${today}`);
await AsyncStorage.removeItem(`water_${today}`);
};
useEffect(() => {
const loadDailyMotivation = async () => {
const today = new Date().toISOString().split("T")[0];
const storageKey = `${MOTIVATION_KEY_PREFIX}_${user?.id || "guest"}`;
const storedValue = await AsyncStorage.getItem(storageKey);
if (storedValue) {
try {
const parsed = JSON.parse(storedValue) as {
date: string;
message: string;
};
if (parsed.date === today && parsed.message) {
setMotivationalMessage(parsed.message);
return;
}
} catch {
// Ignore invalid local value and regenerate
}
}
const nextMessage = getRandomMotivation();
setMotivationalMessage(nextMessage);
await AsyncStorage.setItem(
storageKey,
JSON.stringify({ date: today, message: nextMessage }),
);
const loadPersistedData = async () => {
const today = new Date().toDateString();
const storedCalories = await AsyncStorage.getItem(`calories_${today}`);
const storedWater = await AsyncStorage.getItem(`water_${today}`);
if (storedCalories) setCalories(parseInt(storedCalories, 10));
if (storedWater) setWaterIntake(parseInt(storedWater, 10));
};
loadDailyMotivation();
}, [user?.id]);
loadPersistedData();
}, []);
useEffect(() => {
void reconcileDailyMetrics();
}, [reconcileDailyMetrics]);
const persistCalories = async () => {
const today = new Date().toDateString();
await AsyncStorage.setItem(`calories_${today}`, calories.toString());
};
persistCalories();
}, [calories]);
useEffect(() => {
const appStateSubscription = AppState.addEventListener(
"change",
(state) => {
if (state === "active") {
void reconcileDailyMetrics();
}
},
);
const persistWater = async () => {
const today = new Date().toDateString();
await AsyncStorage.setItem(`water_${today}`, waterIntake.toString());
};
persistWater();
}, [waterIntake]);
scheduleMidnightReset();
return () => {
appStateSubscription.remove();
if (midnightResetTimerRef.current) {
clearTimeout(midnightResetTimerRef.current);
useEffect(() => {
const checkAndResetIfNeeded = async () => {
const lastResetDate = await AsyncStorage.getItem("lastResetDate");
const today = new Date().toDateString();
if (lastResetDate !== today) {
await resetAllCounters();
}
};
}, [reconcileDailyMetrics, scheduleMidnightReset]);
checkAndResetIfNeeded();
}, []);
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
const currentStreak = statistics?.attendance.currentStreak || 0;
@ -487,7 +235,7 @@ export default function HomeScreen() {
{user?.firstName || "Champion"}
</Text>
<Text style={[typography.body, { color: colors.textSecondary }]}>
{motivationalMessage}
{getMotivationalMessage()}
</Text>
</View>
<TouchableOpacity activeOpacity={0.8}>
@ -599,57 +347,6 @@ export default function HomeScreen() {
</MinimalCard>
</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 */}
<View style={styles.section}>
<SectionHeader
@ -658,17 +355,11 @@ export default function HomeScreen() {
/>
<View style={styles.quickActionsGrid}>
<TouchableOpacity
onPress={handleWorkoutAction}
disabled={workoutActionLoading}
onPress={() => console.log("Log workout")}
activeOpacity={0.85}
style={[
styles.quickActionCard,
{
backgroundColor: activeWorkoutSession
? colors.warning
: colors.primary,
opacity: workoutActionLoading ? 0.7 : 1,
},
{ backgroundColor: colors.primary },
]}
>
<View
@ -677,16 +368,12 @@ export default function HomeScreen() {
{ backgroundColor: "rgba(255,255,255,0.2)" },
]}
>
<Ionicons
name={activeWorkoutSession ? "stop-circle" : "barbell"}
size={28}
color={colors.white}
/>
<Ionicons name="barbell" size={28} color={colors.white} />
</View>
<Text
style={[typography.h4, { color: colors.white, marginTop: 12 }]}
>
{activeWorkoutSession ? "End Workout" : "Start Workout"}
Workout
</Text>
<Text
style={[
@ -694,30 +381,12 @@ export default function HomeScreen() {
{ color: "rgba(255,255,255,0.7)", marginTop: 4 },
]}
>
{activeWorkoutSession
? `In session since ${new Date(
activeWorkoutSession.checkInTime,
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}`
: workoutActionLoading
? "Updating session..."
: "Log your session"}
Log your session
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
if (!features.nutritionTracking) {
Alert.alert(
"Premium Feature",
"Meal tracking is available on Premium and VIP plans.",
);
return;
}
setTrackMealModalVisible(true);
}}
onPress={() => setTrackMealModalVisible(true)}
activeOpacity={0.85}
style={[
styles.quickActionCard,
@ -748,16 +417,7 @@ export default function HomeScreen() {
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
if (!features.hydrationTracking) {
Alert.alert(
"Premium Feature",
"Hydration tracking is available on Premium and VIP plans.",
);
return;
}
setAddWaterModalVisible(true);
}}
onPress={() => setAddWaterModalVisible(true)}
activeOpacity={0.85}
style={[styles.quickActionCard, { backgroundColor: colors.info }]}
>
@ -819,23 +479,6 @@ export default function HomeScreen() {
{/* Today's Progress */}
<View style={styles.section}>
{!features.nutritionTracking || !features.hydrationTracking ? (
<MinimalCard
variant="bordered"
style={[styles.progressCard, { marginBottom: 12 }]}
>
<Text
style={[
typography.body,
{ color: colors.textSecondary, textAlign: "center" },
]}
>
{membershipType === "basic"
? "Upgrade to Premium or VIP to unlock nutrition and hydration tracking."
: "Some advanced tracking features are unavailable on your plan."}
</Text>
</MinimalCard>
) : null}
<SectionHeader
title="Today's Progress"
subtitle="Track your daily goals"
@ -1109,12 +752,7 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
alignItems: "center",
},
progressLabelRow: {
flexDirection: "row",
alignItems: "center",
flex: 1,
minWidth: 0,
},
progressLabelRow: { flexDirection: "row", alignItems: "center" },
progressIcon: {
width: 48,
height: 48,

View File

@ -18,10 +18,8 @@ import { ListItem } from "../../components/ListItem";
import { MinimalButton } from "../../components/MinimalButton";
import { Badge } from "../../components/Badge";
import { IconContainer } from "../../components/IconContainer";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile";
import { gymsApi, type Gym } from "../../api/gyms";
import { useMembership } from "../../hooks/useMembership";
import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence";
import log from "../../utils/logger";
export default function ProfileScreen() {
@ -30,9 +28,10 @@ export default function ProfileScreen() {
const router = useRouter();
const { colors, typography, theme: activeTheme, setTheme } = useTheme();
const { getToken } = useAuth();
const { membershipType } = useMembership();
const [gyms, setGyms] = useState<Gym[]>([]);
const [gyms, setGyms] = useState<
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
const [currentGymId, setCurrentGymId] = useState<string | null>(null);
@ -77,14 +76,51 @@ export default function ProfileScreen() {
try {
setGymsLoading(true);
const token = await getToken();
const list = await gymsApi.getGyms(token);
const url = `${API_BASE_URL}${API_ENDPOINTS.GYMS}`;
log.debug("Loading gyms", { url });
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
const text = await res.text().catch(() => "");
log.error(
"Failed to fetch gyms - non-OK response",
new Error(text.slice(0, 200)),
{ status: res.status },
);
setGyms([]);
return;
}
if (!contentType.includes("application/json")) {
const text = await res.text().catch(() => "");
log.error(
"Failed to fetch gyms - expected JSON",
new Error(text.slice(0, 200)),
{ contentType },
);
setGyms([]);
return;
}
let data: any = null;
try {
data = await res.json();
} catch (e) {
const text = await res.text().catch(() => "");
log.error("Failed to parse gyms JSON", e, {
bodyPreview: text?.slice(0, 200),
});
setGyms([]);
return;
}
const list = Array.isArray(data) ? data : [];
setGyms(list);
const gid =
currentGymId ??
((user?.publicMetadata as any)?.gymId as string | undefined) ??
null;
if (gid) {
const g = list.find((x) => x.id === gid);
const g = list.find((x: any) => x.id === gid);
setCurrentGymId(gid);
setCurrentGymName(g?.name ?? null);
if (selectedGymId === null) setSelectedGymId(gid);
@ -100,7 +136,42 @@ export default function ProfileScreen() {
const handleApplyGym = async () => {
try {
const token = await getToken();
await gymsApi.updateUserGym(selectedGymId, token);
const url = `${API_BASE_URL}${API_ENDPOINTS.USERS.GYM}`;
log.debug("Updating gym selection", {
url,
gymId: selectedGymId,
token: token ? "present" : "missing",
});
const res = await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ gymId: selectedGymId }),
});
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
const text = await res.text().catch(() => "");
log.error(
"Failed to update gym selection - non-OK response",
new Error(text.slice(0, 200)),
{ status: res.status },
);
Alert.alert("Error", "Failed to update gym selection");
return;
}
if (contentType.includes("application/json")) {
try {
const data = await res.json();
log.debug("Gym selection updated", { data });
} catch (e) {
const text = await res.text().catch(() => "");
log.error("Failed to parse update response JSON", e, {
bodyPreview: text?.slice(0, 200),
});
}
}
setCurrentGymId(selectedGymId);
setCurrentGymName(
selectedGymId
@ -116,12 +187,6 @@ export default function ProfileScreen() {
"Success",
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
);
if (token) {
await syncAutoWorkoutGeofenceWithToken(token, {
requestPermissions: true,
});
}
} catch (err) {
log.error("Failed to update gym selection", err);
Alert.alert("Error", "Failed to update gym selection");
@ -212,8 +277,8 @@ export default function ProfileScreen() {
{user?.primaryEmailAddress?.emailAddress}
</Text>
<Badge
label={`${membershipType.toUpperCase()} Member`}
variant={membershipType === "basic" ? "neutral" : "success"}
label="Premium Member"
variant="success"
style={{ marginTop: 12 }}
/>
</MinimalCard>

View File

@ -21,7 +21,6 @@ import { IconContainer } from "../../components/IconContainer";
import { useRecommendations } from "../../contexts/RecommendationsContext";
import { useNotifications } from "../../contexts/NotificationsContext";
import { NotificationsModal } from "../../components/NotificationsModal";
import { useMembership } from "../../hooks/useMembership";
import type { Recommendation } from "../../api/recommendations";
import log from "../../utils/logger";
@ -34,7 +33,6 @@ export default function RecommendationsScreen() {
refetchRecommendations,
generateNewRecommendation,
} = useRecommendations();
const { membershipType, features } = useMembership();
const { unreadCount, refetchNotifications } = useNotifications();
const [generating, setGenerating] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@ -71,17 +69,6 @@ export default function RecommendationsScreen() {
const handleGenerateRecommendation = async () => {
if (!user?.id) return;
if (
features.recommendationsPerMonth === 1 &&
allRecommendations.length >= 1
) {
Alert.alert(
"Basic Plan Limit",
"Basic plan includes 1 recommendation per month. Upgrade to Premium or VIP for unlimited recommendations.",
);
return;
}
Alert.alert(
"Generate AI Recommendation",
"Generate a personalized fitness and nutrition plan based on your profile and goals?",
@ -193,11 +180,7 @@ export default function RecommendationsScreen() {
{/* Generate Button */}
<View style={styles.section}>
<MinimalButton
title={
features.recommendationsPerMonth === 1
? "Generate Monthly Plan"
: "Generate New Plan"
}
title="Generate New Plan"
onPress={handleGenerateRecommendation}
variant="primary"
size="lg"
@ -206,20 +189,6 @@ export default function RecommendationsScreen() {
disabled={generating}
textStyle={{ fontSize: 16 }}
/>
<Text
style={[
typography.caption,
{
color: colors.textTertiary,
marginTop: 8,
textAlign: "center",
},
]}
>
{membershipType === "basic"
? `Basic plan: ${Math.max(0, 1 - allRecommendations.length)} recommendation left this month`
: `${membershipType.toUpperCase()} plan: unlimited recommendations`}
</Text>
</View>
{/* Recommendations List */}

View File

@ -4,16 +4,12 @@ import * as SecureStore from "expo-secure-store";
import { View, Text } from "react-native";
import { useEffect, useState } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryClientProvider } from "@tanstack/react-query";
import { validateEnv } from "../utils/env";
import { ThemeProvider } from "../contexts/ThemeContext";
import { StatisticsProvider } from "../contexts/StatisticsContext";
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
import { NotificationsProvider } from "../contexts/NotificationsContext";
import { MembershipProvider } from "../contexts/MembershipContext";
import { queryClient } from "../lib/query-client";
import { useAutoWorkoutGeofence } from "../hooks/useAutoWorkoutGeofence";
import log from "../utils/logger";
// Wrapper to use notification permissions hook after ClerkLoaded
@ -23,7 +19,6 @@ function AppContent() {
useNotificationPermissions,
} = require("../hooks/useNotificationPermissions");
useNotificationPermissions();
useAutoWorkoutGeofence();
return (
<Stack>
@ -177,25 +172,21 @@ export default function RootLayout() {
return (
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded>
<ThemeProvider>
<NotificationsProvider>
<StatisticsProvider>
<MembershipProvider>
<FitnessGoalsProvider>
<RecommendationsProvider>
<AppContent />
</RecommendationsProvider>
</FitnessGoalsProvider>
</MembershipProvider>
</StatisticsProvider>
</NotificationsProvider>
</ThemeProvider>
</ClerkLoaded>
</ClerkProvider>
</QueryClientProvider>
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded>
<ThemeProvider>
<NotificationsProvider>
<StatisticsProvider>
<FitnessGoalsProvider>
<RecommendationsProvider>
<AppContent />
</RecommendationsProvider>
</FitnessGoalsProvider>
</StatisticsProvider>
</NotificationsProvider>
</ThemeProvider>
</ClerkLoaded>
</ClerkProvider>
</SafeAreaProvider>
);
}

View File

@ -1,193 +1,269 @@
import React, { useState } from "react";
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
} from "react-native";
import { Stack, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useUser } from "@clerk/clerk-expo";
import { useTheme } from "../contexts/ThemeContext";
import { MinimalCard } from "../components/MinimalCard";
import { Input } from "../components/Input";
import { MinimalButton } from "../components/MinimalButton";
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
TextInput,
Alert,
Platform,
} from 'react-native';
import { useRouter, Stack } from 'expo-router';
import { useUser } from '@clerk/clerk-expo';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
export default function PersonalDetailsScreen() {
const router = useRouter();
const { user } = useUser();
const { colors, typography } = useTheme();
const [loading, setLoading] = useState(false);
const router = useRouter();
const { user } = useUser();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
firstName: user?.firstName || "",
lastName: user?.lastName || "",
email: user?.primaryEmailAddress?.emailAddress || "",
phone: user?.primaryPhoneNumber?.phoneNumber || "",
});
// Initialize with current user data
const [formData, setFormData] = useState({
firstName: user?.firstName || '',
lastName: user?.lastName || '',
email: user?.primaryEmailAddress?.emailAddress || '',
phone: user?.primaryPhoneNumber?.phoneNumber || '',
});
const updateField = (field: "firstName" | "lastName", value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setLoading(true);
try {
// Update user profile via Clerk
await user?.update({
firstName: formData.firstName,
lastName: formData.lastName,
});
const handleSave = async () => {
setLoading(true);
try {
await user?.update({
firstName: formData.firstName,
lastName: formData.lastName,
});
Alert.alert('Success', 'Personal details updated successfully', [
{ text: 'OK', onPress: () => router.back() },
]);
} catch (error) {
console.error('Error updating personal details:', error);
Alert.alert('Error', 'Failed to update personal details. Please try again.');
} finally {
setLoading(false);
}
};
Alert.alert("Success", "Personal details updated successfully", [
{ text: "OK", onPress: () => router.back() },
]);
} catch {
Alert.alert(
"Error",
"Failed to update personal details. Please try again.",
);
} finally {
setLoading(false);
}
};
const updateField = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View
style={[styles.header, { borderBottomColor: colors.borderLight }]}
>
<TouchableOpacity
style={[
styles.backButton,
{ backgroundColor: colors.surfaceElevated },
]}
onPress={() => router.back()}
activeOpacity={0.8}
>
<Ionicons name="arrow-back" size={20} color={colors.textPrimary} />
</TouchableOpacity>
<Text style={[typography.h3, { color: colors.textPrimary }]}>
Personal Details
</Text>
<View style={styles.backButtonPlaceholder} />
</View>
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.container}>
{/* Header */}
<LinearGradient
colors={theme.gradients.primary}
style={styles.header}
>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}>Personal Details</Text>
<View style={{ width: 40 }} />
</LinearGradient>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<MinimalCard variant="elevated" style={styles.card}>
<Input
label="First Name"
value={formData.firstName}
onChangeText={(value) => updateField("firstName", value)}
placeholder="Enter first name"
/>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* First Name */}
<View style={styles.field}>
<Text style={styles.label}>First Name *</Text>
<View style={styles.inputContainer}>
<Ionicons name="person-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={styles.input}
value={formData.firstName}
onChangeText={(value) => updateField('firstName', value)}
placeholder="Enter first name"
placeholderTextColor={theme.colors.gray400}
/>
</View>
</View>
<Input
label="Last Name"
value={formData.lastName}
onChangeText={(value) => updateField("lastName", value)}
placeholder="Enter last name"
/>
{/* Last Name */}
<View style={styles.field}>
<Text style={styles.label}>Last Name *</Text>
<View style={styles.inputContainer}>
<Ionicons name="person-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={styles.input}
value={formData.lastName}
onChangeText={(value) => updateField('lastName', value)}
placeholder="Enter last name"
placeholderTextColor={theme.colors.gray400}
/>
</View>
</View>
<View style={styles.readOnlyField}>
<Text style={[typography.h4, { color: colors.textPrimary }]}>
Email
</Text>
<Text style={[typography.body, { color: colors.textSecondary }]}>
{formData.email || "Not set"}
</Text>
<Text
style={[typography.caption, { color: colors.textTertiary }]}
>
Read-only in app settings
</Text>
{/* Email (Read-only) */}
<View style={styles.field}>
<Text style={styles.label}>Email</Text>
<View style={[styles.inputContainer, styles.disabledInput]}>
<Ionicons name="mail-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={[styles.input, styles.disabledText]}
value={formData.email}
editable={false}
placeholderTextColor={theme.colors.gray400}
/>
<Ionicons name="lock-closed-outline" size={16} color={theme.colors.gray400} />
</View>
<Text style={styles.helperText}>Email cannot be changed here</Text>
</View>
{/* Phone (Read-only for now) */}
<View style={styles.field}>
<Text style={styles.label}>Phone Number</Text>
<View style={[styles.inputContainer, styles.disabledInput]}>
<Ionicons name="call-outline" size={20} color={theme.colors.gray400} style={styles.inputIcon} />
<TextInput
style={[styles.input, styles.disabledText]}
value={formData.phone || 'Not set'}
editable={false}
placeholderTextColor={theme.colors.gray400}
/>
<Ionicons name="lock-closed-outline" size={16} color={theme.colors.gray400} />
</View>
<Text style={styles.helperText}>Phone number cannot be changed here</Text>
</View>
</ScrollView>
{/* Save Button */}
<View style={styles.footer}>
<TouchableOpacity
style={[styles.saveButton, loading && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={loading}
>
<LinearGradient
colors={theme.gradients.primary}
style={styles.saveButtonGradient}
>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<Text style={styles.saveButtonText}>
{loading ? 'Saving...' : 'Save Changes'}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
<View style={styles.readOnlyField}>
<Text style={[typography.h4, { color: colors.textPrimary }]}>
Phone Number
</Text>
<Text style={[typography.body, { color: colors.textSecondary }]}>
{formData.phone || "Not set"}
</Text>
<Text
style={[typography.caption, { color: colors.textTertiary }]}
>
Read-only in app settings
</Text>
</View>
</MinimalCard>
<MinimalButton
title="Save Changes"
onPress={handleSave}
loading={loading}
disabled={loading}
fullWidth
size="lg"
style={{ marginTop: 16 }}
/>
{loading ? (
<View style={styles.loadingRow}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : null}
</ScrollView>
</View>
</>
);
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingTop: 56,
paddingBottom: 12,
paddingHorizontal: 20,
borderBottomWidth: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
backButton: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
backButtonPlaceholder: {
width: 36,
height: 36,
},
content: {
flex: 1,
},
scrollContent: {
padding: 20,
paddingBottom: 80,
},
card: {
borderRadius: 16,
},
readOnlyField: {
marginTop: 8,
marginBottom: 12,
gap: 4,
},
loadingRow: {
marginTop: 16,
alignItems: "center",
},
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingBottom: 20,
paddingHorizontal: 20,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: theme.typography.fontSize['2xl'],
fontWeight: theme.typography.fontWeight.bold,
color: '#fff',
},
content: {
flex: 1,
},
scrollContent: {
padding: 20,
paddingBottom: 100,
},
field: {
marginBottom: 24,
},
label: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray700,
marginBottom: 8,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.gray200,
paddingHorizontal: 16,
...theme.shadows.subtle,
},
inputIcon: {
marginRight: 12,
},
input: {
flex: 1,
paddingVertical: 16,
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray900,
},
disabledInput: {
backgroundColor: theme.colors.gray50,
},
disabledText: {
color: theme.colors.gray500,
},
helperText: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray500,
marginTop: 6,
marginLeft: 4,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: theme.colors.gray100,
...theme.shadows.medium,
},
saveButton: {
borderRadius: theme.borderRadius.lg,
overflow: 'hidden',
},
saveButtonGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
gap: 8,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonText: {
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.bold,
color: '#fff',
},
});

View File

@ -0,0 +1,303 @@
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,
},
});

View File

@ -49,6 +49,8 @@ export function CustomTabBar({
return focused ? "home" : "home-outline";
case "goals":
return focused ? "trophy" : "trophy-outline";
case "attendance":
return focused ? "calendar" : "calendar-outline";
case "recommendations":
return focused ? "sparkles" : "sparkles-outline";
case "profile":
@ -64,6 +66,8 @@ export function CustomTabBar({
return "Home";
case "goals":
return "Goals";
case "attendance":
return "Attendance";
case "recommendations":
return "Plans";
case "profile":

View File

@ -20,7 +20,6 @@ interface GoalProgressCardProps {
onPress?: () => void;
onComplete?: () => void;
onDelete?: () => void;
aiAligned?: boolean;
}
export function GoalProgressCard({
@ -28,7 +27,6 @@ export function GoalProgressCard({
onPress,
onComplete,
onDelete,
aiAligned = false,
}: GoalProgressCardProps) {
const { colors, typography } = useTheme();
const isCompleted = goal.status === "completed";
@ -279,10 +277,6 @@ export function GoalProgressCard({
</Text>
)}
{aiAligned && !isCompleted && (
<Badge variant="info" label="AI-ALIGNED" size="sm" />
)}
{isCompleted && goal.completedDate && (
<Text style={[typography.caption, { color: colors.success }]}>
Completed {new Date(goal.completedDate).toLocaleDateString()}

View File

@ -1,12 +1,5 @@
import React from "react";
import {
View,
Text,
TextInput,
StyleSheet,
TextInputProps,
} from "react-native";
import { useTheme } from "../contexts/ThemeContext";
import { View, Text, TextInput, StyleSheet, TextInputProps } from "react-native";
interface InputProps extends TextInputProps {
label: string;
@ -14,39 +7,15 @@ interface InputProps extends TextInputProps {
}
export function Input({ label, error, style, ...props }: InputProps) {
const { colors, typography } = useTheme();
return (
<View style={styles.container}>
<Text
style={[typography.h4, { color: colors.textPrimary }, styles.label]}
>
{label}
</Text>
<Text style={styles.label}>{label}</Text>
<TextInput
style={[
styles.input,
{
backgroundColor: colors.surface,
borderColor: error ? colors.danger : colors.border,
color: colors.textPrimary,
},
style,
]}
placeholderTextColor={colors.textTertiary}
style={[styles.input, error && styles.inputError, style]}
placeholderTextColor="#9ca3af"
{...props}
/>
{error && (
<Text
style={[
typography.caption,
styles.errorText,
{ color: colors.danger },
]}
>
{error}
</Text>
)}
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
@ -56,16 +25,27 @@ const styles = StyleSheet.create({
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#374151",
marginBottom: 8,
},
input: {
backgroundColor: "white",
borderWidth: 1,
borderRadius: 12,
borderColor: "#e5e7eb",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 14,
paddingVertical: 12,
fontSize: 16,
color: "#1f2937",
},
inputError: {
borderColor: "#ef4444",
},
errorText: {
fontSize: 12,
color: "#ef4444",
marginTop: 4,
},
});

View File

@ -1,7 +1,6 @@
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import { Picker as RNPicker } from "@react-native-picker/picker";
import { useTheme } from "../contexts/ThemeContext";
interface PickerProps {
label: string;
@ -11,57 +10,23 @@ interface PickerProps {
error?: string;
}
export function Picker({
label,
value,
onValueChange,
items,
error,
}: PickerProps) {
const { colors, typography } = useTheme();
export function Picker({ label, value, onValueChange, items, error }: PickerProps) {
return (
<View style={styles.container}>
<Text
style={[typography.h4, { color: colors.textPrimary }, styles.label]}
>
{label}
</Text>
<View
style={[
styles.pickerWrapper,
{
backgroundColor: colors.surface,
borderColor: error ? colors.danger : colors.border,
},
]}
>
<Text style={styles.label}>{label}</Text>
<View style={[styles.pickerWrapper, error && styles.pickerError]}>
<RNPicker
selectedValue={value}
onValueChange={onValueChange}
style={[styles.picker, { color: colors.textPrimary }]}
style={styles.picker}
>
<RNPicker.Item label="Select..." value="" />
{items.map((item) => (
<RNPicker.Item
key={item.value}
label={item.label}
value={item.value}
/>
<RNPicker.Item key={item.value} label={item.label} value={item.value} />
))}
</RNPicker>
</View>
{error && (
<Text
style={[
typography.caption,
styles.errorText,
{ color: colors.danger },
]}
>
{error}
</Text>
)}
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
@ -71,17 +36,27 @@ const styles = StyleSheet.create({
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#374151",
marginBottom: 8,
},
pickerWrapper: {
backgroundColor: "white",
borderWidth: 1,
borderRadius: 12,
borderColor: "#e5e7eb",
borderRadius: 8,
overflow: "hidden",
},
pickerError: {
borderColor: "#ef4444",
},
picker: {
height: 50,
},
errorText: {
fontSize: 12,
color: "#ef4444",
marginTop: 4,
},
});

File diff suppressed because it is too large Load Diff

View File

@ -44,12 +44,6 @@ export const API_ENDPOINTS = {
HISTORY: "/api/attendance/history",
},
RECOMMENDATIONS: "/api/recommendations",
MEMBERSHIP: {
FEATURES: "/api/membership/features",
},
FOOD: {
LOOKUP_BARCODE: (code: string) => `/api/food/barcode/${code}`,
},
NUTRITION: {
BASE: "/api/nutrition",
MEALS: "/api/nutrition/meals",

View File

@ -3,7 +3,6 @@ import React, {
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import { useUser, useAuth } from "@clerk/clerk-expo";
@ -169,22 +168,12 @@ export function FitnessGoalsProvider({
const clearCache = useCallback(() => {
setGoals([]);
setLoading(false);
setLastFetchTime(0);
setError(null);
fetchInProgress.current = false;
log.debug("Fitness goals cache cleared");
}, []);
useEffect(() => {
clearCache();
if (user?.id) {
log.debug("Fitness goals cache reset for user", { userId: user.id });
} else {
log.debug("Fitness goals cache reset on sign-out");
}
}, [user?.id, clearCache]);
return (
<FitnessGoalsContext.Provider
value={{

View File

@ -74,18 +74,6 @@ export function HydrationProvider({ children }: { children: React.ReactNode }) {
fetchTodayHydration();
}, [fetchTodayHydration]);
useEffect(() => {
setHydration(null);
setError(null);
setLoading(false);
setWaterGoal(2000);
if (user?.id) {
log.debug("Hydration state reset for user", { userId: user.id });
} else {
log.debug("Hydration state reset on sign-out");
}
}, [user?.id]);
const addWater = useCallback(
async (amount: number) => {
if (!user?.id) return;

View File

@ -1,96 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useAuth, useUser } from "@clerk/clerk-expo";
import {
getCurrentMembershipFeaturesFromServer,
type MembershipFeatures,
type MembershipType,
} from "../api/membership";
import log from "../utils/logger";
const BASIC_FEATURES: MembershipFeatures = {
recommendationsPerMonth: 1,
hydrationTracking: false,
nutritionTracking: false,
advancedStatistics: false,
};
interface MembershipContextValue {
membershipType: MembershipType;
features: MembershipFeatures;
loading: boolean;
refreshMembership: () => Promise<void>;
}
const MembershipContext = createContext<MembershipContextValue | undefined>(
undefined,
);
export function MembershipProvider({ children }: { children: ReactNode }) {
const { user } = useUser();
const { getToken, isSignedIn } = useAuth();
const [membershipType, setMembershipType] = useState<MembershipType>("basic");
const [features, setFeatures] = useState<MembershipFeatures>(BASIC_FEATURES);
const [loading, setLoading] = useState(true);
const loadMembership = useCallback(async () => {
if (!isSignedIn || !user?.id) {
setMembershipType("basic");
setFeatures(BASIC_FEATURES);
setLoading(false);
return;
}
try {
setLoading(true);
const token = await getToken();
const result = await getCurrentMembershipFeaturesFromServer(token);
setMembershipType(result.membershipType);
setFeatures(result.features);
} catch (error) {
log.error("Failed to load membership", error, { userId: user.id });
setMembershipType("basic");
setFeatures(BASIC_FEATURES);
} finally {
setLoading(false);
}
}, [isSignedIn, user?.id]);
useEffect(() => {
loadMembership();
}, [loadMembership]);
const value = useMemo(
() => ({
membershipType,
features,
loading,
refreshMembership: loadMembership,
}),
[membershipType, features, loading, loadMembership],
);
return (
<MembershipContext.Provider value={value}>
{children}
</MembershipContext.Provider>
);
}
export function useMembershipContext(): MembershipContextValue {
const context = useContext(MembershipContext);
if (!context) {
throw new Error(
"useMembershipContext must be used within MembershipProvider",
);
}
return context;
}

View File

@ -6,7 +6,7 @@ import React, {
useCallback,
useRef,
} from "react";
import { useAuth, useUser } from "@clerk/clerk-expo";
import { useAuth } from "@clerk/clerk-expo";
import {
fetchNotifications,
fetchUnreadCount,
@ -37,7 +37,6 @@ export function NotificationsProvider({
children: React.ReactNode;
}) {
const { getToken, isSignedIn } = useAuth();
const { user } = useUser();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(false);
@ -161,19 +160,6 @@ export function NotificationsProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]); // Only run when sign-in state changes
useEffect(() => {
setNotifications([]);
setUnreadCount(0);
setLoading(false);
fetchInProgressRef.current = false;
lastFetchTimeRef.current = 0;
if (user?.id) {
log.debug("Notifications state reset for user", { userId: user.id });
} else {
log.debug("Notifications state reset on sign-out");
}
}, [user?.id]);
// Periodic refresh every 30 seconds
useEffect(() => {
if (!isSignedIn) return;

View File

@ -88,19 +88,6 @@ export function NutritionProvider({ children }: { children: React.ReactNode }) {
fetchTodayNutrition();
}, [fetchTodayNutrition]);
useEffect(() => {
setNutrition(null);
setMeals([]);
setError(null);
setLoading(false);
setCalorieGoal(2000);
if (user?.id) {
log.debug("Nutrition state reset for user", { userId: user.id });
} else {
log.debug("Nutrition state reset on sign-out");
}
}, [user?.id]);
const addMeal = useCallback(
async (data: Omit<MealEntry, "id" | "createdAt" | "dailyNutritionId">) => {
if (!user?.id) return;

View File

@ -3,14 +3,12 @@ import React, {
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import { useUser, useAuth } from "@clerk/clerk-expo";
import {
getRecommendations,
generateRecommendation,
generateSelfRecommendation,
type Recommendation,
type GenerateRecommendationRequest,
} from "../api/recommendations";
@ -24,7 +22,6 @@ interface RecommendationsContextValue {
generateNewRecommendation: (
data: GenerateRecommendationRequest,
) => Promise<Recommendation>;
generateSelfPlan: () => Promise<Recommendation>;
clearCache: () => void;
}
@ -109,36 +106,14 @@ export function RecommendationsProvider({
[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(() => {
setRecommendations([]);
setLoading(false);
setLastFetchTime(0);
setError(null);
fetchInProgress.current = false;
log.debug("Recommendations cache cleared");
}, []);
useEffect(() => {
clearCache();
if (user?.id) {
log.debug("Recommendations cache reset for user", { userId: user.id });
} else {
log.debug("Recommendations cache reset on sign-out");
}
}, [user?.id, clearCache]);
return (
<RecommendationsContext.Provider
value={{
@ -147,7 +122,6 @@ export function RecommendationsProvider({
error,
refetchRecommendations,
generateNewRecommendation,
generateSelfPlan,
clearCache,
}}
>

View File

@ -1,11 +1,4 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import React, { createContext, useContext, useState, useCallback } from "react";
import { useUser, useAuth } from "@clerk/clerk-expo";
import { getUserStatistics } from "../api/statistics";
import type { UserStatisticsResponse } from "../api/types";
@ -37,9 +30,6 @@ export function StatisticsProvider({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [lastFetchTime, setLastFetchTime] = useState<number>(0);
const statisticsRef = useRef<UserStatisticsResponse | null>(null);
const lastFetchTimeRef = useRef<number>(0);
const fetchInProgressRef = useRef(false);
// Cache statistics for 30 seconds to avoid duplicate calls
const CACHE_DURATION = 30000; // 30 seconds
@ -47,83 +37,75 @@ export function StatisticsProvider({
const refetchStatistics = useCallback(async () => {
if (!user?.id) return;
if (fetchInProgressRef.current) {
return;
}
// Check if we have recent cached data
const now = Date.now();
if (
statisticsRef.current &&
now - lastFetchTimeRef.current < CACHE_DURATION
) {
if (statistics && now - lastFetchTime < CACHE_DURATION) {
log.debug("Using cached statistics", {
age: now - lastFetchTime,
cacheRemaining: CACHE_DURATION - (now - lastFetchTime),
});
return;
}
try {
fetchInProgressRef.current = true;
setLoading(true);
setError(null);
log.debug("Fetching fresh statistics", { userId: user.id });
const token = await getToken();
const stats = await getUserStatistics(user.id, token);
setStatistics(stats);
statisticsRef.current = stats;
setLastFetchTime(now);
lastFetchTimeRef.current = now;
log.debug("Statistics fetched and cached", {
userId: user.id,
hasWeeklyTrend: !!stats.weeklyTrend,
weeklyTrendLength: stats.weeklyTrend?.length || 0,
weeklyTrendSample: stats.weeklyTrend?.[0],
stats,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
log.error("Failed to fetch statistics", error);
setError(error);
} finally {
setLoading(false);
fetchInProgressRef.current = false;
}
}, [user?.id, getToken]);
}, [user?.id, getToken, statistics, lastFetchTime]);
const clearCache = useCallback(() => {
setStatistics(null);
statisticsRef.current = null;
setLoading(false);
setLastFetchTime(0);
lastFetchTimeRef.current = 0;
setError(null);
fetchInProgressRef.current = false;
log.debug("Statistics cache cleared");
}, []);
useEffect(() => {
clearCache();
if (user?.id) {
log.debug("Statistics cache reset for user", { userId: user.id });
} else {
log.debug("Statistics cache reset on sign-out");
}
}, [user?.id, clearCache]);
const forceRefresh = useCallback(async () => {
if (!user?.id) return;
try {
fetchInProgressRef.current = true;
setLoading(true);
setError(null);
log.debug("Force fetching statistics", { userId: user.id });
const token = await getToken();
const stats = await getUserStatistics(user.id, token);
setStatistics(stats);
statisticsRef.current = stats;
const now = Date.now();
setLastFetchTime(now);
lastFetchTimeRef.current = now;
setLastFetchTime(Date.now());
log.debug("Statistics force fetched and cached", {
userId: user.id,
hasWeeklyTrend: !!stats.weeklyTrend,
weeklyTrendLength: stats.weeklyTrend?.length || 0,
weeklyTrendSample: stats.weeklyTrend?.[0],
stats,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
log.error("Failed to force fetch statistics", error);
setError(error);
} finally {
setLoading(false);
fetchInProgressRef.current = false;
}
}, [user?.id, getToken]);

View File

@ -1,68 +0,0 @@
import { useAuth } from "@clerk/clerk-expo";
import { useCallback, useEffect } from "react";
import { AppState } from "react-native";
import {
disableAutoWorkoutGeofence,
syncAutoWorkoutGeofenceWithToken,
} from "../services/autoWorkoutGeofence";
import log from "../utils/logger";
export function useAutoWorkoutGeofence() {
const { isSignedIn, getToken } = useAuth();
const syncGeofence = useCallback(async () => {
try {
if (!isSignedIn) {
await disableAutoWorkoutGeofence();
return;
}
const token = await getToken();
if (!token) {
await disableAutoWorkoutGeofence();
return;
}
await syncAutoWorkoutGeofenceWithToken(token, {
requestPermissions: true,
});
} catch (error) {
log.warn("Failed to sync auto workout geofence", {
error: error instanceof Error ? error.message : String(error),
});
}
}, [getToken, isSignedIn]);
useEffect(() => {
void syncGeofence();
}, [syncGeofence]);
useEffect(() => {
const appStateSubscription = AppState.addEventListener(
"change",
(state) => {
if (state === "active") {
void syncGeofence();
}
},
);
const interval = setInterval(
() => {
void syncGeofence();
},
5 * 60 * 1000,
);
return () => {
appStateSubscription.remove();
clearInterval(interval);
};
}, [syncGeofence]);
useEffect(() => {
if (!isSignedIn) {
void disableAutoWorkoutGeofence();
}
}, [isSignedIn]);
}

View File

@ -1,136 +0,0 @@
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,
};
}

View File

@ -1,5 +0,0 @@
import { useMembershipContext } from "../contexts/MembershipContext";
export function useMembership() {
return useMembershipContext();
}

View File

@ -1,19 +0,0 @@
import * as SecureStore from "expo-secure-store";
const BACKGROUND_AUTH_TOKEN_KEY = "fitai_background_auth_token";
export async function saveBackgroundAuthToken(token: string): Promise<void> {
if (!looksLikeJwt(token)) {
return;
}
await SecureStore.setItemAsync(BACKGROUND_AUTH_TOKEN_KEY, token);
}
export async function getBackgroundAuthToken(): Promise<string | null> {
return SecureStore.getItemAsync(BACKGROUND_AUTH_TOKEN_KEY);
}
function looksLikeJwt(token: string): boolean {
return token.split(".").length === 3;
}

Some files were not shown because too many files have changed in this diff Show More