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/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,