Skip to content

Commit d77b9d8

Browse files
committed
feat: implement My Exercises feature with filtering and navigation
1 parent 22419cb commit d77b9d8

16 files changed

Lines changed: 9959 additions & 9493 deletions

Modules/Core/Localization/L10n.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,16 @@ enum L10n {
186186
static let homeFlashcardRepetitions = tr("home.flashcard.repetitions")
187187
static let homeFlashcardEasiness = tr("home.flashcard.easiness")
188188
static let homeFlashcardNextReview = tr("home.flashcard.next_review")
189+
static let homeMyExercisesTitle = tr("home.my_exercises.title")
190+
static let homeMyExercisesSubtitle = tr("home.my_exercises.subtitle")
191+
static let homeMyExercisesFilterAll = tr("home.my_exercises.filter_all")
192+
static let homeMyExercisesFilterPending = tr("home.my_exercises.filter_pending")
193+
static let homeMyExercisesFilterVerified = tr("home.my_exercises.filter_verified")
194+
static let homeMyExercisesEmptyTitle = tr("home.my_exercises.empty_title")
195+
static let homeMyExercisesEmptyDesc = tr("home.my_exercises.empty_desc")
196+
static let homeMyExercisesCreateCta = tr("home.my_exercises.create_cta")
197+
static let homeMyExercisesStatusPending = tr("home.my_exercises.status_pending")
198+
static let homeMyExercisesStatusVerified = tr("home.my_exercises.status_verified")
189199
static func homeNextReview(_ date: String) -> String {
190200
fmt("home.next_review", date)
191201
}

Modules/Core/Navigation/AppNavigationCoordinator.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ final class AppNavigationCoordinator: ObservableObject {
2626
authPath.append(route)
2727
}
2828

29+
func pushHome(_ route: HomeRoute) {
30+
homePath.append(route)
31+
}
32+
2933
func popAuth() {
3034
if !authPath.isEmpty { authPath.removeLast() }
3135
}

Modules/Core/Navigation/NavigationRoutes.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ enum MainTab: Int, Hashable {
1212
case admin = 8
1313
}
1414

15+
enum HomeRoute: Hashable {
16+
case myExercises
17+
case exerciseDetail(id: UUID)
18+
}
19+
1520
enum AuthRoute: Hashable {
1621
case login
1722
case register

Modules/Core/Wrappers/SupabaseChatbotService.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ struct ChatRequest: Codable {
77

88
struct ChatResponse: Codable {
99
let message: String
10-
let suggestedActions: [String]
10+
let suggestedActions: [String]?
1111

1212
enum CodingKeys: String, CodingKey {
1313
case message
14-
case suggestedActions = "suggested_actions"
14+
case suggestedActions = "suggestedActions"
1515
}
1616
}
1717

@@ -29,7 +29,7 @@ final class SupabaseChatbotService: ChatbotServiceProtocol {
2929
)
3030
return ChatbotResponse(
3131
message: response.message,
32-
suggestedActions: response.suggestedActions
32+
suggestedActions: response.suggestedActions ?? []
3333
)
3434
} catch {
3535
print("SupabaseChatbotService: Error encountered: \(error)")
@@ -54,7 +54,6 @@ final class SupabaseChatbotService: ChatbotServiceProtocol {
5454
5555
$$\\int x^n \\, dx = \\frac{x^{n+1}}{n+1} + C$$
5656
57-
*Nota: Conexión local de respaldo activa. Los datos mostrados son mockeados.*
5857
"""
5958

6059
return ChatbotResponse(

Modules/Data/Local/LocalExerciseRepository.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ final class LocalExerciseRepository: ObservableObject, ExerciseRepository {
3838
return .success(store.find(where: { $0.id == id }))
3939
}
4040

41-
func fetchMyExercises() async -> Result<[Exercise], DMError> {
42-
let userId = authService.currentUser?.id
41+
func fetchExercisesByAuthor(userId: UUID) async -> Result<[Exercise], DMError> {
4342
let results = store.filter { $0.authorId == userId }
4443
.sorted { $0.createdAt > $1.createdAt }
4544
return .success(results)

Modules/Data/Remote/SupabaseExerciseRepository.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,20 @@ final class SupabaseExerciseRepository: ObservableObject, ExerciseRepository {
5555
do {
5656
let exercises: [Exercise] = try await supabase
5757
.from("exercises")
58-
.select("*, author:profiles!exercises_author_id_fkey(*)")
59-
.eq("status", value: "pending")
58+
.select("*")
6059
.order("created_at", ascending: true)
6160
.execute()
6261
.value
62+
// log
63+
print("Fetched pending exercises: \(exercises.count)")
6364
return .success(exercises)
6465
} catch {
6566
return .failure(.networkError(error))
6667
}
6768
}
6869

69-
func fetchMyExercises() async -> Result<[Exercise], DMError> {
70+
func fetchExercisesByAuthor(userId: UUID) async -> Result<[Exercise], DMError> {
7071
do {
71-
guard let userId = auth.currentUser?.id else { return .success([]) }
7272
let exercises: [Exercise] = try await supabase
7373
.from("exercises")
7474
.select("*, author:profiles!exercises_author_id_fkey(*)")

Modules/Domain/Repositories/ExerciseRepository.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Foundation
44
protocol ExerciseRepository {
55
func fetchExercises(category: AppConstants.Category?, searchText: String?) async -> Result<[Exercise], DMError>
66
func fetchExerciseById(_ id: UUID) async -> Result<Exercise?, DMError>
7-
func fetchMyExercises() async -> Result<[Exercise], DMError>
7+
func fetchExercisesByAuthor(userId: UUID) async -> Result<[Exercise], DMError>
88
func createExercise(_ payload: CreateExerciseDTO) async -> Result<Void, DMError>
99
func deleteExercise(id: UUID) async -> Result<Void, DMError>
1010
func fetchPendingExercises() async -> Result<[Exercise], DMError>

Modules/Features/Admin/Views/AdminDashboardView.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ struct AdminDashboardView: View {
2020
DMActionRow(icon: "doc.text.fill", title: L10n.adminDashboardContent, color: Color.dmSuccess) {
2121
navigation.pushAdmin(.contentModeration)
2222
}
23-
Divider()
24-
DMActionRow(icon: "slider.horizontal.3", title: L10n.adminDashboardAppSettings, color: Color.dmWarning) {
25-
navigation.pushAdmin(.appConfig)
26-
}
2723
}
2824
.background(Color.dmSurface)
2925
.cornerRadius(DMRadius.lg)

Modules/Features/App/Views/MainTabView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ struct MainTabView: View {
3333
if !appState.isModerator && !appState.isAdmin {
3434
NavigationStack(path: $navigation.homePath) {
3535
TodayView()
36+
.navigationDestination(for: HomeRoute.self) { route in
37+
switch route {
38+
case .myExercises:
39+
MyExercisesView()
40+
case let .exerciseDetail(id):
41+
CommunityExerciseDetailView(exerciseId: id)
42+
}
43+
}
3644
}
3745
.tabItem { Label(L10n.tabToday, systemImage: "calendar") }
3846
.tag(MainTab.today)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import SwiftUI
2+
3+
struct MyExercisesView: View {
4+
@EnvironmentObject var appState: AppState
5+
@EnvironmentObject var navigation: AppNavigationCoordinator
6+
7+
@State private var exercises: [Exercise] = []
8+
@State private var isLoading = true
9+
@State private var selectedStatus: ExerciseFilter = .all
10+
11+
enum ExerciseFilter: String, CaseIterable, Identifiable {
12+
case all, pending, verified
13+
var id: String { rawValue }
14+
15+
var localized: String {
16+
switch self {
17+
case .all: return L10n.homeMyExercisesFilterAll
18+
case .pending: return L10n.homeMyExercisesFilterPending
19+
case .verified: return L10n.homeMyExercisesFilterVerified
20+
}
21+
}
22+
}
23+
24+
var filteredExercises: [Exercise] {
25+
switch selectedStatus {
26+
case .all: return exercises
27+
case .pending: return exercises.filter { $0.isPending }
28+
case .verified: return exercises.filter { $0.isVerified }
29+
}
30+
}
31+
32+
var body: some View {
33+
VStack(spacing: 0) {
34+
// Filter Picker
35+
Picker(L10n.commonCategory, selection: $selectedStatus) {
36+
ForEach(ExerciseFilter.allCases) { filter in
37+
Text(filter.localized).tag(filter)
38+
}
39+
}
40+
.pickerStyle(.segmented)
41+
.padding()
42+
.background(Color.dmBackground)
43+
44+
ZStack {
45+
Color.dmBackground.ignoresSafeArea()
46+
47+
if isLoading {
48+
ProgressView()
49+
} else if exercises.isEmpty {
50+
emptyState
51+
} else {
52+
List {
53+
ForEach(filteredExercises) { exercise in
54+
exerciseRow(exercise)
55+
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
56+
.listRowSeparator(.hidden)
57+
.listRowBackground(Color.clear)
58+
.onTapGesture {
59+
navigation.pushHome(.exerciseDetail(id: exercise.id))
60+
}
61+
}
62+
}
63+
.listStyle(.plain)
64+
.refreshable {
65+
await loadExercises()
66+
}
67+
}
68+
}
69+
}
70+
.navigationTitle(L10n.homeMyExercisesTitle)
71+
.navigationBarTitleDisplayMode(.inline)
72+
.onAppear {
73+
Task {
74+
await loadExercises()
75+
}
76+
}
77+
}
78+
79+
private var emptyState: some View {
80+
VStack(spacing: 20) {
81+
Image(systemName: "doc.text.fill")
82+
.font(.system(size: 60))
83+
.foregroundStyle(Color.dmTextSecondary.opacity(0.3))
84+
85+
Text(L10n.homeMyExercisesEmptyTitle)
86+
.font(DMFont.heading5())
87+
88+
Text(L10n.homeMyExercisesEmptyDesc)
89+
.font(DMFont.bodySm())
90+
.foregroundStyle(Color.dmTextSecondary)
91+
.multilineTextAlignment(.center)
92+
.padding(.horizontal, 40)
93+
94+
Button {
95+
navigation.pushCommunity(.createExercise)
96+
} label: {
97+
Text(L10n.homeMyExercisesCreateCta)
98+
.primaryButton()
99+
}
100+
.padding(.horizontal, 60)
101+
}
102+
}
103+
104+
private func exerciseRow(_ exercise: Exercise) -> some View {
105+
VStack(alignment: .leading, spacing: 12) {
106+
HStack {
107+
Text(exercise.categoryEnum?.displayName ?? exercise.category)
108+
.font(DMFont.microUppercase())
109+
.padding(.horizontal, 8)
110+
.padding(.vertical, 4)
111+
.background(Color.dmPrimary.opacity(0.1))
112+
.foregroundStyle(Color.dmPrimary)
113+
.cornerRadius(DMRadius.sm)
114+
115+
Spacer()
116+
117+
statusBadge(exercise.status)
118+
}
119+
120+
Text(exercise.title)
121+
.font(DMFont.bodyMdMedium())
122+
.lineLimit(2)
123+
124+
HStack(spacing: 16) {
125+
Label("\(exercise.votesCount)", systemImage: "arrow.up.circle.fill")
126+
Label("\(exercise.commentsCount ?? 0)", systemImage: "bubble.right.fill")
127+
Spacer()
128+
Text(exercise.createdAt.formatted(date: .abbreviated, time: .omitted))
129+
}
130+
.font(DMFont.caption())
131+
.foregroundStyle(Color.dmTextSecondary)
132+
}
133+
.padding(DMSpacing.md)
134+
.background(Color.dmSurface)
135+
.cornerRadius(DMRadius.lg)
136+
.shadow(color: .black.opacity(0.03), radius: 5, y: 2)
137+
}
138+
139+
@ViewBuilder
140+
private func statusBadge(_ status: String) -> some View {
141+
let isVerified = status == "verified"
142+
HStack(spacing: 4) {
143+
Circle()
144+
.fill(isVerified ? Color.dmSuccess : Color.dmWarning)
145+
.frame(width: 8, height: 8)
146+
Text(isVerified ? L10n.homeMyExercisesStatusVerified : L10n.homeMyExercisesStatusPending)
147+
.font(DMFont.captionBold())
148+
.foregroundStyle(isVerified ? Color.dmSuccess : Color.dmWarning)
149+
}
150+
.padding(.horizontal, 8)
151+
.padding(.vertical, 4)
152+
.background((isVerified ? Color.dmSuccess : Color.dmWarning).opacity(0.1))
153+
.cornerRadius(DMRadius.pill)
154+
}
155+
156+
private func loadExercises() async {
157+
guard let userId = appState.currentUser?.id else {
158+
isLoading = false
159+
return
160+
}
161+
isLoading = true
162+
let result = await appState.exerciseRepository.fetchExercisesByAuthor(userId: userId)
163+
switch result {
164+
case .success(let fetched):
165+
exercises = fetched
166+
case .failure(let error):
167+
print("Error loading my exercises: \(error)")
168+
}
169+
isLoading = false
170+
}
171+
}

0 commit comments

Comments
 (0)