diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index f5f5ffc..10b387a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -23,6 +23,7 @@ import com.mycrib.android.ui.screens.RegisterScreen import com.mycrib.android.ui.screens.ResidenceDetailScreen import com.mycrib.android.ui.screens.ResidencesScreen import com.mycrib.android.ui.screens.TasksScreen +import com.mycrib.android.ui.screens.VerifyEmailScreen import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -42,14 +43,58 @@ import mycrib.composeapp.generated.resources.compose_multiplatform @Preview fun App() { var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) } + var isVerified by remember { mutableStateOf(false) } + var isCheckingAuth by remember { mutableStateOf(true) } val navController = rememberNavController() - // Check for stored token on app start and initialize lookups if logged in + // Check for stored token and verification status on app start LaunchedEffect(Unit) { - isLoggedIn = TokenStorage.hasToken() - if (isLoggedIn) { - LookupsRepository.initialize() + val hasToken = TokenStorage.hasToken() + isLoggedIn = hasToken + + if (hasToken) { + // Fetch current user to check verification status + val authApi = com.mycrib.shared.network.AuthApi() + val token = TokenStorage.getToken() + + if (token != null) { + when (val result = authApi.getCurrentUser(token)) { + is com.mycrib.shared.network.ApiResult.Success -> { + isVerified = result.data.verified + LookupsRepository.initialize() + } + else -> { + // If fetching user fails, clear token and logout + TokenStorage.clearToken() + isLoggedIn = false + } + } + } } + + isCheckingAuth = false + } + + if (isCheckingAuth) { + // Show loading screen while checking auth + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.CircularProgressIndicator() + } + } + return + } + + val startDestination = when { + !isLoggedIn -> LoginRoute + !isVerified -> VerifyEmailRoute + else -> ResidencesRoute } Surface( @@ -58,16 +103,25 @@ fun App() { ) { NavHost( navController = navController, - startDestination = if (isLoggedIn) ResidencesRoute else LoginRoute + startDestination = startDestination ) { composable { LoginScreen( - onLoginSuccess = { + onLoginSuccess = { user -> isLoggedIn = true + isVerified = user.verified // Initialize lookups after successful login LookupsRepository.initialize() - navController.navigate(ResidencesRoute) { - popUpTo { inclusive = true } + + // Check if user is verified + if (user.verified) { + navController.navigate(ResidencesRoute) { + popUpTo { inclusive = true } + } + } else { + navController.navigate(VerifyEmailRoute) { + popUpTo { inclusive = true } + } } }, onNavigateToRegister = { @@ -80,9 +134,10 @@ fun App() { RegisterScreen( onRegisterSuccess = { isLoggedIn = true + isVerified = false // Initialize lookups after successful registration LookupsRepository.initialize() - navController.navigate(ResidencesRoute) { + navController.navigate(VerifyEmailRoute) { popUpTo { inclusive = true } } }, @@ -92,6 +147,27 @@ fun App() { ) } + composable { + VerifyEmailScreen( + onVerifySuccess = { + isVerified = true + navController.navigate(ResidencesRoute) { + popUpTo { inclusive = true } + } + }, + onLogout = { + // Clear token and lookups on logout + TokenStorage.clearToken() + LookupsRepository.clear() + isLoggedIn = false + isVerified = false + navController.navigate(LoginRoute) { + popUpTo { inclusive = true } + } + } + ) + } + composable { HomeScreen( onNavigateToResidences = { @@ -105,6 +181,7 @@ fun App() { TokenStorage.clearToken() LookupsRepository.clear() isLoggedIn = false + isVerified = false navController.navigate(LoginRoute) { popUpTo { inclusive = true } } @@ -125,6 +202,7 @@ fun App() { TokenStorage.clearToken() LookupsRepository.clear() isLoggedIn = false + isVerified = false navController.navigate(LoginRoute) { popUpTo { inclusive = true } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt index 0a18ffc..8a88f57 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt @@ -12,7 +12,8 @@ data class User( @SerialName("last_name") val lastName: String?, @SerialName("is_staff") val isStaff: Boolean = false, @SerialName("is_active") val isActive: Boolean = true, - @SerialName("date_joined") val dateJoined: String + @SerialName("date_joined") val dateJoined: String, + val verified: Boolean = false ) @Serializable @@ -47,5 +48,17 @@ data class LoginRequest( @Serializable data class AuthResponse( val token: String, - val user: User + val user: User, + val verified: Boolean = false +) + +@Serializable +data class VerifyEmailRequest( + val code: String +) + +@Serializable +data class VerifyEmailResponse( + val message: String, + val verified: Boolean ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index e0edc23..2baa8ca 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -9,6 +9,9 @@ object LoginRoute @Serializable object RegisterRoute +@Serializable +object VerifyEmailRoute + @Serializable object HomeRoute diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiResult.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiResult.kt index b83b53a..9e88fe4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiResult.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiResult.kt @@ -4,4 +4,5 @@ sealed class ApiResult { data class Success(val data: T) : ApiResult() data class Error(val message: String, val code: Int? = null) : ApiResult() object Loading : ApiResult() + object Idle : ApiResult() } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt index 7e571c4..4a441d4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt @@ -74,4 +74,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult { + return try { + val response = client.post("$baseUrl/auth/verify/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorBody = try { + response.body>() + } catch (e: Exception) { + mapOf("error" to "Verification failed") + } + ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt index a759aea..0bf33cf 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt @@ -126,6 +126,8 @@ fun HomeScreen( is ApiResult.Error -> { // Don't show error card, just let navigation cards show } + + else -> {} } // Residences Card diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt index 61f21fb..3720b59 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt @@ -21,7 +21,7 @@ import com.mycrib.shared.network.ApiResult @Composable fun LoginScreen( - onLoginSuccess: () -> Unit, + onLoginSuccess: (com.mycrib.shared.models.User) -> Unit, onNavigateToRegister: () -> Unit, viewModel: AuthViewModel = viewModel { AuthViewModel() } ) { @@ -33,7 +33,8 @@ fun LoginScreen( LaunchedEffect(loginState) { when (loginState) { is ApiResult.Success -> { - onLoginSuccess() + val user = (loginState as ApiResult.Success).data.user + onLoginSuccess(user) } else -> {} } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index 93b9071..3f2355a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -547,9 +547,13 @@ fun ResidenceDetailScreen( } } } + + else -> {} } } } + + else -> {} } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index 9cc4d3c..df4335a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -298,6 +298,8 @@ fun ResidencesScreen( } } } + + else -> {} } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt index b88741b..f5a58c5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt @@ -105,6 +105,8 @@ fun TasksScreen( } } } + + else -> {} } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyEmailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyEmailScreen.kt new file mode 100644 index 0000000..0eb1591 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyEmailScreen.kt @@ -0,0 +1,195 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.ui.components.auth.AuthHeader +import com.mycrib.android.ui.components.common.ErrorCard +import com.mycrib.android.viewmodel.AuthViewModel +import com.mycrib.shared.network.ApiResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerifyEmailScreen( + onVerifySuccess: () -> Unit, + onLogout: () -> Unit, + viewModel: AuthViewModel = viewModel { AuthViewModel() } +) { + var code by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + + val verifyState by viewModel.verifyEmailState.collectAsState() + + LaunchedEffect(verifyState) { + when (verifyState) { + is ApiResult.Success -> { + viewModel.resetVerifyEmailState() + onVerifySuccess() + } + is ApiResult.Error -> { + errorMessage = (verifyState as ApiResult.Error).message + isLoading = false + } + is ApiResult.Loading -> { + isLoading = true + errorMessage = "" + } + else -> {} + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Verify Email", fontWeight = FontWeight.SemiBold) }, + actions = { + TextButton(onClick = onLogout) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Logout, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text("Logout") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + AuthHeader( + icon = Icons.Default.MarkEmailRead, + title = "Verify Your Email", + subtitle = "You must verify your email address to continue" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = "Email verification is required. Check your inbox for a 6-digit code.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold + ) + } + } + + OutlinedTextField( + value = code, + onValueChange = { + if (it.length <= 6 && it.all { char -> char.isDigit() }) { + code = it + } + }, + label = { Text("Verification Code") }, + leadingIcon = { + Icon(Icons.Default.Pin, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = { Text("000000") } + ) + + if (errorMessage.isNotEmpty()) { + ErrorCard( + message = errorMessage + ) + } + + Button( + onClick = { + if (code.length == 6) { + isLoading = true + viewModel.verifyEmail(code) + } else { + errorMessage = "Please enter a valid 6-digit code" + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(12.dp), + enabled = !isLoading && code.length == 6 + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.CheckCircle, contentDescription = null) + Text( + "Verify Email", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Didn't receive the code? Check your spam folder or contact support.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt index 4a6b09a..b7c42ec 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt @@ -6,6 +6,9 @@ import com.mycrib.shared.models.AuthResponse import com.mycrib.shared.models.LoginRequest import com.mycrib.shared.models.RegisterRequest import com.mycrib.shared.models.Residence +import com.mycrib.shared.models.User +import com.mycrib.shared.models.VerifyEmailRequest +import com.mycrib.shared.models.VerifyEmailResponse import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.AuthApi import com.mycrib.storage.TokenStorage @@ -16,12 +19,15 @@ import kotlinx.coroutines.launch class AuthViewModel : ViewModel() { private val authApi = AuthApi() - private val _loginState = MutableStateFlow>(ApiResult.Loading) - val loginState: StateFlow> = _loginState + private val _loginState = MutableStateFlow>(ApiResult.Idle) + val loginState: StateFlow> = _loginState - private val _registerState = MutableStateFlow>(ApiResult.Loading) + private val _registerState = MutableStateFlow>(ApiResult.Idle) val registerState: StateFlow> = _registerState + private val _verifyEmailState = MutableStateFlow>(ApiResult.Idle) + val verifyEmailState: StateFlow> = _verifyEmailState + fun login(username: String, password: String) { viewModelScope.launch { _loginState.value = ApiResult.Loading @@ -30,7 +36,7 @@ class AuthViewModel : ViewModel() { is ApiResult.Success -> { // Store token for future API calls TokenStorage.saveToken(result.data.token) - ApiResult.Success(result.data.token) + ApiResult.Success(result.data) } is ApiResult.Error -> result else -> ApiResult.Error("Unknown error") @@ -61,7 +67,31 @@ class AuthViewModel : ViewModel() { } fun resetRegisterState() { - _registerState.value = ApiResult.Loading + _registerState.value = ApiResult.Idle + } + + fun verifyEmail(code: String) { + viewModelScope.launch { + _verifyEmailState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + val result = authApi.verifyEmail( + token = token, + request = VerifyEmailRequest(code = code) + ) + _verifyEmailState.value = when (result) { + is ApiResult.Success -> ApiResult.Success(result.data) + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } else { + _verifyEmailState.value = ApiResult.Error("Not authenticated") + } + } + } + + fun resetVerifyEmailState() { + _verifyEmailState.value = ApiResult.Idle } fun logout() { diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index 457189b..de9f632 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -4,6 +4,7 @@ struct LoginView: View { @StateObject private var viewModel = LoginViewModel() @FocusState private var focusedField: Field? @State private var showingRegister = false + @State private var showingVerifyEmail = false enum Field { case username, password @@ -11,97 +12,107 @@ struct LoginView: View { var body: some View { NavigationView { - ZStack { - // Background - Color(.systemGroupedBackground) - .ignoresSafeArea() + Form { + Section { + VStack(spacing: 16) { + Image(systemName: "house.fill") + .font(.system(size: 60)) + .foregroundStyle(.blue.gradient) - ScrollView { - VStack(spacing: 24) { - // Logo or App Name - LoginHeader() + Text("MyCrib") + .font(.largeTitle) + .fontWeight(.bold) - // Login Form - VStack(spacing: 16) { - // Username Field - VStack(alignment: .leading, spacing: 8) { - Text("Username") - .font(.subheadline) - .foregroundColor(.secondary) + Text("Manage your properties with ease") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + .listRowBackground(Color.clear) - TextField("Enter your username", text: $viewModel.username) - .textFieldStyle(.roundedBorder) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focusedField, equals: .username) - .submitLabel(.next) - .onSubmit { - focusedField = .password - } - } - - // Password Field - VStack(alignment: .leading, spacing: 8) { - Text("Password") - .font(.subheadline) - .foregroundColor(.secondary) - - SecureField("Enter your password", text: $viewModel.password) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .password) - .submitLabel(.go) - .onSubmit { - viewModel.login() - } - } - - // Error Message - if let errorMessage = viewModel.errorMessage { - ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) - } - - // Login Button - Button(action: viewModel.login) { - HStack { - if viewModel.isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Text("Login") - .fontWeight(.semibold) - } - } - .frame(maxWidth: .infinity) - .padding() - .background(viewModel.isLoading ? Color.gray : Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(viewModel.isLoading) - - // Forgot Password / Sign Up Links - HStack(spacing: 4) { - Text("Don't have an account?") - .font(.caption) - .foregroundColor(.secondary) - - Button("Sign Up") { - showingRegister = true - } - .font(.caption) - .fontWeight(.semibold) - } + Section { + TextField("Username", text: $viewModel.username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .username) + .submitLabel(.next) + .onSubmit { + focusedField = .password } - .padding(.horizontal, 24) + SecureField("Password", text: $viewModel.password) + .focused($focusedField, equals: .password) + .submitLabel(.go) + .onSubmit { + viewModel.login() + } + } header: { + Text("Account Information") + } + + if let errorMessage = viewModel.errorMessage { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .font(.subheadline) + } + } + } + + Section { + Button(action: viewModel.login) { + HStack { + Spacer() + if viewModel.isLoading { + ProgressView() + } else { + Text("Login") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(viewModel.isLoading) + } + + Section { + HStack { + Spacer() + Text("Don't have an account?") + .font(.subheadline) + .foregroundColor(.secondary) + Button("Sign Up") { + showingRegister = true + } + .font(.subheadline) + .fontWeight(.semibold) Spacer() } } + .listRowBackground(Color.clear) } .navigationTitle("Welcome Back") - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.large) .fullScreenCover(isPresented: $viewModel.isAuthenticated) { - MainTabView() + if viewModel.isVerified { + MainTabView() + } else { + VerifyEmailView( + onVerifySuccess: { + // After verification, show main tab view + viewModel.isVerified = true + }, + onLogout: { + // Logout and dismiss verification screen + viewModel.logout() + } + ) + } } .sheet(isPresented: $showingRegister) { RegisterView() diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 1243912..757cbf9 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -10,7 +10,9 @@ class LoginViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var isAuthenticated: Bool = false - + @Published var isVerified: Bool = false + @Published var currentUser: User? + // MARK: - Private Properties private let authApi: AuthApi private let tokenStorage: TokenStorage @@ -77,6 +79,10 @@ class LoginViewModel: ObservableObject { let user = results.data?.user { self.tokenStorage.saveToken(token: token) + // Store user data and verification status + self.currentUser = user + self.isVerified = user.verified + // Initialize lookups repository after successful login LookupsManager.shared.initialize() @@ -85,7 +91,7 @@ class LoginViewModel: ObservableObject { self.isLoading = false print("Login successful! Token: token") - print("User: \(user.username)") + print("User: \(user.username), Verified: \(user.verified)") } } diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index 553c54d..806a84f 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -1,9 +1,11 @@ import SwiftUI +import ComposeApp struct RegisterView: View { @StateObject private var viewModel = RegisterViewModel() @Environment(\.dismiss) var dismiss @FocusState private var focusedField: Field? + @State private var showVerifyEmail = false enum Field { case username, email, password, confirmPassword @@ -11,114 +13,99 @@ struct RegisterView: View { var body: some View { NavigationView { - ZStack { - Color(.systemGroupedBackground) - .ignoresSafeArea() + Form { + Section { + VStack(spacing: 16) { + Image(systemName: "person.badge.plus") + .font(.system(size: 60)) + .foregroundStyle(.blue.gradient) - ScrollView { - VStack(spacing: 24) { - // Icon and Title - RegisterHeader() + Text("Join MyCrib") + .font(.largeTitle) + .fontWeight(.bold) - // Registration Form - VStack(spacing: 16) { - // Username Field - VStack(alignment: .leading, spacing: 8) { - Text("Username") - .font(.subheadline) - .foregroundColor(.secondary) - - TextField("Enter your username", text: $viewModel.username) - .textFieldStyle(.roundedBorder) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focusedField, equals: .username) - .submitLabel(.next) - .onSubmit { - focusedField = .email - } - } - - // Email Field - VStack(alignment: .leading, spacing: 8) { - Text("Email") - .font(.subheadline) - .foregroundColor(.secondary) - - TextField("Enter your email", text: $viewModel.email) - .textFieldStyle(.roundedBorder) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - .focused($focusedField, equals: .email) - .submitLabel(.next) - .onSubmit { - focusedField = .password - } - } - - // Password Field - VStack(alignment: .leading, spacing: 8) { - Text("Password") - .font(.subheadline) - .foregroundColor(.secondary) - - SecureField("Enter your password", text: $viewModel.password) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .password) - .submitLabel(.next) - .onSubmit { - focusedField = .confirmPassword - } - } - - // Confirm Password Field - VStack(alignment: .leading, spacing: 8) { - Text("Confirm Password") - .font(.subheadline) - .foregroundColor(.secondary) - - SecureField("Confirm your password", text: $viewModel.confirmPassword) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .confirmPassword) - .submitLabel(.go) - .onSubmit { - viewModel.register() - } - } - - // Error Message - if let errorMessage = viewModel.errorMessage { - ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) - } - - // Register Button - Button(action: viewModel.register) { - HStack { - if viewModel.isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Text("Create Account") - .fontWeight(.semibold) - } - } - .frame(maxWidth: .infinity) - .padding() - .background(viewModel.isLoading ? Color.gray : Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(viewModel.isLoading) - } - .padding(.horizontal, 24) - - Spacer() + Text("Start managing your properties today") + .font(.subheadline) + .foregroundColor(.secondary) } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + .listRowBackground(Color.clear) + + Section { + TextField("Username", text: $viewModel.username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .username) + .submitLabel(.next) + .onSubmit { + focusedField = .email + } + + TextField("Email", text: $viewModel.email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .focused($focusedField, equals: .email) + .submitLabel(.next) + .onSubmit { + focusedField = .password + } + } header: { + Text("Account Information") + } + + Section { + SecureField("Password", text: $viewModel.password) + .focused($focusedField, equals: .password) + .submitLabel(.next) + .onSubmit { + focusedField = .confirmPassword + } + + SecureField("Confirm Password", text: $viewModel.confirmPassword) + .focused($focusedField, equals: .confirmPassword) + .submitLabel(.go) + .onSubmit { + viewModel.register() + } + } header: { + Text("Security") + } footer: { + Text("Password must be secure") + } + + if let errorMessage = viewModel.errorMessage { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .font(.subheadline) + } + } + } + + Section { + Button(action: viewModel.register) { + HStack { + Spacer() + if viewModel.isLoading { + ProgressView() + } else { + Text("Create Account") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(viewModel.isLoading) } } .navigationTitle("Create Account") - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { @@ -127,7 +114,18 @@ struct RegisterView: View { } } .fullScreenCover(isPresented: $viewModel.isRegistered) { - MainTabView() + VerifyEmailView( + onVerifySuccess: { + dismiss() + showVerifyEmail = false + }, + onLogout: { + // Logout and return to login screen + TokenManager().clearToken() + LookupsManager.shared.clear() + dismiss() + } + ) } } } diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift new file mode 100644 index 0000000..433451d --- /dev/null +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct VerifyEmailView: View { + @StateObject private var viewModel = VerifyEmailViewModel() + @FocusState private var isFocused: Bool + var onVerifySuccess: () -> Void + var onLogout: () -> Void + + var body: some View { + NavigationView { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + Spacer().frame(height: 20) + + // Header + VStack(spacing: 12) { + Image(systemName: "envelope.badge.shield.half.filled") + .font(.system(size: 60)) + .foregroundStyle(.blue.gradient) + .padding(.bottom, 8) + + Text("Verify Your Email") + .font(.title) + .fontWeight(.bold) + + Text("You must verify your email address to continue") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + // Info Card + GroupBox { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.shield.fill") + .foregroundColor(.orange) + .font(.title2) + + Text("Email verification is required. Check your inbox for a 6-digit code.") + .font(.subheadline) + .foregroundColor(.primary) + .fontWeight(.semibold) + } + .padding(.vertical, 4) + } + .padding(.horizontal) + + // Code Input + VStack(alignment: .leading, spacing: 12) { + Text("Verification Code") + .font(.headline) + .padding(.horizontal) + + TextField("000000", text: $viewModel.code) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .frame(height: 60) + .padding(.horizontal) + .focused($isFocused) + .onChange(of: viewModel.code) { _, newValue in + // Limit to 6 digits + if newValue.count > 6 { + viewModel.code = String(newValue.prefix(6)) + } + // Only allow numbers + viewModel.code = newValue.filter { $0.isNumber } + } + + Text("Code must be 6 digits") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + } + + // Error Message + if let errorMessage = viewModel.errorMessage { + ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) + .padding(.horizontal) + } + + // Verify Button + Button(action: { + viewModel.verifyEmail() + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "checkmark.shield.fill") + Text("Verify Email") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + viewModel.code.count == 6 && !viewModel.isLoading + ? Color.blue + : Color.gray.opacity(0.3) + ) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(viewModel.code.count != 6 || viewModel.isLoading) + .padding(.horizontal) + + Spacer().frame(height: 20) + + // Help Text + Text("Didn't receive the code? Check your spam folder or contact support.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .interactiveDismissDisabled(true) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: onLogout) { + HStack(spacing: 4) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 16)) + Text("Logout") + .font(.subheadline) + } + } + } + } + .onAppear { + isFocused = true + } + .onChange(of: viewModel.isVerified) { _, isVerified in + if isVerified { + onVerifySuccess() + } + } + } + } +} + +#Preview { + VerifyEmailView( + onVerifySuccess: { print("Verified!") }, + onLogout: { print("Logout") } + ) +} diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift new file mode 100644 index 0000000..930c949 --- /dev/null +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift @@ -0,0 +1,89 @@ +import Foundation +import ComposeApp +import Combine + +@MainActor +class VerifyEmailViewModel: ObservableObject { + // MARK: - Published Properties + @Published var code: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isVerified: Bool = false + + // MARK: - Private Properties + private let authApi: AuthApi + private let tokenStorage: TokenStorage + + // MARK: - Initialization + init() { + self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) + self.tokenStorage = TokenStorage() + self.tokenStorage.initialize(manager: TokenManager.init()) + } + + // MARK: - Public Methods + func verifyEmail() { + // Validation + guard code.count == 6 else { + errorMessage = "Please enter a valid 6-digit code" + return + } + + guard code.allSatisfy({ $0.isNumber }) else { + errorMessage = "Code must contain only numbers" + return + } + + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + let request = VerifyEmailRequest(code: code) + + authApi.verifyEmail(token: token, request: request) { result, error in + if let successResult = result as? ApiResultSuccess { + self.handleSuccess(results: successResult) + return + } + + if let errorResult = result as? ApiResultError { + self.handleError(message: errorResult.message) + return + } + + if let error = error { + self.handleError(message: error.localizedDescription) + return + } + + self.isLoading = false + print("Unknown error during email verification") + } + } + + @MainActor + func handleError(message: String) { + self.isLoading = false + self.errorMessage = message + print("Verification error: \(message)") + } + + @MainActor + func handleSuccess(results: ApiResultSuccess) { + if let verified = results.data?.verified, verified { + self.isVerified = true + self.isLoading = false + print("Email verification successful!") + } else { + self.handleError(message: "Verification failed") + } + } + + func clearError() { + errorMessage = nil + } +}