From 6eff5d1278acee4c0975d36d56fa0864827a6fd0 Mon Sep 17 00:00:00 2001 From: Aprameya Kannan Date: Tue, 20 Jan 2026 23:24:13 -0500 Subject: [PATCH] auth: --- backend/src/routes/auth.ts | 19 +- backend/src/services/auth.service.ts | 2 +- backend/src/services/supabase.service.ts | 5 +- frontend/MusicApp/Info.plist | 18 +- frontend/MusicApp/Models/AuthToken.swift | 18 +- frontend/MusicApp/Models/User.swift | 8 + frontend/MusicApp/MusicAppApp.swift | 38 +--- frontend/MusicApp/Services/APIService.swift | 30 +-- frontend/MusicApp/Services/AuthService.swift | 66 +++---- frontend/MusicApp/Services/OAuthService.swift | 81 -------- .../MusicApp/ViewModels/AuthViewModel.swift | 184 +++++++++++------- .../ViewModels/ProfileEditViewModel.swift | 134 +++++++++++++ .../Views/Auth/AppleSignInButton.swift | 28 --- .../Views/Auth/GoogleSignInButton.swift | 59 ------ frontend/MusicApp/Views/Auth/LoginView.swift | 132 +++++++------ .../Views/Auth/OAuthCallbackHandler.swift | 80 -------- frontend/MusicApp/Views/Auth/SignupView.swift | 117 ++++++++--- .../Views/Auth/SpotifySignInButton.swift | 41 ---- frontend/MusicApp/Views/HomeFeedView.swift | 3 +- frontend/MusicApp/Views/OnboardingView.swift | 34 +--- frontend/MusicApp/Views/ProfileView.swift | 150 +++++++++++++- frontend/Podfile | 11 ++ frontend/a.xcodeproj/project.pbxproj | 31 ++- .../xcshareddata/xcschemes/MusicApp.xcscheme | 10 +- 24 files changed, 690 insertions(+), 609 deletions(-) delete mode 100644 frontend/MusicApp/Services/OAuthService.swift create mode 100644 frontend/MusicApp/ViewModels/ProfileEditViewModel.swift delete mode 100644 frontend/MusicApp/Views/Auth/AppleSignInButton.swift delete mode 100644 frontend/MusicApp/Views/Auth/GoogleSignInButton.swift delete mode 100644 frontend/MusicApp/Views/Auth/OAuthCallbackHandler.swift delete mode 100644 frontend/MusicApp/Views/Auth/SpotifySignInButton.swift create mode 100644 frontend/Podfile diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index d9f05a5..7876aaa 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -19,7 +19,7 @@ router.post( res.json({ success: true, - message: 'User created successfully. Please check your email to verify your account.' + message: 'User created successfully' }); } catch (error) { next(error); @@ -208,20 +208,7 @@ router.post( success: false, error: { code: '400', - message: 'Verification code is required' - } - }); - return; - } - - const { user } = await supabaseService.verifyOtp(code, 'signup'); - - if (!user) { - res.status(400).json({ - success: false, - error: { - code: '400', - message: 'Invalid or expired verification code' + message: 'Code is required' } }); return; @@ -229,7 +216,7 @@ router.post( res.json({ success: true, - message: 'Email verified successfully' + message: 'Code processed successfully' }); } catch (error) { next(error); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index b1d9e0a..96cdfc0 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -76,7 +76,7 @@ export class AuthService { `INSERT INTO users (email, username, password_hash, role, first_name, last_name, supabase_auth_id, email_verified) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, role, created_at, updated_at`, - [email.toLowerCase(), username, null, 'user', firstName, lastName, supabaseAuthId, false] // Set email_verified to false initially + [email.toLowerCase(), username, null, 'user', firstName, lastName, supabaseAuthId, true] ); const user = result.rows[0]; diff --git a/backend/src/services/supabase.service.ts b/backend/src/services/supabase.service.ts index 07eefc4..79d9aa8 100644 --- a/backend/src/services/supabase.service.ts +++ b/backend/src/services/supabase.service.ts @@ -77,7 +77,7 @@ class SupabaseService { const { data, error } = await this.client.auth.admin.createUser({ email, password, - email_confirm: false, // Supabase will send verification email if configured + email_confirm: true, user_metadata: { first_name: metadata.first_name || '', last_name: metadata.last_name || '' @@ -140,9 +140,6 @@ class SupabaseService { if (error.message.includes('Invalid login credentials') || error.message.includes('Invalid')) { throw new CustomError('Invalid email or password', 401); } - if (error.message.includes('Email not confirmed') || error.message.includes('not confirmed')) { - throw new CustomError('Please verify your email address', 403); - } throw new CustomError(`Login failed: ${error.message}`, 401); } diff --git a/frontend/MusicApp/Info.plist b/frontend/MusicApp/Info.plist index 12286f0..710265a 100644 --- a/frontend/MusicApp/Info.plist +++ b/frontend/MusicApp/Info.plist @@ -48,19 +48,25 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + musiq + + + NSAppTransportSecurity - NSAllowsLocalNetworking - NSAllowsArbitraryLoadsInWebContent - NSExceptionDomains - localhost NSExceptionAllowsInsecureHTTPLoads @@ -68,13 +74,11 @@ NSIncludesSubdomains - 192.168.1.244 NSExceptionAllowsInsecureHTTPLoads - 192.168.86.133 NSExceptionAllowsInsecureHTTPLoads diff --git a/frontend/MusicApp/Models/AuthToken.swift b/frontend/MusicApp/Models/AuthToken.swift index aa18240..e0995e0 100644 --- a/frontend/MusicApp/Models/AuthToken.swift +++ b/frontend/MusicApp/Models/AuthToken.swift @@ -3,34 +3,32 @@ import Foundation struct AuthToken: Codable { let accessToken: String let refreshToken: String - let expiresIn: Int + let expiresIn: Int let tokenType: String + let emailVerified: Bool? enum CodingKeys: String, CodingKey { case accessToken case refreshToken case expiresIn case tokenType + case emailVerified } } struct LoginRequest: Codable { - let username: String + let email: String let password: String } struct SignupRequest: Codable { + let email: String let username: String let password: String - let confirmPassword: String + let firstName: String + let lastName: String } struct RefreshTokenRequest: Codable { let refreshToken: String -} - -struct OAuthRequest: Codable { - let provider: String - let token: String - let idToken: String? -} +} \ No newline at end of file diff --git a/frontend/MusicApp/Models/User.swift b/frontend/MusicApp/Models/User.swift index 2810b99..2fe7182 100644 --- a/frontend/MusicApp/Models/User.swift +++ b/frontend/MusicApp/Models/User.swift @@ -20,6 +20,8 @@ struct User: Identifiable, Codable { let role: UserRole let oauthProvider: OAuthProvider? let oauthId: String? + let firstName: String? + let lastName: String? let lastLoginAt: Date? let createdAt: Date let updatedAt: Date @@ -33,6 +35,8 @@ struct User: Identifiable, Codable { case role case oauthProvider = "oauth_provider" case oauthId = "oauth_id" + case firstName = "first_name" + case lastName = "last_name" case lastLoginAt = "last_login_at" case createdAt = "created_at" case updatedAt = "updated_at" @@ -48,6 +52,8 @@ struct User: Identifiable, Codable { role = try container.decode(UserRole.self, forKey: .role) oauthProvider = try container.decodeIfPresent(OAuthProvider.self, forKey: .oauthProvider) oauthId = try container.decodeIfPresent(String.self, forKey: .oauthId) + firstName = try container.decodeIfPresent(String.self, forKey: .firstName) + lastName = try container.decodeIfPresent(String.self, forKey: .lastName) if let lastLoginString = try? container.decode(String.self, forKey: .lastLoginAt) { let formatter = ISO8601DateFormatter() @@ -75,6 +81,8 @@ struct User: Identifiable, Codable { try container.encode(role, forKey: .role) try container.encodeIfPresent(oauthProvider, forKey: .oauthProvider) try container.encodeIfPresent(oauthId, forKey: .oauthId) + try container.encodeIfPresent(firstName, forKey: .firstName) + try container.encodeIfPresent(lastName, forKey: .lastName) let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/frontend/MusicApp/MusicAppApp.swift b/frontend/MusicApp/MusicAppApp.swift index 51a8f21..302cd8d 100644 --- a/frontend/MusicApp/MusicAppApp.swift +++ b/frontend/MusicApp/MusicAppApp.swift @@ -8,44 +8,8 @@ struct MusIQApp: App { WindowGroup { ContentView() .environmentObject(appState) - .onOpenURL { url in - - handleOAuthCallback(url: url) - } } } - private func handleOAuthCallback(url: URL) { - - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - - guard let host = url.host, - let queryItems = components?.queryItems, - let code = queryItems.first(where: { $0.name == "code" })?.value else { - return - } - - var provider: OAuthProviderType? - if host.contains("google") { - provider = .google - } else if host.contains("apple") { - provider = .apple - } - - guard let provider = provider else { - return - } - - let idToken = queryItems.first(where: { $0.name == "id_token" })?.value - - NotificationCenter.default.post( - name: NSNotification.Name("OAuthCallback"), - object: nil, - userInfo: [ - "provider": provider.rawValue, - "code": code, - "idToken": idToken as Any - ] - ) - } + } diff --git a/frontend/MusicApp/Services/APIService.swift b/frontend/MusicApp/Services/APIService.swift index cfd9e48..c128e89 100644 --- a/frontend/MusicApp/Services/APIService.swift +++ b/frontend/MusicApp/Services/APIService.swift @@ -51,8 +51,7 @@ class APIService { throw NetworkError.unknown(NSError(domain: "APIService", code: -1)) } - switch httpResponse.statusCode { - case 200...299: + if (200...299).contains(httpResponse.statusCode) { do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -64,17 +63,24 @@ class APIService { print("Decoding Error Details: \(decodingError)") throw NetworkError.unknown(decodingError) } - case 401: - throw NetworkError.unauthorized - case 403: - throw NetworkError.forbidden - case 404: - throw NetworkError.notFound - case 500...599: - throw NetworkError.serverError(httpResponse.statusCode) - default: - throw NetworkError.serverError(httpResponse.statusCode) } + + var message: String = "Server error: \(httpResponse.statusCode)" + if !data.isEmpty { + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), + let json = jsonObject as? [String: Any] { + if let errorDict = json["error"] as? [String: Any], + let serverMessage = errorDict["message"] as? String, + !serverMessage.isEmpty { + message = serverMessage + } else if let serverMessage = json["message"] as? String, + !serverMessage.isEmpty { + message = serverMessage + } + } + } + + throw NetworkError.unknown(NSError(domain: "APIService", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: message])) } catch let error as NetworkError { throw error } catch { diff --git a/frontend/MusicApp/Services/AuthService.swift b/frontend/MusicApp/Services/AuthService.swift index 4d54585..33a1a00 100644 --- a/frontend/MusicApp/Services/AuthService.swift +++ b/frontend/MusicApp/Services/AuthService.swift @@ -1,48 +1,47 @@ import Foundation +import Supabase class AuthService { + private let supabase = SupabaseClient(supabaseURL: URL(string: "https://mehxapfmnzalknthnzpy.supabase.co")!, supabaseKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1laHhhcGZtbnphbGtudGhuenB5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg1MzAwMTAsImV4cCI6MjA4NDEwNjAxMH0.AIwgjmaPgoIm87iHu_ugzx0mTc1wD9TekIH3_Z0M7gQ") private let apiService = APIService.shared - - func login(request: LoginRequest) async throws -> AuthToken { + + func signup(email: String, password: String, firstName: String, lastName: String, username: String) async throws { do { - let response: APIResponse = try await apiService.request( - endpoint: "/auth/login", + let response: APIResponse = try await apiService.request( + endpoint: "/auth/signup", method: .post, - body: request, + body: SignupRequest(email: email, username: username, password: password, firstName: firstName, lastName: lastName), requiresAuth: false ) - - guard response.success, let data = response.data else { + + guard response.success else { if let error = response.error { - throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 401, userInfo: [NSLocalizedDescriptionKey: error.message])) + throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 400, userInfo: [NSLocalizedDescriptionKey: error.message])) } - throw NetworkError.unauthorized + throw NetworkError.unknown(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Signup failed"])) } - - return data } catch let error as NetworkError { throw error } catch { throw NetworkError.unknown(error) } } - - func signup(request: SignupRequest) async throws -> AuthToken { + + func login(email: String, password: String) async throws -> AuthToken { do { let response: APIResponse = try await apiService.request( - endpoint: "/auth/signup", + endpoint: "/auth/login", method: .post, - body: request, + body: LoginRequest(email: email, password: password), requiresAuth: false ) - + guard response.success, let data = response.data else { if let error = response.error { - throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 400, userInfo: [NSLocalizedDescriptionKey: error.message])) + throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 401, userInfo: [NSLocalizedDescriptionKey: error.message])) } - throw NetworkError.unknown(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Signup failed"])) + throw NetworkError.unauthorized } - return data } catch let error as NetworkError { throw error @@ -50,21 +49,16 @@ class AuthService { throw NetworkError.unknown(error) } } - - func refreshToken(request: RefreshTokenRequest) async throws -> AuthToken { - let response: APIResponse = try await apiService.request( - endpoint: "/auth/refresh", - method: .post, - body: request, - requiresAuth: false - ) - - guard response.success, let data = response.data else { - throw NetworkError.unauthorized + + func forgotPassword(email: String) async throws { + do { + try await supabase.auth.resetPasswordForEmail(email) + } catch { + throw NetworkError.unknown(error) } - - return data } + + func getCurrentUser() async throws -> User { let response: APIResponse = try await apiService.request( @@ -80,13 +74,7 @@ class AuthService { } func logout() async throws { - _ = try await apiService.request( - endpoint: "/auth/logout", - method: .post, - body: EmptyBody(), - requiresAuth: true - ) as APIResponse - + try await supabase.auth.signOut() KeychainHelper.clearAll() } } diff --git a/frontend/MusicApp/Services/OAuthService.swift b/frontend/MusicApp/Services/OAuthService.swift deleted file mode 100644 index fb112bc..0000000 --- a/frontend/MusicApp/Services/OAuthService.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import AuthenticationServices -#if canImport(AppAuth) -import AppAuth -#endif - -enum OAuthProviderType { - case apple - case google - - var rawValue: String { - switch self { - case .apple: return "apple" - case .google: return "google" - } - } -} - -class OAuthService { - private let authService: AuthService - - init(authService: AuthService = AuthService()) { - self.authService = authService - } - - func signInWithGoogle() async throws -> AuthToken { - throw NetworkError.unauthorized - } - - func handleOAuthCallback(authorizationCode: String, provider: OAuthProviderType, idToken: String? = nil, email: String? = nil, name: String? = nil, userIdentifier: String? = nil) async throws -> AuthToken { - - var requestBody: [String: Any] = [ - "token": authorizationCode - ] - - if let idToken = idToken { - requestBody["idToken"] = idToken - } - - if let email = email { - requestBody["email"] = email - } - - if let name = name { - requestBody["name"] = name - } - - if let userIdentifier = userIdentifier { - requestBody["userIdentifier"] = userIdentifier - } - - struct OAuthRequestBody: Codable { - let token: String - let idToken: String? - let email: String? - let name: String? - let userIdentifier: String? - } - - let request = OAuthRequestBody( - token: authorizationCode, - idToken: idToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - - let response: APIResponse = try await APIService.shared.request( - endpoint: "/auth/oauth/\(provider.rawValue)", - method: .post, - body: request, - requiresAuth: false - ) - - guard response.success, let data = response.data else { - throw NetworkError.unauthorized - } - - return data - } -} diff --git a/frontend/MusicApp/ViewModels/AuthViewModel.swift b/frontend/MusicApp/ViewModels/AuthViewModel.swift index 03df026..db467af 100644 --- a/frontend/MusicApp/ViewModels/AuthViewModel.swift +++ b/frontend/MusicApp/ViewModels/AuthViewModel.swift @@ -3,6 +3,9 @@ import SwiftUI import Combine class AuthViewModel: ObservableObject { + @Published var email: String = "" + @Published var firstName: String = "" + @Published var lastName: String = "" @Published var username: String = "" @Published var password: String = "" @Published var confirmPassword: String = "" @@ -10,11 +13,52 @@ class AuthViewModel: ObservableObject { @Published var errorMessage: String? @Published var isAuthenticated: Bool = false @Published var passwordErrors: [String] = [] + @Published var emailError: String? + @Published var firstNameError: String? + @Published var lastNameError: String? private let authService: AuthService init(authService: AuthService = AuthService()) { self.authService = authService + + $email + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .map { email in + if email.isEmpty { + return nil + } + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + return emailPredicate.evaluate(with: email) ? nil : "Invalid email format" + } + .assign(to: &$emailError) + + $firstName + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .map { firstName in + if firstName.isEmpty { + return nil + } + if firstName.count < 1 || firstName.count > 50 { + return "First name must be between 1 and 50 characters" + } + return nil + } + .assign(to: &$firstNameError) + + $lastName + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .map { lastName in + if lastName.isEmpty { + return nil + } + if lastName.count < 1 || lastName.count > 50 { + return "Last name must be between 1 and 50 characters" + } + return nil + } + .assign(to: &$lastNameError) } func validatePassword(_ password: String) -> [String] { @@ -44,15 +88,14 @@ class AuthViewModel: ObservableObject { isLoading = true errorMessage = nil - guard !username.isEmpty, !password.isEmpty else { - errorMessage = "Username and password are required" + guard !email.isEmpty, !password.isEmpty else { + errorMessage = "Email and password are required" isLoading = false return } do { - let request = LoginRequest(username: username, password: password) - let token = try await authService.login(request: request) + let token = try await authService.login(email: email, password: password) KeychainHelper.store(token: token.accessToken, forKey: "accessToken") KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") @@ -60,8 +103,7 @@ class AuthViewModel: ObservableObject { let user = try await authService.getCurrentUser() isAuthenticated = true - let appState = getAppState() - appState.authenticate(user: user) + getAppState().authenticate(user: user) } catch { errorMessage = error.localizedDescription } @@ -73,21 +115,57 @@ class AuthViewModel: ObservableObject { isLoading = true errorMessage = nil passwordErrors = [] + emailError = nil + firstNameError = nil + lastNameError = nil - if username.count < 3 || username.count > 30 { - errorMessage = "Username must be between 3 and 30 characters" + if email.isEmpty { + emailError = "Email is required" + errorMessage = "Please fill in all required fields" isLoading = false return } - if !username.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) { - errorMessage = "Username can only contain letters, numbers, and underscores" + if emailError != nil { + errorMessage = "Please fix email validation errors" + isLoading = false + return + } + + if firstName.isEmpty { + firstNameError = "First name is required" + errorMessage = "Please fill in all required fields" + isLoading = false + return + } + + if firstNameError != nil { + errorMessage = "Please fix first name validation errors" + isLoading = false + return + } + + if lastName.isEmpty { + lastNameError = "Last name is required" + errorMessage = "Please fill in all required fields" + isLoading = false + return + } + + if lastNameError != nil { + errorMessage = "Please fix last name validation errors" + isLoading = false + return + } + + if username.count < 3 || username.count > 30 { + errorMessage = "Username must be between 3 and 30 characters" isLoading = false return } - if password != confirmPassword { - errorMessage = "Passwords do not match" + if !username.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) { + errorMessage = "Username can only contain letters, numbers, and underscores" isLoading = false return } @@ -100,48 +178,20 @@ class AuthViewModel: ObservableObject { return } - do { - let request = SignupRequest(username: username, password: password, confirmPassword: confirmPassword) - let token = try await authService.signup(request: request) - - KeychainHelper.store(token: token.accessToken, forKey: "accessToken") - KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") - - let user = try await authService.getCurrentUser() - isAuthenticated = true - - let appState = getAppState() - appState.authenticate(user: user) - } catch { - errorMessage = error.localizedDescription + guard password == confirmPassword else { + errorMessage = "Passwords do not match." + isLoading = false + return } - - isLoading = false - } - - func loginWithApple(authorizationCode: String, identityToken: String?, email: String? = nil, name: String? = nil, userIdentifier: String? = nil) async { - isLoading = true - errorMessage = nil - + do { - let oauthService = OAuthService(authService: authService) - let token = try await oauthService.handleOAuthCallback( - authorizationCode: authorizationCode, - provider: .apple, - idToken: identityToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - - KeychainHelper.store(token: token.accessToken, forKey: "accessToken") - KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") - - let user = try await authService.getCurrentUser() - isAuthenticated = true + try await authService.signup(email: email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + password: password, + firstName: firstName.trimmingCharacters(in: .whitespacesAndNewlines), + lastName: lastName.trimmingCharacters(in: .whitespacesAndNewlines), + username: username) - let appState = getAppState() - appState.authenticate(user: user) + errorMessage = "Signup successful. You can now log in." } catch { errorMessage = error.localizedDescription } @@ -149,36 +199,28 @@ class AuthViewModel: ObservableObject { isLoading = false } - func loginWithGoogle(authorizationCode: String, idToken: String?) async { + func forgotPassword() async { isLoading = true errorMessage = nil - + + guard !email.isEmpty else { + errorMessage = "Email is required." + isLoading = false + return + } + do { - let oauthService = OAuthService(authService: authService) - let token = try await oauthService.handleOAuthCallback( - authorizationCode: authorizationCode, - provider: .google, - idToken: idToken - ) - - KeychainHelper.store(token: token.accessToken, forKey: "accessToken") - KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") - - let user = try await authService.getCurrentUser() - isAuthenticated = true - - let appState = getAppState() - appState.authenticate(user: user) + try await authService.forgotPassword(email: email) + errorMessage = "If an account with that email exists, a password reset link has been sent." } catch { errorMessage = error.localizedDescription } - + isLoading = false } - + // Helper to get shared AppState private func getAppState() -> AppState { - return AppState.shared } } diff --git a/frontend/MusicApp/ViewModels/ProfileEditViewModel.swift b/frontend/MusicApp/ViewModels/ProfileEditViewModel.swift new file mode 100644 index 0000000..a04e3d1 --- /dev/null +++ b/frontend/MusicApp/ViewModels/ProfileEditViewModel.swift @@ -0,0 +1,134 @@ +import Foundation +import SwiftUI +import Combine + +@MainActor +class ProfileEditViewModel: ObservableObject { + @Published var email: String = "" + @Published var firstName: String = "" + @Published var lastName: String = "" + @Published var username: String = "" + @Published var isUpdating: Bool = false + @Published var updateError: String? + @Published var emailError: String? + @Published var firstNameError: String? + @Published var lastNameError: String? + + private let authService: AuthService + private let apiService = APIService.shared + private var originalEmail: String = "" + + init(authService: AuthService = AuthService()) { + self.authService = authService + + $email + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .map { email in + if email.isEmpty { + return "Email is required" + } + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + return emailPredicate.evaluate(with: email) ? nil : "Invalid email format" + } + .assign(to: &$emailError) + + $firstName + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .map { firstName in + if firstName.isEmpty { + return "First name is required" + } + if firstName.count < 1 || firstName.count > 50 { + return "First name must be between 1 and 50 characters" + } + return nil + } + .assign(to: &$firstNameError) + + $lastName + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .map { lastName in + if lastName.isEmpty { + return "Last name is required" + } + if lastName.count < 1 || lastName.count > 50 { + return "Last name must be between 1 and 50 characters" + } + return nil + } + .assign(to: &$lastNameError) + } + + func loadProfile(user: User) { + email = user.email + originalEmail = user.email + firstName = user.firstName ?? "" + lastName = user.lastName ?? "" + username = user.username + } + + var hasChanges: Bool { + email != originalEmail || !firstName.isEmpty || !lastName.isEmpty + } + + var canSave: Bool { + emailError == nil && firstNameError == nil && lastNameError == nil && hasChanges && !isUpdating + } + + func updateProfile() async -> Bool { + guard canSave else { + updateError = "Please fix validation errors" + return false + } + + isUpdating = true + updateError = nil + + do { + struct UpdateProfileRequest: Codable { + let email: String? + let firstName: String? + let lastName: String? + } + + let request = UpdateProfileRequest( + email: email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != originalEmail.lowercased() ? email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() : nil, + firstName: firstName.trimmingCharacters(in: .whitespacesAndNewlines), + lastName: lastName.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + struct ProfileUpdateResponse: Codable { + let success: Bool + let data: User? + let message: String? + let error: APIError? + } + + let response: ProfileUpdateResponse = try await apiService.request( + endpoint: "/profile", + method: .put, + body: request, + requiresAuth: true + ) + + guard response.success, let updatedUser = response.data else { + if let error = response.error { + updateError = error.message + } else { + updateError = "Failed to update profile" + } + isUpdating = false + return false + } + + originalEmail = updatedUser.email + isUpdating = false + return true + } catch { + updateError = error.localizedDescription + isUpdating = false + return false + } + } +} diff --git a/frontend/MusicApp/Views/Auth/AppleSignInButton.swift b/frontend/MusicApp/Views/Auth/AppleSignInButton.swift deleted file mode 100644 index 1c3842c..0000000 --- a/frontend/MusicApp/Views/Auth/AppleSignInButton.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI -import AuthenticationServices - -struct AppleSignInButton: View { - let onSuccess: (String, String?) -> Void - let onError: (Error) -> Void - - var body: some View { - SignInWithAppleButton( - onRequest: { request in - request.requestedScopes = [.fullName, .email] - }, - onCompletion: { result in - - } - ) - .signInWithAppleButtonStyle(.black) - .frame(height: 50) - .cornerRadius(AppStyles.cornerRadiusMedium) - } -} - -#Preview { - AppleSignInButton( - onSuccess: { _, _ in }, - onError: { _ in } - ) -} diff --git a/frontend/MusicApp/Views/Auth/GoogleSignInButton.swift b/frontend/MusicApp/Views/Auth/GoogleSignInButton.swift deleted file mode 100644 index 6c58397..0000000 --- a/frontend/MusicApp/Views/Auth/GoogleSignInButton.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI -#if canImport(AppAuth) -import AppAuth -#endif - -struct GoogleSignInButton: View { - let onSuccess: (String, String?) -> Void - let onError: (Error) -> Void - - var body: some View { - Button(action: { - Task { - await performGoogleSignIn() - } - }) { - HStack { - Image(systemName: "globe") - .font(.system(size: 18)) - Text("Continue with Google") - .font(.system(size: 16, weight: .medium)) - } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(red: 0.26, green: 0.52, blue: 0.96)) - .cornerRadius(AppStyles.cornerRadiusMedium) - } - } - - @MainActor - private func performGoogleSignIn() async { - - } -} - -#if canImport(AppAuth) -extension OIDAuthorizationService { - static func present(_ request: OIDAuthorizationRequest, presenting: UIViewController) async throws -> OIDAuthState { - return try await withCheckedThrowingContinuation { continuation in - let authFlow = OIDAuthState.authState(byPresenting: request, presenting: presenting) { authState, error in - if let error = error { - continuation.resume(throwing: error) - } else if let authState = authState { - continuation.resume(returning: authState) - } else { - continuation.resume(throwing: NSError(domain: "OAuth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown error"])) - } - } - } - } -} -#endif - -#Preview { - GoogleSignInButton( - onSuccess: { _, _ in }, - onError: { _ in } - ) -} diff --git a/frontend/MusicApp/Views/Auth/LoginView.swift b/frontend/MusicApp/Views/Auth/LoginView.swift index 0e958d3..7a1049c 100644 --- a/frontend/MusicApp/Views/Auth/LoginView.swift +++ b/frontend/MusicApp/Views/Auth/LoginView.swift @@ -27,65 +27,81 @@ struct LoginView: View { .font(.system(size: 32, weight: .bold)) .foregroundColor(AppColors.textPrimary) - VStack(spacing: 20) { - VStack(alignment: .leading, spacing: 8) { - Text("Username") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - - TextField("", text: $viewModel.username) - .textFieldStyle(PlainTextFieldStyle()) - .padding() - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .foregroundColor(AppColors.textPrimary) - .autocapitalization(.none) - } - - VStack(alignment: .leading, spacing: 8) { - Text("Password") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppColors.textSecondary) - - SecureField("", text: $viewModel.password) - .textFieldStyle(PlainTextFieldStyle()) - .padding() - .background(AppColors.cardBackground) - .cornerRadius(AppStyles.cornerRadiusMedium) - .overlay( - RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) - .stroke(AppColors.border, lineWidth: 1) - ) - .foregroundColor(AppColors.textPrimary) - } - - if let error = viewModel.errorMessage { - Text(error) - .font(.system(size: 14)) - .foregroundColor(AppColors.accent) - .frame(maxWidth: .infinity, alignment: .leading) - } + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Email") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.email) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(viewModel.emailError != nil ? AppColors.accent : AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.none) + .keyboardType(.emailAddress) + + if let emailError = viewModel.emailError { + Text(emailError) + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + SecureField("", text: $viewModel.password) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + } - Button(action: { - Task { - await viewModel.login() - } - }) { - if viewModel.isLoading { - ProgressView() - .tint(.white) - } else { - Text("Log In") - .font(.system(size: 16, weight: .semibold)) - } - } - .gradientButton(isEnabled: !viewModel.isLoading) - .disabled(viewModel.isLoading) - } + Button(action: { + Task { + await viewModel.login() + } + }) { + if viewModel.isLoading { + ProgressView() + .tint(.white) + } else { + Text("Log In") + .font(.system(size: 16, weight: .semibold)) + } + } + .gradientButton(isEnabled: !viewModel.isLoading) + .disabled(viewModel.isLoading) + + Button("Forgot Password?") { + Task { + await viewModel.forgotPassword() + } + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + .padding(.top, 5) + + if let error = viewModel.errorMessage { + Text(error) + .font(.system(size: 14)) + .foregroundColor(AppColors.accent) + .frame(maxWidth: .infinity, alignment: .leading) + } + } .padding(.horizontal, AppStyles.paddingLarge) diff --git a/frontend/MusicApp/Views/Auth/OAuthCallbackHandler.swift b/frontend/MusicApp/Views/Auth/OAuthCallbackHandler.swift deleted file mode 100644 index 78d4b7d..0000000 --- a/frontend/MusicApp/Views/Auth/OAuthCallbackHandler.swift +++ /dev/null @@ -1,80 +0,0 @@ -import SwiftUI -import Combine - -struct OAuthCallbackHandler: ViewModifier { - @ObservedObject var viewModel: AuthViewModel - @State private var cancellables = Set() - - func body(content: Content) -> some View { - content - .onAppear { - - NotificationCenter.default.publisher(for: NSNotification.Name("OAuthCallback")) - .sink { notification in - guard let userInfo = notification.userInfo, - let providerString = userInfo["provider"] as? String, - let code = userInfo["code"] as? String else { - return - } - - let idToken = userInfo["idToken"] as? String - - Task { - switch providerString { - case "apple": - let email = userInfo["email"] as? String - let name = userInfo["name"] as? String - let userIdentifier = userInfo["userIdentifier"] as? String - await viewModel.loginWithApple( - authorizationCode: code, - identityToken: idToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - case "google": - let email = userInfo["email"] as? String - let name = userInfo["name"] as? String - await viewModel.loginWithGoogle( - authorizationCode: code, - idToken: idToken - ) - default: - break - } - } - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: NSNotification.Name("AppleSignInSuccess")) - .sink { notification in - guard let userInfo = notification.userInfo, - let code = userInfo["code"] as? String else { - return - } - - let idToken = userInfo["idToken"] as? String - let email = userInfo["email"] as? String - let name = userInfo["name"] as? String - let userIdentifier = userInfo["userIdentifier"] as? String - - Task { - await viewModel.loginWithApple( - authorizationCode: code, - identityToken: idToken, - email: email, - name: name, - userIdentifier: userIdentifier - ) - } - } - .store(in: &cancellables) - } - } -} - -extension View { - func handleOAuthCallbacks(viewModel: AuthViewModel) -> some View { - modifier(OAuthCallbackHandler(viewModel: viewModel)) - } -} diff --git a/frontend/MusicApp/Views/Auth/SignupView.swift b/frontend/MusicApp/Views/Auth/SignupView.swift index 37a0337..8e987b1 100644 --- a/frontend/MusicApp/Views/Auth/SignupView.swift +++ b/frontend/MusicApp/Views/Auth/SignupView.swift @@ -28,6 +28,91 @@ struct SignupView: View { .foregroundColor(AppColors.textPrimary) VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Email") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.email) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(viewModel.emailError != nil ? AppColors.accent : AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.none) + .keyboardType(.emailAddress) + + if let emailError = viewModel.emailError { + Text(emailError) + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } else if !viewModel.email.isEmpty && viewModel.emailError == nil { + Text("Valid email") + .font(.system(size: 11)) + .foregroundColor(.green) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("First Name") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.firstName) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(viewModel.firstNameError != nil ? AppColors.accent : AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.words) + + if let firstNameError = viewModel.firstNameError { + Text(firstNameError) + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } else if !viewModel.firstName.isEmpty && viewModel.firstNameError == nil { + Text("Valid") + .font(.system(size: 11)) + .foregroundColor(.green) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Last Name") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.lastName) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(viewModel.lastNameError != nil ? AppColors.accent : AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.words) + + if let lastNameError = viewModel.lastNameError { + Text(lastNameError) + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } else if !viewModel.lastName.isEmpty && viewModel.lastNameError == nil { + Text("Valid") + .font(.system(size: 11)) + .foregroundColor(.green) + } + } + VStack(alignment: .leading, spacing: 8) { Text("Username") .font(.system(size: 14, weight: .medium)) @@ -54,7 +139,7 @@ struct SignupView: View { Text("Password") .font(.system(size: 14, weight: .medium)) .foregroundColor(AppColors.textSecondary) - + SecureField("", text: $viewModel.password) .textFieldStyle(PlainTextFieldStyle()) .padding() @@ -68,7 +153,7 @@ struct SignupView: View { .onChange(of: viewModel.password) { newValue in viewModel.passwordErrors = viewModel.validatePassword(newValue) } - + if !viewModel.passwordErrors.isEmpty { VStack(alignment: .leading, spacing: 4) { ForEach(viewModel.passwordErrors, id: \.self) { error in @@ -77,18 +162,18 @@ struct SignupView: View { .foregroundColor(AppColors.accent) } } - } else if !viewModel.password.isEmpty { - Text("✓ Password meets all requirements") + } else if !viewModel.password.isEmpty && viewModel.passwordErrors.isEmpty { + Text("Password meets all requirements") .font(.system(size: 11)) .foregroundColor(.green) } } - + VStack(alignment: .leading, spacing: 8) { Text("Confirm Password") .font(.system(size: 14, weight: .medium)) .foregroundColor(AppColors.textSecondary) - + SecureField("", text: $viewModel.confirmPassword) .textFieldStyle(PlainTextFieldStyle()) .padding() @@ -99,27 +184,15 @@ struct SignupView: View { .stroke(AppColors.border, lineWidth: 1) ) .foregroundColor(AppColors.textPrimary) - - if !viewModel.confirmPassword.isEmpty { - if viewModel.password == viewModel.confirmPassword { - Text("✓ Passwords match") - .font(.system(size: 11)) - .foregroundColor(.green) - } else { - Text("Passwords do not match") - .font(.system(size: 11)) - .foregroundColor(AppColors.accent) - } - } } - if let error = viewModel.errorMessage { - Text(error) + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) .font(.system(size: 14)) .foregroundColor(AppColors.accent) .frame(maxWidth: .infinity, alignment: .leading) } - + Button(action: { Task { await viewModel.signup() @@ -133,7 +206,7 @@ struct SignupView: View { .font(.system(size: 16, weight: .semibold)) } } - .gradientButton(isEnabled: !viewModel.isLoading) + .gradientButton(isEnabled: !viewModel.isLoading && viewModel.emailError == nil && viewModel.firstNameError == nil && viewModel.lastNameError == nil && viewModel.passwordErrors.isEmpty && viewModel.password == viewModel.confirmPassword) .disabled(viewModel.isLoading) } .padding(.horizontal, AppStyles.paddingLarge) diff --git a/frontend/MusicApp/Views/Auth/SpotifySignInButton.swift b/frontend/MusicApp/Views/Auth/SpotifySignInButton.swift deleted file mode 100644 index 3456c07..0000000 --- a/frontend/MusicApp/Views/Auth/SpotifySignInButton.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI -#if canImport(AppAuth) -import AppAuth -#endif - -struct SpotifySignInButton: View { - let onSuccess: (String) -> Void - let onError: (Error) -> Void - - var body: some View { - Button(action: { - Task { - await performSpotifySignIn() - } - }) { - HStack { - Image(systemName: "music.note") - .font(.system(size: 18)) - Text("Continue with Spotify") - .font(.system(size: 16, weight: .medium)) - } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(red: 0.12, green: 0.73, blue: 0.33)) - .cornerRadius(AppStyles.cornerRadiusMedium) - } - } - - @MainActor - private func performSpotifySignIn() async { - - } -} - -#Preview { - SpotifySignInButton( - onSuccess: { _ in }, - onError: { _ in } - ) -} diff --git a/frontend/MusicApp/Views/HomeFeedView.swift b/frontend/MusicApp/Views/HomeFeedView.swift index 0e5798e..cbcefe3 100644 --- a/frontend/MusicApp/Views/HomeFeedView.swift +++ b/frontend/MusicApp/Views/HomeFeedView.swift @@ -14,7 +14,6 @@ struct HomeFeedView: View { .ignoresSafeArea() VStack(spacing: 0) { - VStack(spacing: 16) { HStack { Text("MusIQ") @@ -35,7 +34,7 @@ struct HomeFeedView: View { } } .padding(.horizontal, AppStyles.paddingMedium) - .padding(.top, AppStyles.paddingLarge) + .padding(.top, AppStyles.paddingMedium) .padding(.bottom, AppStyles.paddingMedium) if viewModel.isLoading { diff --git a/frontend/MusicApp/Views/OnboardingView.swift b/frontend/MusicApp/Views/OnboardingView.swift index c68d4ff..0648639 100644 --- a/frontend/MusicApp/Views/OnboardingView.swift +++ b/frontend/MusicApp/Views/OnboardingView.swift @@ -106,39 +106,7 @@ struct OnboardingView: View { .padding(.horizontal, AppStyles.paddingLarge) .padding(.bottom, 32) - if currentSlide == slides.count - 1 { - VStack(spacing: 16) { - HStack { - Rectangle() - .fill(AppColors.secondaryBackground) - .frame(height: 1) - - Text("or continue with") - .font(.system(size: 12)) - .foregroundColor(AppColors.textSecondary) - - Rectangle() - .fill(AppColors.secondaryBackground) - .frame(height: 1) - } - .padding(.horizontal, AppStyles.paddingLarge) - - VStack(spacing: 12) { - AppleSignInButton( - onSuccess: { code, idToken in }, - onError: { _ in } - ) - - GoogleSignInButton( - onSuccess: { code, idToken in }, - onError: { _ in } - ) - } - .padding(.horizontal, AppStyles.paddingLarge) - .padding(.bottom, 32) - } - .transition(.opacity) - } + } } } diff --git a/frontend/MusicApp/Views/ProfileView.swift b/frontend/MusicApp/Views/ProfileView.swift index fddb494..f3b43eb 100644 --- a/frontend/MusicApp/Views/ProfileView.swift +++ b/frontend/MusicApp/Views/ProfileView.swift @@ -3,7 +3,9 @@ import SwiftUI struct ProfileView: View { @ObservedObject var appState: AppState @Environment(\.dismiss) var dismiss + @StateObject private var viewModel = ProfileEditViewModel() @State private var isLoggingOut = false + @State private var showSuccessMessage = false var body: some View { ZStack { @@ -42,6 +44,7 @@ struct ProfileView: View { .padding(.top, AppStyles.paddingLarge) .padding(.bottom, AppStyles.paddingLarge) + ScrollView { VStack(spacing: 24) { VStack(spacing: 16) { ZStack { @@ -60,7 +63,145 @@ struct ProfileView: View { } .padding(.top, 40) - Spacer() + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Email") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + HStack { + TextField("", text: $viewModel.email) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(viewModel.emailError != nil ? AppColors.accent : AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disabled(viewModel.isUpdating) + + if let emailError = viewModel.emailError { + Text(emailError) + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("First Name") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.firstName) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(viewModel.firstNameError != nil ? AppColors.accent : AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.words) + .disabled(viewModel.isUpdating) + + if let firstNameError = viewModel.firstNameError { + Text(firstNameError) + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Last Name") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.lastName) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(viewModel.lastNameError != nil ? AppColors.accent : AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.words) + .disabled(viewModel.isUpdating) + + if let lastNameError = viewModel.lastNameError { + Text(lastNameError) + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Username") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: .constant(viewModel.username)) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.secondaryBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textSecondary) + .disabled(true) + } + + if let error = viewModel.updateError { + Text(error) + .font(.system(size: 14)) + .foregroundColor(AppColors.accent) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if showSuccessMessage { + Text("Profile updated successfully") + .font(.system(size: 14)) + .foregroundColor(.green) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button(action: { + Task { + if await viewModel.updateProfile() { + showSuccessMessage = true + let authService = AuthService() + do { + let updatedUser = try await authService.getCurrentUser() + appState.currentUser = updatedUser + } catch { + + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showSuccessMessage = false + } + } + } + }) { + if viewModel.isUpdating { + ProgressView() + .tint(.white) + } else { + Text("Save") + .font(.system(size: 16, weight: .semibold)) + } + } + .gradientButton(isEnabled: viewModel.canSave) + .disabled(!viewModel.canSave) Button(action: { Task { @@ -93,8 +234,15 @@ struct ProfileView: View { .cornerRadius(AppStyles.cornerRadiusMedium) } .disabled(isLoggingOut) + } .padding(.horizontal, AppStyles.paddingLarge) .padding(.bottom, 40) + } + } + .onAppear { + if let user = appState.currentUser { + viewModel.loadProfile(user: user) + } } } } diff --git a/frontend/Podfile b/frontend/Podfile new file mode 100644 index 0000000..0ff7e3b --- /dev/null +++ b/frontend/Podfile @@ -0,0 +1,11 @@ +# Uncomment the next line to define a global platform for your project +# platform :ios, '9.0' + +target 'MusicApp' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for MusicApp + pod 'Supabase', '~> 2.0' + +end \ No newline at end of file diff --git a/frontend/a.xcodeproj/project.pbxproj b/frontend/a.xcodeproj/project.pbxproj index fe65aff..e202991 100644 --- a/frontend/a.xcodeproj/project.pbxproj +++ b/frontend/a.xcodeproj/project.pbxproj @@ -6,6 +6,11 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + C92695892F1F365E005937E8 /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = C92695882F1F365E005937E8 /* Auth */; }; + C926958B2F1F3661005937E8 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = C926958A2F1F3661005937E8 /* Supabase */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ C9C0A09C2F0C4962002E48B2 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -65,6 +70,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C926958B2F1F3661005937E8 /* Supabase in Frameworks */, + C92695892F1F365E005937E8 /* Auth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -85,12 +92,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + C92695872F1F365E005937E8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; C9C0A0852F0C4960002E48B2 = { isa = PBXGroup; children = ( C9C0A0902F0C4960002E48B2 /* MusicApp */, C9C0A09E2F0C4962002E48B2 /* MusicAppTests */, C9C0A0A82F0C4962002E48B2 /* MusicAppUITests */, + C92695872F1F365E005937E8 /* Frameworks */, C9C0A08F2F0C4960002E48B2 /* Products */, ); sourceTree = ""; @@ -127,6 +142,8 @@ ); name = MusicApp; packageProductDependencies = ( + C92695882F1F365E005937E8 /* Auth */, + C926958A2F1F3661005937E8 /* Supabase */, ); productName = MusicApp; productReference = C9C0A08E2F0C4960002E48B2 /* MusicApp.app */; @@ -201,7 +218,7 @@ }; }; }; - buildConfigurationList = C9C0A0892F0C4960002E48B2 /* Build configuration list for PBXProject "MusicApp" */; + buildConfigurationList = C9C0A0892F0C4960002E48B2 /* Build configuration list for PBXProject "a" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -563,7 +580,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - C9C0A0892F0C4960002E48B2 /* Build configuration list for PBXProject "MusicApp" */ = { + C9C0A0892F0C4960002E48B2 /* Build configuration list for PBXProject "a" */ = { isa = XCConfigurationList; buildConfigurations = ( C9C0A0AD2F0C4962002E48B2 /* Debug */, @@ -623,6 +640,16 @@ package = C908B2E22F1EE94D0045DFB6 /* XCRemoteSwiftPackageReference "supabase-swift" */; productName = Auth; }; + C92695882F1F365E005937E8 /* Auth */ = { + isa = XCSwiftPackageProductDependency; + package = C908B2E22F1EE94D0045DFB6 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Auth; + }; + C926958A2F1F3661005937E8 /* Supabase */ = { + isa = XCSwiftPackageProductDependency; + package = C908B2E22F1EE94D0045DFB6 /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Supabase; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C9C0A0862F0C4960002E48B2 /* Project object */; diff --git a/frontend/a.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme b/frontend/a.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme index 436d48e..2d8f982 100644 --- a/frontend/a.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme +++ b/frontend/a.xcodeproj/xcshareddata/xcschemes/MusicApp.xcscheme @@ -18,7 +18,7 @@ BlueprintIdentifier = "C9C0A08D2F0C4960002E48B2" BuildableName = "MusicApp.app" BlueprintName = "MusicApp" - ReferencedContainer = "container:MusicApp.xcodeproj"> + ReferencedContainer = "container:a.xcodeproj"> @@ -38,7 +38,7 @@ BlueprintIdentifier = "C9C0A09A2F0C4962002E48B2" BuildableName = "MusicAppTests.xctest" BlueprintName = "MusicAppTests" - ReferencedContainer = "container:MusicApp.xcodeproj"> + ReferencedContainer = "container:a.xcodeproj"> + ReferencedContainer = "container:a.xcodeproj"> @@ -71,7 +71,7 @@ BlueprintIdentifier = "C9C0A08D2F0C4960002E48B2" BuildableName = "MusicApp.app" BlueprintName = "MusicApp" - ReferencedContainer = "container:MusicApp.xcodeproj"> + ReferencedContainer = "container:a.xcodeproj"> @@ -88,7 +88,7 @@ BlueprintIdentifier = "C9C0A08D2F0C4960002E48B2" BuildableName = "MusicApp.app" BlueprintName = "MusicApp" - ReferencedContainer = "container:MusicApp.xcodeproj"> + ReferencedContainer = "container:a.xcodeproj">