From b5d8680d0235c9cf5a1b43d6b9d2a0ca39fbd129 Mon Sep 17 00:00:00 2001 From: alannunes73 Date: Mon, 29 Jun 2026 14:09:06 -0300 Subject: [PATCH] =?UTF-8?q?Implementa=C3=A7=C3=A3o=20da=20visualiza=C3=A7?= =?UTF-8?q?=C3=A3o=20-=20historico=20de=20treinos;=20closes=20#69?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/alembic/env.py | 2 +- .../d4e5f6a7b8c9_criar_tabela_user_workout.py | 38 +++++++ backend/controllers/workout_controller.py | 21 +++- backend/domain/__init__.py | 3 + backend/domain/user_workout.py | 17 ++++ .../repositories/user_workout_repository.py | 22 +++++ backend/services/workout_service.py | 26 ++++- front/src/app/_layout.tsx | 3 +- front/src/app/index.tsx | 79 ++++++++------- front/src/app/login.tsx | 8 +- front/src/app/workout/[id].tsx | 29 ++++++ front/src/components/WorkoutHistoryList.tsx | 98 +++++++++++++++++++ front/src/services/api.ts | 10 ++ front/src/services/session.ts | 38 +++++++ 14 files changed, 349 insertions(+), 45 deletions(-) create mode 100644 backend/alembic/versions/d4e5f6a7b8c9_criar_tabela_user_workout.py create mode 100644 backend/domain/user_workout.py create mode 100644 backend/repositories/user_workout_repository.py create mode 100644 front/src/app/workout/[id].tsx create mode 100644 front/src/components/WorkoutHistoryList.tsx diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 64fac92..752b203 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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. diff --git a/backend/alembic/versions/d4e5f6a7b8c9_criar_tabela_user_workout.py b/backend/alembic/versions/d4e5f6a7b8c9_criar_tabela_user_workout.py new file mode 100644 index 0000000..c024762 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_criar_tabela_user_workout.py @@ -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") diff --git a/backend/controllers/workout_controller.py b/backend/controllers/workout_controller.py index a11830f..f7fce74 100644 --- a/backend/controllers/workout_controller.py +++ b/backend/controllers/workout_controller.py @@ -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 @@ -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) diff --git a/backend/domain/__init__.py b/backend/domain/__init__.py index 54a75a9..626b820 100644 --- a/backend/domain/__init__.py +++ b/backend/domain/__init__.py @@ -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 @@ -16,6 +17,8 @@ "UserAlreadyExistsError", "UserCreate", "UserRead", + "UserWorkout", + "WorkoutHistoryItem", "Workout", "WorkoutExercise", ] diff --git a/backend/domain/user_workout.py b/backend/domain/user_workout.py new file mode 100644 index 0000000..de15e36 --- /dev/null +++ b/backend/domain/user_workout.py @@ -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 diff --git a/backend/repositories/user_workout_repository.py b/backend/repositories/user_workout_repository.py new file mode 100644 index 0000000..2af440f --- /dev/null +++ b/backend/repositories/user_workout_repository.py @@ -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()) diff --git a/backend/services/workout_service.py b/backend/services/workout_service.py index bbf688c..69b18d0 100644 --- a/backend/services/workout_service.py +++ b/backend/services/workout_service.py @@ -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 + ] diff --git a/front/src/app/_layout.tsx b/front/src/app/_layout.tsx index 0a43247..6ab3356 100644 --- a/front/src/app/_layout.tsx +++ b/front/src/app/_layout.tsx @@ -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]) @@ -27,6 +27,7 @@ export default function RootLayout() { + ) diff --git a/front/src/app/index.tsx b/front/src/app/index.tsx index 57ef0d7..4f06413 100644 --- a/front/src/app/index.tsx +++ b/front/src/app/index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Pressable, ScrollView, @@ -6,7 +6,9 @@ import { Text, View, } from "react-native"; +import { useRouter } from "expo-router"; +import WorkoutHistoryList from "@/components/WorkoutHistoryList"; import { TourProvider, useTour, @@ -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 ( @@ -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"); @@ -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 ( - Olá, João 👋 + + Olá{userName ? `, ${userName.split(" ")[0]}` : ""} 👋 + Pronto para treinar hoje? @@ -74,10 +109,12 @@ function DashboardContent() { - - Últimos treinos - - + + router.push(`/workout/${id}`)} + /> - {icon} - {name} - - Concluído - - - ); -} - const GREEN = "#1D9E75"; const styles = StyleSheet.create({ @@ -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)", diff --git a/front/src/app/login.tsx b/front/src/app/login.tsx index 0a3eb90..67d408a 100644 --- a/front/src/app/login.tsx +++ b/front/src/app/login.tsx @@ -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() { @@ -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 { diff --git a/front/src/app/workout/[id].tsx b/front/src/app/workout/[id].tsx new file mode 100644 index 0000000..2df0684 --- /dev/null +++ b/front/src/app/workout/[id].tsx @@ -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 ( + + Detalhe do treino + Treino #{id} + + A exibição completa deste treino será implementada em breve. + + + ); +} + +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 }, +}); diff --git a/front/src/components/WorkoutHistoryList.tsx b/front/src/components/WorkoutHistoryList.tsx new file mode 100644 index 0000000..2ed59cf --- /dev/null +++ b/front/src/components/WorkoutHistoryList.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; + +export type WorkoutHistoryEntry = { + id: number; + title: string; +}; + +type WorkoutHistoryListProps = { + workouts: WorkoutHistoryEntry[]; + loading?: boolean; + onItemPress?: (id: number) => void; +}; + +export default function WorkoutHistoryList({ + workouts, + loading = false, + onItemPress, +}: WorkoutHistoryListProps) { + return ( + + Histórico de treinos + + {loading && ( + + + + )} + + {!loading && workouts.length === 0 && ( + Nenhum treino realizado ainda. + )} + + {!loading && + workouts.map((workout) => ( + onItemPress?.(workout.id)} + style={({ pressed }) => pressed && styles.itemPressed} + > + + + ))} + + ); +} + +function HistoryItem({ name }: { name: string }) { + return ( + + 🏋️ + {name} + + Concluído + + + ); +} + +const GREEN = "#1D9E75"; + +const styles = StyleSheet.create({ + card: { + backgroundColor: "#fff", + borderRadius: 14, + padding: 16, + gap: 10, + borderWidth: 0.5, + borderColor: "rgba(0,0,0,0.08)", + }, + cardTitle: { fontSize: 14, fontWeight: "600", color: "#222" }, + centered: { paddingVertical: 12, alignItems: "center" }, + empty: { fontSize: 13, color: "#999", paddingVertical: 4 }, + 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" }, + itemPressed: { opacity: 0.7 }, +}); diff --git a/front/src/services/api.ts b/front/src/services/api.ts index d1c7f11..f8ccaf0 100644 --- a/front/src/services/api.ts +++ b/front/src/services/api.ts @@ -101,3 +101,13 @@ export async function updateProfile(data: any) { return normalizeUser(response) } + +export type WorkoutHistoryItem = { + id: number + title: string + created_at: string +} + +export async function getWorkoutHistory(userId: number): Promise { + return request(`/workouts/history?user_id=${userId}`) +} diff --git a/front/src/services/session.ts b/front/src/services/session.ts index ecaad92..2c4fe7d 100644 --- a/front/src/services/session.ts +++ b/front/src/services/session.ts @@ -2,6 +2,8 @@ import { Platform } from 'react-native' import * as SecureStore from 'expo-secure-store' const TOKEN_KEY = 'token' +const USER_ID_KEY = 'userId' +const USER_NAME_KEY = 'userName' function getStorage() { if (Platform.OS === 'web' && typeof globalThis !== 'undefined' && globalThis.localStorage) { @@ -31,8 +33,44 @@ export async function clearToken() { const storage = getStorage() if (storage) { storage.removeItem(TOKEN_KEY) + storage.removeItem(USER_ID_KEY) + storage.removeItem(USER_NAME_KEY) return } await SecureStore.deleteItemAsync(TOKEN_KEY) + await SecureStore.deleteItemAsync(USER_ID_KEY) + await SecureStore.deleteItemAsync(USER_NAME_KEY) +} + +export async function getUserId() { + const storage = getStorage() + if (storage) return storage.getItem(USER_ID_KEY) + return SecureStore.getItemAsync(USER_ID_KEY) +} + +export async function setUserId(userId: string) { + const storage = getStorage() + if (storage) { + storage.setItem(USER_ID_KEY, userId) + return + } + + await SecureStore.setItemAsync(USER_ID_KEY, userId) +} + +export async function getUserName() { + const storage = getStorage() + if (storage) return storage.getItem(USER_NAME_KEY) + return SecureStore.getItemAsync(USER_NAME_KEY) +} + +export async function setUserName(name: string) { + const storage = getStorage() + if (storage) { + storage.setItem(USER_NAME_KEY, name) + return + } + + await SecureStore.setItemAsync(USER_NAME_KEY, name) }