Skip to content

Commit

Permalink
mobile onboarding flow completed
Browse files Browse the repository at this point in the history
  • Loading branch information
voynow committed Oct 25, 2024
1 parent 0fc75ce commit 5cd4713
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 44 deletions.
6 changes: 4 additions & 2 deletions mobile/mobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<absolute>"; };
809F8EBF2CCBE0BE00D41481 /* mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand All @@ -27,6 +27,7 @@
LoadingView.swift,
mobileApp.swift,
Models.swift,
OnboardingView.swift,
PreferencesView.swift,
"Preview Content/Preview Assets.xcassets",
ProfileSkeletonView.swift,
Expand Down Expand Up @@ -57,6 +58,7 @@
isa = PBXGroup;
children = (
807A711A2CCB2DA30032BA17 /* mobile */,
809F8EBF2CCBE0BE00D41481 /* mobile.app */,
);
sourceTree = "<group>";
};
Expand All @@ -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 */
Expand Down
22 changes: 22 additions & 0 deletions mobile/mobile/APIManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,26 @@ class APIManager {
}
}.resume()
}

func startOnboarding(token: String, completion: @escaping (Result<Void, Error>) -> 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?
}
15 changes: 11 additions & 4 deletions mobile/mobile/AppState.swift
Original file line number Diff line number Diff line change
@@ -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")")
}
}
}
14 changes: 6 additions & 8 deletions mobile/mobile/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
5 changes: 4 additions & 1 deletion mobile/mobile/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ struct DashboardView: View {
Text("No training data available")
.font(.headline)
.foregroundColor(ColorTheme.lightGrey)
Button(action: handleLogout) {
Text("Logout")
}
}
}
}
Expand Down Expand Up @@ -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")
}
Expand Down
7 changes: 7 additions & 0 deletions mobile/mobile/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,10 @@ struct WeeklySummariesResponse: Codable {
struct GenerateTrainingPlanResponse: Codable {
let success: Bool
}

enum AppStateStatus {
case loading
case loggedOut
case loggedIn
case newUser
}
144 changes: 144 additions & 0 deletions mobile/mobile/OnboardingView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
2 changes: 1 addition & 1 deletion mobile/mobile/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 5 additions & 11 deletions mobile/mobile/StravaAuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -80,4 +75,3 @@ class StravaAuthManager: ObservableObject {
}

}

6 changes: 3 additions & 3 deletions mobile/mobile/mobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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")
}
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading

0 comments on commit 5cd4713

Please sign in to comment.