Add Sign in with Apple for iOS

Kotlin Shared Layer:
- Add AppleSignInRequest and AppleSignInResponse models
- Add appleSignIn method to AuthApi and APILayer
- Add appleSignInState and appleSignIn() to AuthViewModel

iOS App:
- Create AppleSignInManager for AuthenticationServices integration
- Create AppleSignInViewModel to coordinate Apple auth flow
- Update LoginView with "Sign in with Apple" button
- Add Sign in with Apple entitlement
- Add accessibility identifier for UI testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-29 01:17:38 -06:00
parent 4b905ad5fe
commit 5a1a87fe8d
10 changed files with 529 additions and 0 deletions

View File

@@ -162,3 +162,27 @@ data class ResetPasswordResponse(
data class MessageResponse(
val message: String
)
// Apple Sign In Models
/**
* Apple Sign In request matching Go API
*/
@Serializable
data class AppleSignInRequest(
@SerialName("id_token") val idToken: String,
@SerialName("user_id") val userId: String,
val email: String? = null,
@SerialName("first_name") val firstName: String? = null,
@SerialName("last_name") val lastName: String? = null
)
/**
* Apple Sign In response matching Go API
*/
@Serializable
data class AppleSignInResponse(
val token: String,
val user: User,
@SerialName("is_new_user") val isNewUser: Boolean
)

View File

@@ -921,6 +921,20 @@ object APILayer {
return authApi.resetPassword(request)
}
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
val result = authApi.appleSignIn(request)
// Update cache on success
if (result is ApiResult.Success) {
TokenStorage.saveToken(result.data.token)
DataCache.updateCurrentUser(result.data.user)
// Prefetch all data after successful Apple sign in
prefetchManager.prefetchAllData()
}
return result
}
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
val result = authApi.updateProfile(token, request)

View File

@@ -200,4 +200,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Apple Sign In
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/apple-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Apple Sign In failed")
}
ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -2,6 +2,8 @@ package com.example.casera.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.casera.models.AppleSignInRequest
import com.example.casera.models.AppleSignInResponse
import com.example.casera.models.AuthResponse
import com.example.casera.models.ForgotPasswordRequest
import com.example.casera.models.ForgotPasswordResponse
@@ -48,6 +50,9 @@ class AuthViewModel : ViewModel() {
private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle)
val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState
private val _appleSignInState = MutableStateFlow<ApiResult<AppleSignInResponse>>(ApiResult.Idle)
val appleSignInState: StateFlow<ApiResult<AppleSignInResponse>> = _appleSignInState
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = ApiResult.Loading
@@ -207,6 +212,36 @@ class AuthViewModel : ViewModel() {
_resetPasswordState.value = ApiResult.Idle
}
fun appleSignIn(
idToken: String,
userId: String,
email: String?,
firstName: String?,
lastName: String?
) {
viewModelScope.launch {
_appleSignInState.value = ApiResult.Loading
val result = APILayer.appleSignIn(
AppleSignInRequest(
idToken = idToken,
userId = userId,
email = email,
firstName = firstName,
lastName = lastName
)
)
_appleSignInState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
}
}
fun resetAppleSignInState() {
_appleSignInState.value = ApiResult.Idle
}
fun logout() {
viewModelScope.launch {
APILayer.logout()

View File

@@ -12,6 +12,7 @@ struct AccessibilityIdentifiers {
static let signUpButton = "Login.SignUpButton"
static let forgotPasswordButton = "Login.ForgotPasswordButton"
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
static let appleSignInButton = "Login.AppleSignInButton"
// Registration
static let registerUsernameField = "Register.UsernameField"

View File

@@ -12,6 +12,7 @@ struct AccessibilityIdentifiers {
static let signUpButton = "Login.SignUpButton"
static let forgotPasswordButton = "Login.ForgotPasswordButton"
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
static let appleSignInButton = "Login.AppleSignInButton"
// Registration
static let registerUsernameField = "Register.UsernameField"

View File

@@ -0,0 +1,176 @@
import Foundation
import AuthenticationServices
/// Handles Sign in with Apple authentication flow
class AppleSignInManager: NSObject, ObservableObject {
// MARK: - Published Properties
@Published var isProcessing: Bool = false
@Published var error: Error?
// MARK: - Completion Handler
private var completionHandler: ((Result<AppleSignInCredential, Error>) -> Void)?
// MARK: - Public Methods
/// Initiates the Sign in with Apple flow
/// - Parameter completion: Callback with either credentials or error
func signIn(completion: @escaping (Result<AppleSignInCredential, Error>) -> Void) {
self.completionHandler = completion
self.error = nil
self.isProcessing = true
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
}
}
// MARK: - ASAuthorizationControllerDelegate
extension AppleSignInManager: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
isProcessing = false
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
let error = AppleSignInError.invalidCredential
self.error = error
completionHandler?(.failure(error))
return
}
// Get the identity token as a string
guard let identityTokenData = appleIDCredential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
let error = AppleSignInError.missingIdentityToken
self.error = error
completionHandler?(.failure(error))
return
}
// Extract user info (only available on first sign in)
let email = appleIDCredential.email
let firstName = appleIDCredential.fullName?.givenName
let lastName = appleIDCredential.fullName?.familyName
let userIdentifier = appleIDCredential.user
let credential = AppleSignInCredential(
identityToken: identityToken,
userIdentifier: userIdentifier,
email: email,
firstName: firstName,
lastName: lastName
)
completionHandler?(.success(credential))
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
isProcessing = false
// Check if user cancelled
if let authError = error as? ASAuthorizationError {
switch authError.code {
case .canceled:
// User cancelled, don't treat as error
self.error = AppleSignInError.userCancelled
completionHandler?(.failure(AppleSignInError.userCancelled))
return
case .failed:
self.error = AppleSignInError.authorizationFailed
completionHandler?(.failure(AppleSignInError.authorizationFailed))
return
case .invalidResponse:
self.error = AppleSignInError.invalidResponse
completionHandler?(.failure(AppleSignInError.invalidResponse))
return
case .notHandled:
self.error = AppleSignInError.notHandled
completionHandler?(.failure(AppleSignInError.notHandled))
return
case .notInteractive:
self.error = AppleSignInError.notInteractive
completionHandler?(.failure(AppleSignInError.notInteractive))
return
case .unknown:
break // Fall through to generic error
@unknown default:
break
}
}
self.error = error
completionHandler?(.failure(error))
}
}
// MARK: - ASAuthorizationControllerPresentationContextProviding
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
// Get the key window for presentation
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
// Fallback to first window
return UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first ?? ASPresentationAnchor()
}
return window
}
}
// MARK: - Supporting Types
/// Credentials returned from successful Apple Sign In
struct AppleSignInCredential {
let identityToken: String
let userIdentifier: String
let email: String?
let firstName: String?
let lastName: String?
}
/// Custom errors for Apple Sign In
enum AppleSignInError: LocalizedError {
case invalidCredential
case missingIdentityToken
case userCancelled
case authorizationFailed
case invalidResponse
case notHandled
case notInteractive
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidCredential:
return "Invalid Apple ID credential"
case .missingIdentityToken:
return "Missing identity token from Apple"
case .userCancelled:
return nil // Don't show error for user cancellation
case .authorizationFailed:
return "Apple Sign In authorization failed"
case .invalidResponse:
return "Invalid response from Apple"
case .notHandled:
return "Apple Sign In request was not handled"
case .notInteractive:
return "Apple Sign In requires user interaction"
case .serverError(let message):
return message
}
}
var isCancellation: Bool {
if case .userCancelled = self {
return true
}
return false
}
}

View File

@@ -0,0 +1,171 @@
import Foundation
import ComposeApp
import Combine
/// ViewModel for handling Apple Sign In flow
/// Coordinates between AppleSignInManager (iOS) and AuthViewModel (Kotlin)
@MainActor
class AppleSignInViewModel: ObservableObject {
// MARK: - Published Properties
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isNewUser: Bool = false
// MARK: - Private Properties
private let appleSignInManager = AppleSignInManager()
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol
// MARK: - Callbacks
var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified
// MARK: - Initialization
init(
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
}
// MARK: - Public Methods
/// Initiates the Apple Sign In flow
func signInWithApple() {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
appleSignInManager.signIn { [weak self] result in
Task { @MainActor in
switch result {
case .success(let credential):
self?.sendCredentialToBackend(credential)
case .failure(let error):
self?.handleAppleError(error)
}
}
}
}
/// Resets the error state
func clearError() {
errorMessage = nil
}
// MARK: - Private Methods
/// Sends Apple credential to backend for verification/authentication
private func sendCredentialToBackend(_ credential: AppleSignInCredential) {
sharedViewModel.appleSignIn(
idToken: credential.identityToken,
userId: credential.userIdentifier,
email: credential.email,
firstName: credential.firstName,
lastName: credential.lastName
)
// Observe the result
Task {
for await state in sharedViewModel.appleSignInState {
if state is ApiResultLoading {
await MainActor.run {
self.isLoading = true
}
} else if let success = state as? ApiResultSuccess<AppleSignInResponse> {
await MainActor.run {
self.handleSuccess(success.data)
}
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.handleBackendError(error)
}
break
}
}
}
}
/// Handles successful authentication
private func handleSuccess(_ response: AppleSignInResponse?) {
isLoading = false
guard let response = response,
let token = response.token as String? else {
errorMessage = "Invalid response from server"
return
}
let user = response.user
// Store the token
tokenStorage.saveToken(token: token)
// Track if this is a new user
isNewUser = response.isNewUser
// Initialize lookups
Task {
_ = try? await APILayer.shared.initializeLookups()
}
// Prefetch data
Task {
do {
print("Starting data prefetch after Apple Sign In...")
let prefetchManager = DataPrefetchManager.Companion().getInstance()
_ = try await prefetchManager.prefetchAllData()
print("Data prefetch completed successfully")
} catch {
print("Data prefetch failed: \(error.localizedDescription)")
}
}
print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)")
// Call success callback with verification status
onSignInSuccess?(user.verified)
}
/// Handles Apple Sign In errors
private func handleAppleError(_ error: Error) {
isLoading = false
if let appleError = error as? AppleSignInError {
// Don't show error for user cancellation
if appleError.isCancellation {
return
}
errorMessage = appleError.errorDescription
} else {
errorMessage = error.localizedDescription
}
}
/// Handles backend API errors
private func handleBackendError(_ error: ApiResultError) {
isLoading = false
sharedViewModel.resetAppleSignInState()
if let code = error.code?.intValue {
switch code {
case 400:
errorMessage = "Invalid Apple Sign In token"
case 401:
errorMessage = "Authentication failed. Please try again."
case 403:
errorMessage = "Access denied"
case 500...599:
errorMessage = "Server error. Please try again later."
default:
errorMessage = ErrorMessageParser.parse(error.message)
}
} else {
errorMessage = ErrorMessageParser.parse(error.message)
}
print("Apple Sign In backend error: \(error.message)")
}
}

View File

@@ -1,7 +1,9 @@
import SwiftUI
import AuthenticationServices
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
@FocusState private var focusedField: Field?
@State private var showingRegister = false
@State private var showVerification = false
@@ -202,6 +204,70 @@ struct LoginView: View {
.disabled(!isFormValid || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
// Divider
HStack {
Rectangle()
.fill(Color.appTextSecondary.opacity(0.3))
.frame(height: 1)
Text("or")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.padding(.horizontal, AppSpacing.sm)
Rectangle()
.fill(Color.appTextSecondary.opacity(0.3))
.frame(height: 1)
}
.padding(.vertical, AppSpacing.xs)
// Sign in with Apple Button
SignInWithAppleButton(
onRequest: { request in
request.requestedScopes = [.fullName, .email]
},
onCompletion: { _ in }
)
.frame(height: 56)
.cornerRadius(AppRadius.md)
.signInWithAppleButtonStyle(.black)
.disabled(appleSignInViewModel.isLoading)
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
.overlay {
// Custom tap handler to use our view model
Color.clear
.contentShape(Rectangle())
.onTapGesture {
appleSignInViewModel.signInWithApple()
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.appleSignInButton)
// Apple Sign In loading indicator
if appleSignInViewModel.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text("Signing in with Apple...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.padding(.top, AppSpacing.xs)
}
// Apple Sign In Error
if let appleError = appleSignInViewModel.errorMessage {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(appleError)
.font(.callout)
.foregroundColor(Color.appError)
Spacer()
}
.padding(AppSpacing.md)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
}
// Sign Up Link
HStack(spacing: AppSpacing.xs) {
Text("Don't have an account?")
@@ -242,6 +308,20 @@ struct LoginView: View {
// since AuthenticationManager.isVerified is now false
}
}
// Set up callback for Apple Sign In success
appleSignInViewModel.onSignInSuccess = { [self] isVerified in
// Update the shared authentication manager
AuthenticationManager.shared.login(verified: isVerified)
if isVerified {
// User is verified, call the success callback
self.onLoginSuccess?()
} else {
// User needs verification - RootView will handle showing VerifyEmailView
// since AuthenticationManager.isVerified is now false
}
}
}
.fullScreenCover(isPresented: $showVerification) {
VerifyEmailView(

View File

@@ -4,6 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.mycrib.MyCribDev</string>