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">