Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# access to the values within the .ini file in use.
config = context.config

config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL.replace("%", "%%"))

# Interpret the config file for Python logging.
# This line sets up loggers basically.
Expand Down
38 changes: 38 additions & 0 deletions backend/alembic/versions/d4e5f6a7b8c9_criar_tabela_user_workout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""criar tabela user_workout

Revision ID: d4e5f6a7b8c9
Revises: b2c3d4e5f6a7
Create Date: 2026-06-29 12:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, Sequence[str], None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"userworkout",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_userworkout_user_id"), "userworkout", ["user_id"], unique=False
)


def downgrade() -> None:
op.drop_index(op.f("ix_userworkout_user_id"), table_name="userworkout")
op.drop_table("userworkout")
21 changes: 19 additions & 2 deletions backend/controllers/workout_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

from core.database import get_session
from domain.user import UserNotFoundError
from domain.user_workout import WorkoutHistoryItem
from domain.workout_plan import WorkoutPlan, WorkoutRequest
from repositories.user_repository import UserRepository
from repositories.user_workout_repository import UserWorkoutRepository
from repositories.workout_repository import WorkoutRepository
from services.llm_facade import LLMFacade
from services.rag_engine import RAGEngine
Expand All @@ -16,10 +18,25 @@

def get_workout_service(session: Session = Depends(get_session)) -> WorkoutService:
user_repository = UserRepository(session)
user_workout_repository = UserWorkoutRepository(session)
rag_engine = RAGEngine(WorkoutRepository(session))
llm_facade = LLMFacade(rag_engine)

return WorkoutService(user_repository, llm_facade)

return WorkoutService(user_repository, user_workout_repository, llm_facade)


@router.get("/history", response_model=list[WorkoutHistoryItem])
def get_workout_history(
user_id: int,
service: WorkoutService = Depends(get_workout_service),
) -> list[WorkoutHistoryItem]:
try:
return service.list_history(user_id)
except UserNotFoundError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(error),
)


@router.post("/generate", response_model=WorkoutPlan, status_code=status.HTTP_200_OK)
Expand Down
3 changes: 3 additions & 0 deletions backend/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .embedding import EmbeddingDocument
from .exercise import Exercise
from .user import User, UserAlreadyExistsError, UserCreate, UserRead
from .user_workout import UserWorkout, WorkoutHistoryItem
from .workout import DifficultyLevel, Workout
from .workout_exercise import WorkoutExercise

Expand All @@ -16,6 +17,8 @@
"UserAlreadyExistsError",
"UserCreate",
"UserRead",
"UserWorkout",
"WorkoutHistoryItem",
"Workout",
"WorkoutExercise",
]
17 changes: 17 additions & 0 deletions backend/domain/user_workout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from datetime import datetime
from typing import Optional

from sqlmodel import Field, SQLModel


class UserWorkout(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
title: str
created_at: datetime = Field(default_factory=datetime.utcnow)


class WorkoutHistoryItem(SQLModel):
id: int
title: str
created_at: datetime
22 changes: 22 additions & 0 deletions backend/repositories/user_workout_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from sqlmodel import Session, select

from domain.user_workout import UserWorkout


class UserWorkoutRepository:
def __init__(self, session: Session) -> None:
self.session = session

def save(self, user_workout: UserWorkout) -> UserWorkout:
self.session.add(user_workout)
self.session.commit()
self.session.refresh(user_workout)
return user_workout

def list_by_user(self, user_id: int) -> list[UserWorkout]:
statement = (
select(UserWorkout)
.where(UserWorkout.user_id == user_id)
.order_by(UserWorkout.created_at.desc())
)
return list(self.session.exec(statement).all())
26 changes: 24 additions & 2 deletions backend/services/workout_service.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
from domain.user import UserNotFoundError
from domain.user_workout import UserWorkout, WorkoutHistoryItem
from domain.workout_plan import WorkoutPlan
from repositories.user_repository import UserRepository
from repositories.user_workout_repository import UserWorkoutRepository
from services.llm_facade import LLMFacade


class WorkoutService:
def __init__(self, user_repository: UserRepository, llm_facade: LLMFacade) -> None:
def __init__(
self,
user_repository: UserRepository,
user_workout_repository: UserWorkoutRepository,
llm_facade: LLMFacade,
) -> None:
self._user_repo = user_repository
self._user_workout_repo = user_workout_repository
self._facade = llm_facade

def generate(self, user_id: int, query: str) -> WorkoutPlan:
user = self._user_repo.get_by_id(user_id)
if not user:
raise UserNotFoundError()
return self._facade.generate_workout_plan(user_query=query, user=user)

plan = self._facade.generate_workout_plan(user_query=query, user=user)
self._user_workout_repo.save(UserWorkout(user_id=user_id, title=plan.title))
return plan

def list_history(self, user_id: int) -> list[WorkoutHistoryItem]:
user = self._user_repo.get_by_id(user_id)
if not user:
raise UserNotFoundError()

workouts = self._user_workout_repo.list_by_user(user_id)
return [
WorkoutHistoryItem(id=w.id, title=w.title, created_at=w.created_at)
for w in workouts
]
3 changes: 2 additions & 1 deletion front/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function RootLayout() {
useEffect(() => {
if (!fontsLoaded) return
getToken()
.then((token) => router.replace(token ? '/profile' : '/login'))
.then((token) => router.replace(token ? '/' : '/login'))
.finally(() => SplashScreen.hideAsync())
}, [fontsLoaded, router])

Expand All @@ -27,6 +27,7 @@ export default function RootLayout() {
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="profile" />
<Stack.Screen name="workout/[id]" />
</Stack>
</>
)
Expand Down
79 changes: 43 additions & 36 deletions front/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from "react";
import React, { useEffect, useState } from "react";
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useRouter } from "expo-router";

import WorkoutHistoryList from "@/components/WorkoutHistoryList";
import {
TourProvider,
useTour,
Expand All @@ -18,6 +20,8 @@ import {
resetTourSeen,
useTourAutoStart,
} from "@/components/tour/useTourAutoStart";
import { getWorkoutHistory } from "@/services/api";
import { getUserId, getUserName } from "@/services/session";

export default function DashboardScreen() {
return (
Expand All @@ -29,6 +33,7 @@ export default function DashboardScreen() {

function DashboardContent() {
useTourAutoStart(600);
const router = useRouter();

const refGen = useTourRef("gen-workout");
const refWorkouts = useTourRef("nav-workouts");
Expand All @@ -39,9 +44,39 @@ function DashboardContent() {

const { startTour } = useTour();

const [userName, setUserName] = useState("");
const [workouts, setWorkouts] = useState<{ id: number; title: string }[]>([]);
const [historyLoading, setHistoryLoading] = useState(true);

useEffect(() => {
async function loadHistory() {
const userId = await getUserId();
const name = await getUserName();
if (name) setUserName(name);

if (!userId) {
setHistoryLoading(false);
return;
}

try {
const data = await getWorkoutHistory(Number(userId));
setWorkouts(data.map((item) => ({ id: item.id, title: item.title })));
} catch {
setWorkouts([]);
} finally {
setHistoryLoading(false);
}
}

loadHistory();
}, []);

return (
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
<Text style={styles.greeting}>Olá, João 👋</Text>
<Text style={styles.greeting}>
Olá{userName ? `, ${userName.split(" ")[0]}` : ""} 👋
</Text>
<Text style={styles.subtitle}>Pronto para treinar hoje?</Text>

<View ref={refGen} style={styles.card}>
Expand Down Expand Up @@ -74,10 +109,12 @@ function DashboardContent() {
<StatCard value="Interm." label="Seu nível" />
</View>

<View ref={refHistory} style={styles.card}>
<Text style={styles.cardTitle}>Últimos treinos</Text>
<HistoryItem icon="🏃" name="Treino HIIT – Segunda" />
<HistoryItem icon="🏋️" name="Força membros – Quarta" />
<View ref={refHistory}>
<WorkoutHistoryList
workouts={workouts}
loading={historyLoading}
onItemPress={(id) => router.push(`/workout/${id}`)}
/>
</View>

<Pressable
Expand All @@ -102,18 +139,6 @@ function StatCard({ value, label }: { value: string; label: string }) {
);
}

function HistoryItem({ icon, name }: { icon: string; name: string }) {
return (
<View style={styles.historyItem}>
<Text style={styles.historyIcon}>{icon}</Text>
<Text style={styles.historyName}>{name}</Text>
<View style={styles.badge}>
<Text style={styles.badgeText}>Concluído</Text>
</View>
</View>
);
}

const GREEN = "#1D9E75";

const styles = StyleSheet.create({
Expand Down Expand Up @@ -175,24 +200,6 @@ const styles = StyleSheet.create({
statValue: { fontSize: 18, fontWeight: "600", color: "#111" },
statLabel: { fontSize: 10, color: "#888", marginTop: 2, textAlign: "center" },

historyItem: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 6,
borderTopWidth: 0.5,
borderColor: "rgba(0,0,0,0.06)",
},
historyIcon: { fontSize: 14 },
historyName: { flex: 1, fontSize: 13, color: "#444" },
badge: {
backgroundColor: "#E1F5EE",
borderRadius: 20,
paddingHorizontal: 8,
paddingVertical: 3,
},
badgeText: { fontSize: 10, color: GREEN, fontWeight: "600" },

btnOutline: {
borderWidth: 0.5,
borderColor: "rgba(0,0,0,0.15)",
Expand Down
8 changes: 5 additions & 3 deletions front/src/app/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import AuthLayout from '@/components/AuthLayout'
import Button from '@/components/Button'
import Input from '@/components/Input'
import { loginUser } from '@/services/api'
import { setToken } from '@/services/session'
import { setToken, setUserId, setUserName } from '@/services/session'
import { colors } from '@/styles/theme'

export default function LoginScreen() {
Expand All @@ -29,8 +29,10 @@ export default function LoginScreen() {
setLoading(true)
try {
const data = await loginUser(form)
await setToken(data.access_token)
router.replace('/profile')
await setUserId(String(data.user.id))
await setUserName(data.user.name || '')
await setToken('active')
router.replace('/')
} catch (err: any) {
setError(err.message || 'Email ou senha inválidos.')
} finally {
Expand Down
29 changes: 29 additions & 0 deletions front/src/app/workout/[id].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { useLocalSearchParams } from "expo-router";

export default function WorkoutDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();

return (
<View style={styles.screen}>
<Text style={styles.title}>Detalhe do treino</Text>
<Text style={styles.subtitle}>Treino #{id}</Text>
<Text style={styles.placeholder}>
A exibição completa deste treino será implementada em breve.
</Text>
</View>
);
}

const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: "#f9f9f7",
padding: 20,
gap: 8,
},
title: { fontSize: 22, fontWeight: "600", color: "#111" },
subtitle: { fontSize: 14, color: "#888" },
placeholder: { fontSize: 13, color: "#666", marginTop: 8 },
});
Loading