diff --git a/mobile/mobile.xcodeproj/project.pbxproj b/mobile/mobile.xcodeproj/project.pbxproj index b90f8f5..5907ca7 100644 --- a/mobile/mobile.xcodeproj/project.pbxproj +++ b/mobile/mobile.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXFileReference section */ - 80777D112C973F01007565FC /* mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = mobile.app; path = "/Users/jamievoynow/Desktop/code/trackflow/mobile/build/Debug-iphoneos/mobile.app"; sourceTree = ""; }; + 809F8EBF2CCBE0BE00D41481 /* mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -27,6 +27,7 @@ LoadingView.swift, mobileApp.swift, Models.swift, + OnboardingView.swift, PreferencesView.swift, "Preview Content/Preview Assets.xcassets", ProfileSkeletonView.swift, @@ -57,6 +58,7 @@ isa = PBXGroup; children = ( 807A711A2CCB2DA30032BA17 /* mobile */, + 809F8EBF2CCBE0BE00D41481 /* mobile.app */, ); sourceTree = ""; }; @@ -77,7 +79,7 @@ ); name = mobile; productName = mobile; - productReference = 80777D112C973F01007565FC /* mobile.app */; + productReference = 809F8EBF2CCBE0BE00D41481 /* mobile.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ diff --git a/mobile/mobile/APIManager.swift b/mobile/mobile/APIManager.swift index b7e024e..cf5ca57 100644 --- a/mobile/mobile/APIManager.swift +++ b/mobile/mobile/APIManager.swift @@ -183,4 +183,26 @@ class APIManager { } }.resume() } + + func startOnboarding(token: String, completion: @escaping (Result) -> Void) { + let body: [String: Any] = ["jwt_token": token, "method": "start_onboarding"] + performRequest(body: body, responseType: GenericResponse.self) { result in + switch result { + case .success(let response): + if response.success { + completion(.success(())) + } else { + let errorMessage = response.message ?? "Failed to start onboarding" + completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } +} + +struct GenericResponse: Codable { + let success: Bool + let message: String? } diff --git a/mobile/mobile/AppState.swift b/mobile/mobile/AppState.swift index 3ec8eb3..717506b 100644 --- a/mobile/mobile/AppState.swift +++ b/mobile/mobile/AppState.swift @@ -1,8 +1,15 @@ import SwiftUI class AppState: ObservableObject { - @Published var isLoggedIn: Bool = false - @Published var jwtToken: String? = nil - @Published var isLoading: Bool = false - @Published var isNewUser: Bool = false + @Published var status: AppStateStatus = .loggedOut { + didSet { + print("AppState status changed to: \(status)") + } + } + + @Published var jwtToken: String? = nil { + didSet { + print("AppState jwtToken changed to: \(jwtToken ?? "nil")") + } + } } diff --git a/mobile/mobile/ContentView.swift b/mobile/mobile/ContentView.swift index f626627..5b9fb7c 100644 --- a/mobile/mobile/ContentView.swift +++ b/mobile/mobile/ContentView.swift @@ -10,16 +10,14 @@ struct ContentView: View { var body: some View { ZStack { - if appState.isNewUser { - // TODO: Create state enum - // OnboardingView(onComplete: handleOnboardingComplete) - } else if appState.isLoggedIn { + switch appState.status { + case .newUser: + OnboardingView() + case .loggedIn: DashboardView() - } else { + case .loggedOut: LandingPageView(authManager: authManager) - } - - if appState.isLoading { + case .loading: LoadingView() } } diff --git a/mobile/mobile/DashboardView.swift b/mobile/mobile/DashboardView.swift index 3b477e2..f719916 100644 --- a/mobile/mobile/DashboardView.swift +++ b/mobile/mobile/DashboardView.swift @@ -27,6 +27,9 @@ struct DashboardView: View { Text("No training data available") .font(.headline) .foregroundColor(ColorTheme.lightGrey) + Button(action: handleLogout) { + Text("Logout") + } } } } @@ -54,7 +57,7 @@ struct DashboardView: View { } private func handleLogout() { - appState.isLoggedIn = false + appState.status = .loggedOut appState.jwtToken = nil UserDefaults.standard.removeObject(forKey: "jwt_token") } diff --git a/mobile/mobile/Models.swift b/mobile/mobile/Models.swift index bec387f..0fa7929 100644 --- a/mobile/mobile/Models.swift +++ b/mobile/mobile/Models.swift @@ -173,3 +173,10 @@ struct WeeklySummariesResponse: Codable { struct GenerateTrainingPlanResponse: Codable { let success: Bool } + +enum AppStateStatus { + case loading + case loggedOut + case loggedIn + case newUser +} diff --git a/mobile/mobile/OnboardingView.swift b/mobile/mobile/OnboardingView.swift new file mode 100644 index 0000000..ee97a91 --- /dev/null +++ b/mobile/mobile/OnboardingView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct OnboardingView: View { + @EnvironmentObject var appState: AppState + @State private var progress: Double = 0 + @State private var currentStage: Int = 0 + @State private var errorMessage: String? + @State private var showExtendedWaitMessage: Bool = false + + let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() + + let stages: [String] = [ + "Our AI is getting to know you", + "Analyzing your Strava data", + "Our AI Agent is impressed!", + "Analyzing your workouts", + "Considering volume and intensity", + "Generating candidate training plans", + "Selecting the best plan for you", + "Fine-tuning recommendations", + ] + + var body: some View { + GeometryReader { geometry in + ZStack { + ColorTheme.black.edgesIgnoringSafeArea(.all) + + VStack(spacing: 20) { + Spacer() + + brandingView + + Text("Account setup typically takes 30 seconds. Please do not close this window.") + .font(.system(size: 16, weight: .light)) + .foregroundColor(ColorTheme.lightGrey) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + Spacer() + + Text(stages[currentStage]) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(ColorTheme.primaryLight) + .transition(.opacity) + .id(currentStage) + .multilineTextAlignment(.center) + + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle(tint: ColorTheme.primary)) + .frame(height: 4) + + if let errorMessage = errorMessage { + Text(errorMessage) + .font(.subheadline) + .foregroundColor(.red) + .multilineTextAlignment(.center) + } + + if showExtendedWaitMessage { + Text( + "We apologize for the wait. Your account is still being set up and will be ready very soon." + ) + .font(.system(size: 16, weight: .light)) + .foregroundColor(ColorTheme.lightGrey) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .transition(.opacity) + } + + Spacer() + } + .padding(.horizontal, 40) + .frame(width: geometry.size.width) + } + } + .onAppear(perform: startOnboarding) + .onReceive(timer) { _ in + updateProgress() + } + } + + private var brandingView: some View { + VStack(spacing: 16) { + Text("🏃‍♂️🎯") + .font(.system(size: 20)) + + HStack(spacing: 0) { + Text("Track") + .font(.system(size: 40, weight: .black)) + .foregroundColor(ColorTheme.primaryLight) + Text("Flow") + .font(.system(size: 40, weight: .black)) + .foregroundColor(ColorTheme.primary) + } + } + } + + private func startOnboarding() { + guard let token = UserDefaults.standard.string(forKey: "jwt_token") else { + errorMessage = "No token found. Please log in again." + return + } + + APIManager.shared.startOnboarding(token: token) { result in + DispatchQueue.main.async { + switch result { + case .success: + appState.status = .loggedIn + case .failure(let error): + if let decodingError = error as? DecodingError { + errorMessage = "Failed to decode response: \(decodingError)" + } else if let urlError = error as? URLError { + errorMessage = "Network error: \(urlError.localizedDescription)" + } else { + errorMessage = "Onboarding failed: \(error.localizedDescription)" + } + print("Detailed error: \(error)") + } + } + } + } + + private func updateProgress() { + if progress < 1.0 { + progress = min(progress + 0.003, 1.0) + let newStage = min(Int(progress * Double(stages.count)), stages.count - 1) + if newStage != currentStage { + withAnimation(.easeInOut(duration: 0.5)) { + currentStage = newStage + } + } + + if progress >= 0.9 && !showExtendedWaitMessage { + withAnimation { + showExtendedWaitMessage = true + } + } + } else if errorMessage == nil && appState.status != .loggedIn { + // Keep progress at 1.0 and show the last stage + currentStage = stages.count - 1 + showExtendedWaitMessage = true + } + } +} diff --git a/mobile/mobile/ProfileView.swift b/mobile/mobile/ProfileView.swift index 4e3ecfa..74a50a5 100644 --- a/mobile/mobile/ProfileView.swift +++ b/mobile/mobile/ProfileView.swift @@ -52,7 +52,7 @@ struct ProfileView: View { } private func handleSignOut() { - appState.isLoggedIn = false + appState.status = .loggedOut appState.jwtToken = nil UserDefaults.standard.removeObject(forKey: "jwt_token") isPresented = false diff --git a/mobile/mobile/StravaAuthManager.swift b/mobile/mobile/StravaAuthManager.swift index ec965a9..bf1a5c6 100644 --- a/mobile/mobile/StravaAuthManager.swift +++ b/mobile/mobile/StravaAuthManager.swift @@ -37,15 +37,9 @@ class StravaAuthManager: ObservableObject { } private func handleAuthorizationCode(_ code: String) { - appState.isLoading = true + appState.status = .loading Task { - defer { - DispatchQueue.main.async { - self.appState.isLoading = false - } - } - do { let url = URL(string: "https://lwg77yq7dd.execute-api.us-east-1.amazonaws.com/prod/signup")! var request = URLRequest(url: url) @@ -61,10 +55,11 @@ class StravaAuthManager: ObservableObject { if response.success { UserDefaults.standard.set(response.jwt_token, forKey: "jwt_token") DispatchQueue.main.async { - self.appState.isLoggedIn = true self.appState.jwtToken = response.jwt_token - if let isNewUser = response.is_new_user { - self.appState.isNewUser = isNewUser + if let isNewUser = response.is_new_user, isNewUser { + self.appState.status = .newUser + } else { + self.appState.status = .loggedIn } } } else { @@ -80,4 +75,3 @@ class StravaAuthManager: ObservableObject { } } - diff --git a/mobile/mobile/mobileApp.swift b/mobile/mobile/mobileApp.swift index a99ceee..16bf628 100644 --- a/mobile/mobile/mobileApp.swift +++ b/mobile/mobile/mobileApp.swift @@ -10,7 +10,7 @@ struct mobileApp: App { .environmentObject(appState) .onAppear { if let token = UserDefaults.standard.string(forKey: "jwt_token") { - appState.isLoggedIn = true + appState.status = .loggedIn appState.jwtToken = token } } @@ -23,12 +23,12 @@ struct mobileApp: App { DispatchQueue.main.async { switch result { case .success(let newToken): - appState.isLoggedIn = true + appState.status = .loggedIn appState.jwtToken = newToken UserDefaults.standard.set(newToken, forKey: "jwt_token") case .failure(_): // Token refresh failed, user needs to log in again - appState.isLoggedIn = false + appState.status = .loggedOut appState.jwtToken = nil UserDefaults.standard.removeObject(forKey: "jwt_token") } diff --git a/pyproject.toml b/pyproject.toml index 7b7b5b7..48de891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ postgrest = "^0.16.8" jinja2 = "^3.1.4" PyJWT = "^2.9.0" pyperclip = "^1.9.0" +arrow = "^1.3.0" [tool.poetry.group.dev.dependencies] ipykernel = "^6.29.4" diff --git a/src/auth_manager.py b/src/auth_manager.py index 792a2ba..5098337 100644 --- a/src/auth_manager.py +++ b/src/auth_manager.py @@ -151,7 +151,7 @@ def signup(user_auth: UserAuthRow, email: Optional[str] = None) -> dict: text_content=f"You have a new client {email=} attempting to signup with {preferences=}", ) upsert_user(UserRow(athlete_id=user_auth.athlete_id, preferences=preferences)) - return {"success": True, "jwt_token": user_auth.jwt_token} + return {"success": True, "jwt_token": user_auth.jwt_token, "is_new_user": True} def authenticate_on_signin(code: str, email: Optional[str] = None) -> dict: @@ -167,4 +167,8 @@ def authenticate_on_signin(code: str, email: Optional[str] = None) -> dict: if not user_exists(user_auth.athlete_id): return signup(user_auth, email) - return {"success": True, "jwt_token": user_auth.jwt_token} + return { + "success": True, + "jwt_token": user_auth.jwt_token, + "is_new_user": False, + } diff --git a/src/frontend_router.py b/src/frontend_router.py index bf9f2b3..e727dfe 100644 --- a/src/frontend_router.py +++ b/src/frontend_router.py @@ -12,6 +12,8 @@ get_user_auth, update_preferences, ) +from src.types.update_pipeline import ExeType +from src.update_pipeline import training_week_update_executor def get_training_week_handler(athlete_id: str, payload: dict) -> dict: @@ -67,9 +69,11 @@ def get_weekly_summaries_handler(athlete_id: str, payload: dict) -> dict: } -def generate_initial_training_plan_handler(athlete_id: str, payload: dict) -> dict: - """Handle generate_initial_training_plan request.""" - time.sleep(10) +def start_onboarding(athlete_id: str, payload: dict) -> dict: + """Handle start_onboarding request.""" + user = get_user(athlete_id) + training_week_update_executor(user, ExeType.NEW_WEEK, "onboarding-trigger") + training_week_update_executor(user, ExeType.MID_WEEK, "onboarding-trigger") return {"success": True} @@ -78,7 +82,7 @@ def generate_initial_training_plan_handler(athlete_id: str, payload: dict) -> di "get_profile": get_profile_handler, "update_preferences": update_preferences_handler, "get_weekly_summaries": get_weekly_summaries_handler, - "generate_initial_training_plan": generate_initial_training_plan_handler, + "start_onboarding": start_onboarding, } diff --git a/src/update_pipeline.py b/src/update_pipeline.py index 7b1dd0a..2f8fdc2 100644 --- a/src/update_pipeline.py +++ b/src/update_pipeline.py @@ -49,11 +49,13 @@ def training_week_update_pipeline( training_week = pipeline_function(user=user, strava_client=strava_client) upsert_training_week(user.athlete_id, training_week) - send_email( - to={"email": user.email, "name": f"{athlete.firstname} {athlete.lastname}"}, - subject=email_subject, - html_content=training_week_to_html(training_week), - ) + + if user.email: + send_email( + to={"email": user.email, "name": f"{athlete.firstname} {athlete.lastname}"}, + subject=email_subject, + html_content=training_week_to_html(training_week), + ) return training_week @@ -109,7 +111,7 @@ def webhook_executor(user: UserRow) -> dict: def training_week_update_executor( - user: UserRow, exetype: ExeType, invocation_id: str + user: UserRow, exetype: ExeType, invocation_id: str = "manual-trigger" ) -> dict: """ Decides between generating a new week or updating based on the day. diff --git a/test.ipynb b/test.ipynb index 0d8de22..d564f13 100644 --- a/test.ipynb +++ b/test.ipynb @@ -4,7 +4,16 @@ "cell_type": "code", "execution_count": 2, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], "source": [ "%load_ext autoreload\n", "%autoreload 2" @@ -12,7 +21,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -204,22 +213,60 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "UserRow(athlete_id=150403274, preferences=\"I'm looking to improve my running performance while being smart and realistic.\", email=None, preferences_json=Preferences(race_distance=None, ideal_training_week=None), is_active=True, created_at=datetime.datetime(2024, 10, 25, 19, 4, 51, 436181, tzinfo=datetime.timezone.utc))" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "# from src.types.update_pipeline import ExeType\n", - "# from src.update_pipeline import training_week_update_executor\n", - "\n", - "\n", - "# user = get_user()\n", - "# training_week_update_executor(\n", - "# user=user,\n", - "# exetype=ExeType.MID_WEEK,\n", - "# invocation_id=1234567890,\n", - "# )" + "user" ] }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'success': True}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'success': True}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [] + }, { "cell_type": "code", "execution_count": null,