This commit is contained in:
Trey t
2025-11-05 20:31:01 -06:00
parent fe99f67f81
commit bc289d6c88
17 changed files with 819 additions and 203 deletions

View File

@@ -23,6 +23,7 @@ import com.mycrib.android.ui.screens.RegisterScreen
import com.mycrib.android.ui.screens.ResidenceDetailScreen import com.mycrib.android.ui.screens.ResidenceDetailScreen
import com.mycrib.android.ui.screens.ResidencesScreen import com.mycrib.android.ui.screens.ResidencesScreen
import com.mycrib.android.ui.screens.TasksScreen import com.mycrib.android.ui.screens.TasksScreen
import com.mycrib.android.ui.screens.VerifyEmailScreen
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@@ -42,14 +43,58 @@ import mycrib.composeapp.generated.resources.compose_multiplatform
@Preview @Preview
fun App() { fun App() {
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) } var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
var isVerified by remember { mutableStateOf(false) }
var isCheckingAuth by remember { mutableStateOf(true) }
val navController = rememberNavController() 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) { LaunchedEffect(Unit) {
isLoggedIn = TokenStorage.hasToken() val hasToken = TokenStorage.hasToken()
if (isLoggedIn) { 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() 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( Surface(
@@ -58,17 +103,26 @@ fun App() {
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (isLoggedIn) ResidencesRoute else LoginRoute startDestination = startDestination
) { ) {
composable<LoginRoute> { composable<LoginRoute> {
LoginScreen( LoginScreen(
onLoginSuccess = { onLoginSuccess = { user ->
isLoggedIn = true isLoggedIn = true
isVerified = user.verified
// Initialize lookups after successful login // Initialize lookups after successful login
LookupsRepository.initialize() LookupsRepository.initialize()
// Check if user is verified
if (user.verified) {
navController.navigate(ResidencesRoute) { navController.navigate(ResidencesRoute) {
popUpTo<LoginRoute> { inclusive = true } popUpTo<LoginRoute> { inclusive = true }
} }
} else {
navController.navigate(VerifyEmailRoute) {
popUpTo<LoginRoute> { inclusive = true }
}
}
}, },
onNavigateToRegister = { onNavigateToRegister = {
navController.navigate(RegisterRoute) navController.navigate(RegisterRoute)
@@ -80,9 +134,10 @@ fun App() {
RegisterScreen( RegisterScreen(
onRegisterSuccess = { onRegisterSuccess = {
isLoggedIn = true isLoggedIn = true
isVerified = false
// Initialize lookups after successful registration // Initialize lookups after successful registration
LookupsRepository.initialize() LookupsRepository.initialize()
navController.navigate(ResidencesRoute) { navController.navigate(VerifyEmailRoute) {
popUpTo<RegisterRoute> { inclusive = true } popUpTo<RegisterRoute> { inclusive = true }
} }
}, },
@@ -92,6 +147,27 @@ fun App() {
) )
} }
composable<VerifyEmailRoute> {
VerifyEmailScreen(
onVerifySuccess = {
isVerified = true
navController.navigate(ResidencesRoute) {
popUpTo<VerifyEmailRoute> { inclusive = true }
}
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<VerifyEmailRoute> { inclusive = true }
}
}
)
}
composable<HomeRoute> { composable<HomeRoute> {
HomeScreen( HomeScreen(
onNavigateToResidences = { onNavigateToResidences = {
@@ -105,6 +181,7 @@ fun App() {
TokenStorage.clearToken() TokenStorage.clearToken()
LookupsRepository.clear() LookupsRepository.clear()
isLoggedIn = false isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) { navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true } popUpTo<HomeRoute> { inclusive = true }
} }
@@ -125,6 +202,7 @@ fun App() {
TokenStorage.clearToken() TokenStorage.clearToken()
LookupsRepository.clear() LookupsRepository.clear()
isLoggedIn = false isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) { navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true } popUpTo<HomeRoute> { inclusive = true }
} }

View File

@@ -12,7 +12,8 @@ data class User(
@SerialName("last_name") val lastName: String?, @SerialName("last_name") val lastName: String?,
@SerialName("is_staff") val isStaff: Boolean = false, @SerialName("is_staff") val isStaff: Boolean = false,
@SerialName("is_active") val isActive: Boolean = true, @SerialName("is_active") val isActive: Boolean = true,
@SerialName("date_joined") val dateJoined: String @SerialName("date_joined") val dateJoined: String,
val verified: Boolean = false
) )
@Serializable @Serializable
@@ -47,5 +48,17 @@ data class LoginRequest(
@Serializable @Serializable
data class AuthResponse( data class AuthResponse(
val token: String, 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
) )

View File

@@ -9,6 +9,9 @@ object LoginRoute
@Serializable @Serializable
object RegisterRoute object RegisterRoute
@Serializable
object VerifyEmailRoute
@Serializable @Serializable
object HomeRoute object HomeRoute

View File

@@ -4,4 +4,5 @@ sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>() data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>() data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>() object Loading : ApiResult<Nothing>()
object Idle : ApiResult<Nothing>()
} }

View File

@@ -74,4 +74,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
} }
} }
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
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<Map<String, String>>()
} 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")
}
}
} }

View File

@@ -126,6 +126,8 @@ fun HomeScreen(
is ApiResult.Error -> { is ApiResult.Error -> {
// Don't show error card, just let navigation cards show // Don't show error card, just let navigation cards show
} }
else -> {}
} }
// Residences Card // Residences Card

View File

@@ -21,7 +21,7 @@ import com.mycrib.shared.network.ApiResult
@Composable @Composable
fun LoginScreen( fun LoginScreen(
onLoginSuccess: () -> Unit, onLoginSuccess: (com.mycrib.shared.models.User) -> Unit,
onNavigateToRegister: () -> Unit, onNavigateToRegister: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() } viewModel: AuthViewModel = viewModel { AuthViewModel() }
) { ) {
@@ -33,7 +33,8 @@ fun LoginScreen(
LaunchedEffect(loginState) { LaunchedEffect(loginState) {
when (loginState) { when (loginState) {
is ApiResult.Success -> { is ApiResult.Success -> {
onLoginSuccess() val user = (loginState as ApiResult.Success).data.user
onLoginSuccess(user)
} }
else -> {} else -> {}
} }

View File

@@ -547,9 +547,13 @@ fun ResidenceDetailScreen(
} }
} }
} }
else -> {}
} }
} }
} }
else -> {}
} }
} }
} }

View File

@@ -298,6 +298,8 @@ fun ResidencesScreen(
} }
} }
} }
else -> {}
} }
} }
} }

View File

@@ -105,6 +105,8 @@ fun TasksScreen(
} }
} }
} }
else -> {}
} }
} }
} }

View File

@@ -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
)
}
}
}

View File

@@ -6,6 +6,9 @@ import com.mycrib.shared.models.AuthResponse
import com.mycrib.shared.models.LoginRequest import com.mycrib.shared.models.LoginRequest
import com.mycrib.shared.models.RegisterRequest import com.mycrib.shared.models.RegisterRequest
import com.mycrib.shared.models.Residence 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.ApiResult
import com.mycrib.shared.network.AuthApi import com.mycrib.shared.network.AuthApi
import com.mycrib.storage.TokenStorage import com.mycrib.storage.TokenStorage
@@ -16,12 +19,15 @@ import kotlinx.coroutines.launch
class AuthViewModel : ViewModel() { class AuthViewModel : ViewModel() {
private val authApi = AuthApi() private val authApi = AuthApi()
private val _loginState = MutableStateFlow<ApiResult<String>>(ApiResult.Loading) private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val loginState: StateFlow<ApiResult<String>> = _loginState val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState
private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Loading) private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val registerState: StateFlow<ApiResult<AuthResponse>> = _registerState val registerState: StateFlow<ApiResult<AuthResponse>> = _registerState
private val _verifyEmailState = MutableStateFlow<ApiResult<VerifyEmailResponse>>(ApiResult.Idle)
val verifyEmailState: StateFlow<ApiResult<VerifyEmailResponse>> = _verifyEmailState
fun login(username: String, password: String) { fun login(username: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
_loginState.value = ApiResult.Loading _loginState.value = ApiResult.Loading
@@ -30,7 +36,7 @@ class AuthViewModel : ViewModel() {
is ApiResult.Success -> { is ApiResult.Success -> {
// Store token for future API calls // Store token for future API calls
TokenStorage.saveToken(result.data.token) TokenStorage.saveToken(result.data.token)
ApiResult.Success(result.data.token) ApiResult.Success(result.data)
} }
is ApiResult.Error -> result is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error") else -> ApiResult.Error("Unknown error")
@@ -61,7 +67,31 @@ class AuthViewModel : ViewModel() {
} }
fun resetRegisterState() { 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() { fun logout() {

View File

@@ -4,6 +4,7 @@ struct LoginView: View {
@StateObject private var viewModel = LoginViewModel() @StateObject private var viewModel = LoginViewModel()
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@State private var showingRegister = false @State private var showingRegister = false
@State private var showingVerifyEmail = false
enum Field { enum Field {
case username, password case username, password
@@ -11,26 +12,28 @@ struct LoginView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack { Form {
// Background Section {
Color(.systemGroupedBackground)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Logo or App Name
LoginHeader()
// Login Form
VStack(spacing: 16) { VStack(spacing: 16) {
// Username Field Image(systemName: "house.fill")
VStack(alignment: .leading, spacing: 8) { .font(.system(size: 60))
Text("Username") .foregroundStyle(.blue.gradient)
Text("MyCrib")
.font(.largeTitle)
.fontWeight(.bold)
Text("Manage your properties with ease")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
TextField("Enter your username", text: $viewModel.username) Section {
.textFieldStyle(.roundedBorder) TextField("Username", text: $viewModel.username)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($focusedField, equals: .username) .focused($focusedField, equals: .username)
@@ -38,70 +41,78 @@ struct LoginView: View {
.onSubmit { .onSubmit {
focusedField = .password focusedField = .password
} }
}
// Password Field SecureField("Password", text: $viewModel.password)
VStack(alignment: .leading, spacing: 8) {
Text("Password")
.font(.subheadline)
.foregroundColor(.secondary)
SecureField("Enter your password", text: $viewModel.password)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .password) .focused($focusedField, equals: .password)
.submitLabel(.go) .submitLabel(.go)
.onSubmit { .onSubmit {
viewModel.login() viewModel.login()
} }
} header: {
Text("Account Information")
} }
// Error Message
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.subheadline)
}
}
} }
// Login Button Section {
Button(action: viewModel.login) { Button(action: viewModel.login) {
HStack { HStack {
Spacer()
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Text("Login") Text("Login")
.fontWeight(.semibold) .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)
}
}
.padding(.horizontal, 24)
Spacer() 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") .navigationTitle("Welcome Back")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.large)
.fullScreenCover(isPresented: $viewModel.isAuthenticated) { .fullScreenCover(isPresented: $viewModel.isAuthenticated) {
if viewModel.isVerified {
MainTabView() MainTabView()
} else {
VerifyEmailView(
onVerifySuccess: {
// After verification, show main tab view
viewModel.isVerified = true
},
onLogout: {
// Logout and dismiss verification screen
viewModel.logout()
}
)
}
} }
.sheet(isPresented: $showingRegister) { .sheet(isPresented: $showingRegister) {
RegisterView() RegisterView()

View File

@@ -10,6 +10,8 @@ class LoginViewModel: ObservableObject {
@Published var isLoading: Bool = false @Published var isLoading: Bool = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var isAuthenticated: Bool = false @Published var isAuthenticated: Bool = false
@Published var isVerified: Bool = false
@Published var currentUser: User?
// MARK: - Private Properties // MARK: - Private Properties
private let authApi: AuthApi private let authApi: AuthApi
@@ -77,6 +79,10 @@ class LoginViewModel: ObservableObject {
let user = results.data?.user { let user = results.data?.user {
self.tokenStorage.saveToken(token: token) self.tokenStorage.saveToken(token: token)
// Store user data and verification status
self.currentUser = user
self.isVerified = user.verified
// Initialize lookups repository after successful login // Initialize lookups repository after successful login
LookupsManager.shared.initialize() LookupsManager.shared.initialize()
@@ -85,7 +91,7 @@ class LoginViewModel: ObservableObject {
self.isLoading = false self.isLoading = false
print("Login successful! Token: token") print("Login successful! Token: token")
print("User: \(user.username)") print("User: \(user.username), Verified: \(user.verified)")
} }
} }

View File

@@ -1,9 +1,11 @@
import SwiftUI import SwiftUI
import ComposeApp
struct RegisterView: View { struct RegisterView: View {
@StateObject private var viewModel = RegisterViewModel() @StateObject private var viewModel = RegisterViewModel()
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@State private var showVerifyEmail = false
enum Field { enum Field {
case username, email, password, confirmPassword case username, email, password, confirmPassword
@@ -11,25 +13,28 @@ struct RegisterView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack { Form {
Color(.systemGroupedBackground) Section {
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Icon and Title
RegisterHeader()
// Registration Form
VStack(spacing: 16) { VStack(spacing: 16) {
// Username Field Image(systemName: "person.badge.plus")
VStack(alignment: .leading, spacing: 8) { .font(.system(size: 60))
Text("Username") .foregroundStyle(.blue.gradient)
Text("Join MyCrib")
.font(.largeTitle)
.fontWeight(.bold)
Text("Start managing your properties today")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
TextField("Enter your username", text: $viewModel.username) Section {
.textFieldStyle(.roundedBorder) TextField("Username", text: $viewModel.username)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($focusedField, equals: .username) .focused($focusedField, equals: .username)
@@ -37,16 +42,8 @@ struct RegisterView: View {
.onSubmit { .onSubmit {
focusedField = .email focusedField = .email
} }
}
// Email Field TextField("Email", text: $viewModel.email)
VStack(alignment: .leading, spacing: 8) {
Text("Email")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("Enter your email", text: $viewModel.email)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
@@ -55,70 +52,60 @@ struct RegisterView: View {
.onSubmit { .onSubmit {
focusedField = .password focusedField = .password
} }
} header: {
Text("Account Information")
} }
// Password Field Section {
VStack(alignment: .leading, spacing: 8) { SecureField("Password", text: $viewModel.password)
Text("Password")
.font(.subheadline)
.foregroundColor(.secondary)
SecureField("Enter your password", text: $viewModel.password)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .password) .focused($focusedField, equals: .password)
.submitLabel(.next) .submitLabel(.next)
.onSubmit { .onSubmit {
focusedField = .confirmPassword focusedField = .confirmPassword
} }
}
// Confirm Password Field SecureField("Confirm Password", text: $viewModel.confirmPassword)
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) .focused($focusedField, equals: .confirmPassword)
.submitLabel(.go) .submitLabel(.go)
.onSubmit { .onSubmit {
viewModel.register() viewModel.register()
} }
} header: {
Text("Security")
} footer: {
Text("Password must be secure")
} }
// Error Message
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.subheadline)
}
}
} }
// Register Button Section {
Button(action: viewModel.register) { Button(action: viewModel.register) {
HStack { HStack {
Spacer()
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Text("Create Account") Text("Create Account")
.fontWeight(.semibold) .fontWeight(.semibold)
} }
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.isLoading ? Color.gray : Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(viewModel.isLoading)
}
.padding(.horizontal, 24)
Spacer() Spacer()
} }
} }
.disabled(viewModel.isLoading)
}
} }
.navigationTitle("Create Account") .navigationTitle("Create Account")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.large)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { Button("Cancel") {
@@ -127,7 +114,18 @@ struct RegisterView: View {
} }
} }
.fullScreenCover(isPresented: $viewModel.isRegistered) { .fullScreenCover(isPresented: $viewModel.isRegistered) {
MainTabView() VerifyEmailView(
onVerifySuccess: {
dismiss()
showVerifyEmail = false
},
onLogout: {
// Logout and return to login screen
TokenManager().clearToken()
LookupsManager.shared.clear()
dismiss()
}
)
} }
} }
} }

View File

@@ -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") }
)
}

View File

@@ -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<VerifyEmailResponse> {
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<VerifyEmailResponse>) {
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
}
}