From 575121ddd4157181895215c5d5b8c1531ef59dce Mon Sep 17 00:00:00 2001 From: voynow Date: Sat, 12 Oct 2024 13:43:47 -0400 Subject: [PATCH] mobile profile setup --- mobile/mobile/DashboardNavbar.swift | 7 +- mobile/mobile/DashboardView.swift | 75 +++++-- mobile/mobile/Models.swift | 54 ++++- mobile/mobile/ProfileView.swift | 320 ++++++++++++++++++++++++++-- 4 files changed, 414 insertions(+), 42 deletions(-) diff --git a/mobile/mobile/DashboardNavbar.swift b/mobile/mobile/DashboardNavbar.swift index fb30595..e86e5ae 100644 --- a/mobile/mobile/DashboardNavbar.swift +++ b/mobile/mobile/DashboardNavbar.swift @@ -2,6 +2,7 @@ import SwiftUI struct DashboardNavbar: View { var onLogout: () -> Void + @Binding var showProfile: Bool var body: some View { HStack { @@ -16,9 +17,9 @@ struct DashboardNavbar: View { Spacer() - Menu { - Button("Logout", action: onLogout) - } label: { + Button(action: { + showProfile.toggle() + }) { Image(systemName: "person.circle") .resizable() .frame(width: 30, height: 30) diff --git a/mobile/mobile/DashboardView.swift b/mobile/mobile/DashboardView.swift index 9a2fb05..6333ba4 100644 --- a/mobile/mobile/DashboardView.swift +++ b/mobile/mobile/DashboardView.swift @@ -3,29 +3,56 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject var appState: AppState @State private var trainingWeekData: TrainingWeekData? + @State private var profileData: ProfileData? @State private var isLoadingTrainingWeek: Bool = true + @State private var isLoadingProfile: Bool = true + @State private var showProfile: Bool = false var body: some View { NavigationView { - VStack(spacing: 0) { - DashboardNavbar(onLogout: handleLogout) - .background(ColorTheme.superDarkGrey) - .zIndex(1) + ZStack { + VStack(spacing: 0) { + DashboardNavbar(onLogout: handleLogout, showProfile: $showProfile) + .background(ColorTheme.superDarkGrey) + .zIndex(1) - ScrollView { - if isLoadingTrainingWeek { + ScrollView { + if isLoadingTrainingWeek { + LoadingView() + } else if let data = trainingWeekData { + TrainingWeekView(data: data) + } else { + Text("No training data available") + .font(.headline) + .foregroundColor(ColorTheme.lightGrey) + } + } + } + .background(ColorTheme.superDarkGrey.edgesIgnoringSafeArea(.all)) + .navigationBarHidden(true) + + if showProfile { + if let profileData = profileData { + ProfileView( + isPresented: $showProfile, + profileData: Binding( + get: { profileData }, + set: { self.profileData = $0 } + ), + showProfile: $showProfile + ) + .transition(.move(edge: .trailing)) + .zIndex(2) + } else if isLoadingProfile { LoadingView() - } else if let data = trainingWeekData { - TrainingWeekView(data: data) + .zIndex(2) } else { - Text("No training data available") - .font(.headline) + Text("Failed to load profile") .foregroundColor(ColorTheme.lightGrey) + .zIndex(2) } } } - .background(ColorTheme.superDarkGrey.edgesIgnoringSafeArea(.all)) - .navigationBarHidden(true) } .onAppear(perform: fetchData) } @@ -37,6 +64,11 @@ struct DashboardView: View { } private func fetchData() { + fetchTrainingWeekData() + fetchProfileData() + } + + private func fetchTrainingWeekData() { guard let token = appState.jwtToken else { isLoadingTrainingWeek = false return @@ -54,4 +86,23 @@ struct DashboardView: View { } } } + + private func fetchProfileData() { + guard let token = appState.jwtToken else { + isLoadingProfile = false + return + } + + APIManager.shared.fetchProfileData(token: token) { result in + DispatchQueue.main.async { + self.isLoadingProfile = false + switch result { + case .success(let profile): + self.profileData = profile + case .failure(let error): + print("Error fetching profile data: \(error)") + } + } + } + } } diff --git a/mobile/mobile/Models.swift b/mobile/mobile/Models.swift index 3790a2d..1a31b4c 100644 --- a/mobile/mobile/Models.swift +++ b/mobile/mobile/Models.swift @@ -1,12 +1,12 @@ import Foundation struct ProfileData: Codable { - let firstname: String - let lastname: String - let email: String - let profile: String - let isActive: Bool - let preferences: String + var firstname: String + var lastname: String + var email: String + var profile: String + var isActive: Bool + var preferences: String? enum CodingKeys: String, CodingKey { case firstname, lastname, email, profile, preferences @@ -14,6 +14,48 @@ struct ProfileData: Codable { } } +enum Day: String, CaseIterable, Codable { + case mon = "Mon" + case tues = "Tues" + case wed = "Wed" + case thurs = "Thurs" + case fri = "Fri" + case sat = "Sat" + case sun = "Sun" +} + +struct TrainingDay: Codable { + let day: Day + let sessionType: String + + enum CodingKeys: String, CodingKey { + case day + case sessionType = "session_type" + } +} + +struct Preferences: Codable { + var raceDistance: String? + var idealTrainingWeek: [TrainingDay]? + + enum CodingKeys: String, CodingKey { + case raceDistance = "race_distance" + case idealTrainingWeek = "ideal_training_week" + } + + init(fromJSON json: String) { + let decoder = JSONDecoder() + if let jsonData = json.data(using: .utf8), + let decoded = try? decoder.decode(Preferences.self, from: jsonData) + { + self = decoded + } else { + self.raceDistance = nil + self.idealTrainingWeek = nil + } + } +} + struct TrainingWeekData: Codable { let sessions: [TrainingSession] } diff --git a/mobile/mobile/ProfileView.swift b/mobile/mobile/ProfileView.swift index e8b8849..1c441cb 100644 --- a/mobile/mobile/ProfileView.swift +++ b/mobile/mobile/ProfileView.swift @@ -1,40 +1,318 @@ import SwiftUI struct ProfileView: View { - let data: ProfileData + @Binding var isPresented: Bool + @Binding var profileData: ProfileData + @State private var preferences: Preferences + @State private var isEditing: Bool = false + @State private var isSaving: Bool = false + @EnvironmentObject var appState: AppState + @Binding var showProfile: Bool + + init(isPresented: Binding, profileData: Binding, showProfile: Binding) { + self._isPresented = isPresented + self._profileData = profileData + self._showProfile = showProfile + self._preferences = State( + initialValue: Preferences(fromJSON: profileData.wrappedValue.preferences ?? "{}")) + } var body: some View { - HStack(spacing: 16) { - AsyncImage(url: URL(string: data.profile)) { image in - image.resizable() - } placeholder: { - Color.gray.opacity(0.3) + VStack(spacing: 0) { + DashboardNavbar(onLogout: handleSignOut, showProfile: $showProfile) + .background(ColorTheme.superDarkGrey) + .zIndex(1) + + ScrollView { + VStack { + HStack(spacing: 16) { + AsyncImage(url: URL(string: profileData.profile)) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: 70, height: 70) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "person.circle.fill") + .resizable() + .foregroundColor(Color.gray.opacity(0.3)) + @unknown default: + Color.gray.opacity(0.3) + } + } + .frame(width: 70, height: 70) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 6) { + Text("\(profileData.firstname) \(profileData.lastname)") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(ColorTheme.white) + + Text(profileData.email) + .font(.system(size: 14)) + .foregroundColor(ColorTheme.lightGrey) + + HStack(spacing: 6) { + Circle() + .fill(profileData.isActive ? ColorTheme.green : ColorTheme.darkGrey) + .frame(width: 8, height: 8) + + Text(profileData.isActive ? "Active" : "Inactive") + .font(.system(size: 12)) + .foregroundColor(ColorTheme.lightGrey) + } + } + + Spacer() + } + .padding(.vertical, 16) + .padding(.horizontal) + .background(ColorTheme.darkDarkGrey) + .cornerRadius(12) + + if isEditing { + PreferencesForm( + preferences: $preferences, + onSave: handleSavePreferences, + onCancel: { isEditing = false }, + isSaving: $isSaving + ) + } else { + PreferencesView(preferences: preferences, isEditing: $isEditing) + } + } + .padding() } - .frame(width: 70, height: 70) - .clipShape(Circle()) - VStack(alignment: .leading, spacing: 6) { - Text("\(data.firstname) \(data.lastname)") + Spacer() + + Button(action: handleSignOut) { + Text("Sign Out") .font(.system(size: 18, weight: .semibold)) .foregroundColor(ColorTheme.white) + .frame(maxWidth: .infinity) + .padding() + .background(ColorTheme.redPink) + .cornerRadius(12) + } + .padding(.horizontal) + .padding(.bottom, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ColorTheme.superDarkGrey.edgesIgnoringSafeArea(.all)) + } + + private func handleSavePreferences() { + isSaving = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + let encoder = JSONEncoder() + if let preferencesJSON = try? encoder.encode(preferences), + let preferencesString = String(data: preferencesJSON, encoding: .utf8) + { + profileData.preferences = preferencesString + } + isEditing = false + isSaving = false + } + } - Text(data.email) - .font(.system(size: 14)) - .foregroundColor(ColorTheme.lightGrey) + private func handleSignOut() { + appState.isLoggedIn = false + appState.jwtToken = nil + UserDefaults.standard.removeObject(forKey: "jwt_token") + isPresented = false + } +} - HStack(spacing: 6) { - Circle() - .fill(data.isActive ? Color.cyan : Color.red) - .frame(width: 8, height: 8) +struct PreferencesView: View { + let preferences: Preferences + @Binding var isEditing: Bool - Text(data.isActive ? "Active" : "Inactive") - .font(.system(size: 12)) + var body: some View { + VStack(alignment: .leading, spacing: 20) { + // Title and Edit button + HStack { + Text("Preferences") + .font(.title) + .fontWeight(.bold) + .foregroundColor(ColorTheme.white) + Spacer() + Button(action: { isEditing.toggle() }) { + Text("Edit") + .foregroundColor(ColorTheme.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(ColorTheme.darkGrey) + .cornerRadius(8) + } + } + + // Race Details section + PreferenceSection(title: "Race Details") { + HStack { + Text("Race Distance") + .font(.subheadline) .foregroundColor(ColorTheme.lightGrey) + Spacer() + Text(preferences.raceDistance ?? "Not set") + .foregroundColor(ColorTheme.white) + } + } + + // Ideal Training Week section + PreferenceSection(title: "Ideal Training Week") { + ForEach(Day.allCases, id: \.self) { day in + HStack { + Text(day.rawValue) + .font(.subheadline) + .foregroundColor(ColorTheme.lightGrey) + Spacer() + Text( + preferences.idealTrainingWeek?.first(where: { $0.day == day })?.sessionType + ?? "No Preference" + ) + .foregroundColor(ColorTheme.white) + } + } + } + } + .padding() + .background(ColorTheme.darkDarkGrey) + .cornerRadius(12) + } +} + +struct PreferenceSection: View { + let title: String + let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(ColorTheme.lightGrey) + + content + .padding() + .cornerRadius(8) + } + } +} + +struct PreferencesForm: View { + @Binding var preferences: Preferences + let onSave: () -> Void + let onCancel: () -> Void + @Binding var isSaving: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Edit Preferences") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(ColorTheme.white) + Spacer() + } + + Text("Race Distance") + .font(.headline) + .foregroundColor(ColorTheme.lightGrey) + Picker( + "Race Distance", + selection: Binding( + get: { preferences.raceDistance ?? "" }, + set: { preferences.raceDistance = $0.isEmpty ? nil : $0 } + ) + ) { + Text("Select distance").tag("") + Text("5K").tag("5k") + Text("10K").tag("10k") + Text("Half Marathon").tag("half marathon") + Text("Marathon").tag("marathon") + Text("Ultra Marathon").tag("ultra marathon") + Text("None").tag("none") + } + .pickerStyle(MenuPickerStyle()) + .foregroundColor(ColorTheme.white) + + Text("Ideal Training Week") + .font(.headline) + .foregroundColor(ColorTheme.lightGrey) + + ForEach(Day.allCases, id: \.self) { day in + HStack { + Text(day.rawValue) + .foregroundColor(ColorTheme.lightGrey) + Spacer() + Picker( + "Session Type", + selection: Binding( + get: { + preferences.idealTrainingWeek?.first(where: { $0.day == day })?.sessionType ?? "" + }, + set: { newValue in + if var week = preferences.idealTrainingWeek { + if let index = week.firstIndex(where: { $0.day == day }) { + week[index] = TrainingDay(day: day, sessionType: newValue) + } else { + week.append(TrainingDay(day: day, sessionType: newValue)) + } + preferences.idealTrainingWeek = week + } else { + preferences.idealTrainingWeek = [TrainingDay(day: day, sessionType: newValue)] + } + } + ) + ) { + Text("No Preference").tag("") + Text("Easy Run").tag("easy run") + Text("Long Run").tag("long run") + Text("Speed Workout").tag("speed workout") + Text("Rest Day").tag("rest day") + Text("Moderate Run").tag("moderate run") + } + .pickerStyle(MenuPickerStyle()) + .foregroundColor(ColorTheme.white) + } + } + + HStack { + Button(action: onCancel) { + Text("Cancel") + .foregroundColor(ColorTheme.white) + .padding() + .background(ColorTheme.darkGrey) + .cornerRadius(8) + } + + Spacer() + + Button(action: onSave) { + if isSaving { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: ColorTheme.white)) + } else { + Text("Save") + } } + .foregroundColor(ColorTheme.white) + .padding() + .background(ColorTheme.primary) + .cornerRadius(8) + .disabled(isSaving) } } - .padding(.vertical, 16) - .padding(.horizontal) + .padding() .background(ColorTheme.darkDarkGrey) .cornerRadius(12) }