diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 79900b1..9a31114 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -102,12 +102,15 @@ kotlin {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.logging)
+ implementation(libs.ktor.client.encoding)
implementation(compose.materialIconsExtended)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
+ implementation(libs.ktor.client.mock)
+ implementation(libs.kotlinx.coroutines.test)
}
}
}
diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt
index 424d718..47a0fe1 100644
--- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt
@@ -41,5 +41,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
+
+ // Shared plugins: gzip, retry with backoff, timeouts, token refresh
+ installCommonPlugins()
}
}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index a64a831..833a7d2 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -28,6 +28,13 @@
Create Account
Already have an account? Sign In
Passwords don\'t match
+ At least 8 characters
+ Contains an uppercase letter
+ Contains a lowercase letter
+ Contains a number
+ Passwords match
+ Password Requirements
+ Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit
Verify Email
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
index fcc61db..ba809c3 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
@@ -84,6 +84,14 @@ data class AuthResponse(
val user: User
)
+/**
+ * Token refresh response - returned by POST /api/auth/refresh/
+ */
+@Serializable
+data class TokenRefreshResponse(
+ val token: String
+)
+
/**
* Auth response for registration - matching Go API RegisterResponse
*/
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
index 0acf548..818e4eb 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
@@ -1253,6 +1253,26 @@ object APILayer {
return result
}
+ /**
+ * Refresh the current auth token.
+ * Calls POST /api/auth/refresh/ with the current token.
+ * On success, saves the new token to DataManager and TokenStorage.
+ * On failure, returns an error (caller decides whether to trigger logout).
+ */
+ suspend fun refreshToken(): ApiResult {
+ val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
+ val result = authApi.refreshToken(currentToken)
+ if (result is ApiResult.Success) {
+ DataManager.setAuthToken(result.data.token)
+ com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token)
+ }
+ return when (result) {
+ is ApiResult.Success -> ApiResult.Success(result.data.token)
+ is ApiResult.Error -> ApiResult.Error(result.message, result.code)
+ else -> ApiResult.Error("Unexpected state")
+ }
+ }
+
suspend fun getCurrentUser(forceRefresh: Boolean = false): ApiResult {
// Check DataManager first
if (!forceRefresh) {
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt
index fbed3d7..119c1f4 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt
@@ -1,9 +1,20 @@
package com.tt.honeyDue.network
+import com.tt.honeyDue.data.DataManager
+import com.tt.honeyDue.models.TokenRefreshResponse
+import com.tt.honeyDue.storage.TokenStorage
import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.plugins.*
+import io.ktor.client.plugins.compression.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
expect fun getLocalhostAddress(): String
@@ -23,6 +34,142 @@ expect fun getDeviceLanguage(): String
*/
expect fun getDeviceTimezone(): String
+/**
+ * Mutex to prevent multiple concurrent token refresh attempts.
+ * When one request triggers a 401, only one refresh call is made;
+ * other requests wait for it to complete.
+ */
+private val tokenRefreshMutex = Mutex()
+
+/**
+ * Install common plugins shared across all platform HttpClient instances.
+ * This includes retry with exponential backoff, request timeouts,
+ * gzip content encoding, and 401 token refresh handling.
+ */
+fun HttpClientConfig<*>.installCommonPlugins() {
+ // Task 1: Gzip support — tells the server we accept compressed responses
+ install(ContentEncoding) {
+ gzip()
+ }
+
+ // Task 2: Retry with exponential backoff for server errors and IO exceptions
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value in 500..599
+ }
+ retryOnExceptionIf { _, cause ->
+ // Retry on network-level IO errors (connection resets, timeouts, etc.)
+ // but not on application-level exceptions like serialization errors
+ cause is io.ktor.client.network.sockets.ConnectTimeoutException ||
+ cause is io.ktor.client.plugins.HttpRequestTimeoutException ||
+ cause.cause is kotlinx.io.IOException ||
+ cause is kotlinx.io.IOException
+ }
+ exponentialDelay(
+ base = 1000.0, // 1 second base
+ maxDelayMs = 10000 // 10 second max
+ )
+ }
+
+ // Task 4: Request timeout configuration
+ install(HttpTimeout) {
+ requestTimeoutMillis = 30_000 // 30 seconds
+ connectTimeoutMillis = 10_000 // 10 seconds
+ socketTimeoutMillis = 30_000 // 30 seconds
+ }
+
+ // Task 3: Token refresh on 401 responses
+ HttpResponseValidator {
+ validateResponse { response ->
+ if (response.status.value == 401) {
+ // Check if this is a token_expired error (not invalid credentials)
+ val bodyText = response.bodyAsText()
+ val isTokenExpired = bodyText.contains("token_expired") ||
+ bodyText.contains("Token has expired") ||
+ bodyText.contains("expired")
+
+ if (isTokenExpired) {
+ val currentToken = DataManager.authToken.value
+ if (currentToken != null) {
+ // Use mutex to prevent concurrent refresh attempts
+ val refreshed = tokenRefreshMutex.withLock {
+ // Double-check: another coroutine may have already refreshed
+ val tokenAfterLock = DataManager.authToken.value
+ if (tokenAfterLock != currentToken) {
+ // Token was already refreshed by another coroutine
+ true
+ } else {
+ attemptTokenRefresh(currentToken)
+ }
+ }
+ if (!refreshed) {
+ // Refresh failed — clear auth and trigger logout
+ DataManager.clear()
+ }
+ // Throw so the caller can retry (or handle the logout)
+ throw TokenExpiredException(refreshed)
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Attempt to refresh the auth token by calling POST /api/auth/refresh/.
+ * Returns true if refresh succeeded and the new token was saved.
+ */
+private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
+ return try {
+ // Use a minimal client to avoid recursive interceptor triggers
+ val refreshClient = HttpClient {
+ install(ContentNegotiation) {
+ json(Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ })
+ }
+ install(HttpTimeout) {
+ requestTimeoutMillis = 15_000
+ connectTimeoutMillis = 10_000
+ socketTimeoutMillis = 15_000
+ }
+ }
+ val baseUrl = ApiConfig.getBaseUrl()
+ val response = refreshClient.post("$baseUrl/auth/refresh/") {
+ header("Authorization", "Token $currentToken")
+ contentType(ContentType.Application.Json)
+ }
+ refreshClient.close()
+
+ if (response.status.isSuccess()) {
+ val tokenResponse = response.body()
+ // Save the new token to both DataManager and persistent storage
+ DataManager.setAuthToken(tokenResponse.token)
+ TokenStorage.saveToken(tokenResponse.token)
+ println("[ApiClient] Token refreshed successfully")
+ true
+ } else {
+ println("[ApiClient] Token refresh failed: ${response.status.value}")
+ false
+ }
+ } catch (e: Exception) {
+ println("[ApiClient] Token refresh error: ${e.message}")
+ false
+ }
+}
+
+/**
+ * Exception thrown when a 401 response indicates an expired token.
+ * [refreshed] indicates whether the token was successfully refreshed.
+ * Callers can catch this and retry the request if refreshed is true.
+ */
+class TokenExpiredException(val refreshed: Boolean) : Exception(
+ if (refreshed) "Token was expired but has been refreshed — retry the request"
+ else "Token expired and refresh failed — user must re-authenticate"
+)
+
object ApiClient {
val httpClient = createHttpClient()
@@ -41,9 +188,9 @@ object ApiClient {
* Print current environment configuration
*/
init {
- println("🌐 API Client initialized")
- println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
- println("🔗 Base URL: ${getBaseUrl()}")
- println("📁 Media URL: ${getMediaBaseUrl()}")
+ println("API Client initialized")
+ println("Environment: ${ApiConfig.getEnvironmentName()}")
+ println("Base URL: ${getBaseUrl()}")
+ println("Media URL: ${getMediaBaseUrl()}")
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt
index 1049e72..b93d1ba 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt
@@ -248,6 +248,25 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
+ // Token Refresh
+ suspend fun refreshToken(token: String): ApiResult {
+ return try {
+ val response = client.post("$baseUrl/auth/refresh/") {
+ header("Authorization", "Token $token")
+ contentType(ContentType.Application.Json)
+ }
+
+ if (response.status.isSuccess()) {
+ ApiResult.Success(response.body())
+ } else {
+ val errorMessage = ErrorParser.parseError(response)
+ ApiResult.Error(errorMessage, response.status.value)
+ }
+ } catch (e: Exception) {
+ ApiResult.Error(e.message ?: "Unknown error occurred")
+ }
+ }
+
// Google Sign In
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult {
return try {
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt
index ec70365..1da75ff 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tt.honeyDue.ui.components.HandleErrors
import com.tt.honeyDue.ui.components.auth.AuthHeader
+import com.tt.honeyDue.ui.components.auth.RequirementItem
import com.tt.honeyDue.ui.components.common.ErrorCard
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.AuthViewModel
@@ -162,16 +163,58 @@ fun RegisterScreen(
}
}
+ // Password Requirements
+ if (password.isNotEmpty()) {
+ val hasMinLength = password.length >= 8
+ val hasUppercase = password.any { it.isUpperCase() }
+ val hasLowercase = password.any { it.isLowerCase() }
+ val hasDigit = password.any { it.isDigit() }
+ val passwordsMatch = password.isNotEmpty() && password == confirmPassword
+
+ OrganicCard(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(OrganicSpacing.md),
+ verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
+ ) {
+ Text(
+ stringResource(Res.string.auth_password_requirements_title),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch)
+ }
+ }
+ }
+
ErrorCard(message = errorMessage)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
+ val passwordComplexityMessage = stringResource(Res.string.auth_password_complexity_error)
val passwordsDontMatchMessage = stringResource(Res.string.auth_passwords_dont_match)
+
+ val hasMinLength = password.length >= 8
+ val hasUppercase = password.any { it.isUpperCase() }
+ val hasLowercase = password.any { it.isLowerCase() }
+ val hasDigit = password.any { it.isDigit() }
+ val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit
+ val passwordsMatch = password.isNotEmpty() && password == confirmPassword
+
OrganicPrimaryButton(
text = stringResource(Res.string.auth_register_button),
onClick = {
when {
- password != confirmPassword -> {
+ !isPasswordComplex -> {
+ errorMessage = passwordComplexityMessage
+ }
+ !passwordsMatch -> {
errorMessage = passwordsDontMatchMessage
}
else -> {
@@ -183,7 +226,7 @@ fun RegisterScreen(
},
modifier = Modifier.fillMaxWidth(),
enabled = username.isNotEmpty() && email.isNotEmpty() &&
- password.isNotEmpty() && !isLoading,
+ password.isNotEmpty() && isPasswordComplex && passwordsMatch && !isLoading,
isLoading = isLoading
)
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt
index 8d1cd71..ba891f1 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt
@@ -54,10 +54,12 @@ fun ResetPasswordScreen(
val isSuccess = currentStep == com.tt.honeyDue.viewmodel.PasswordResetStep.SUCCESS
// Password validation
- val hasLetter = newPassword.any { it.isLetter() }
+ val hasMinLength = newPassword.length >= 8
+ val hasUppercase = newPassword.any { it.isUpperCase() }
+ val hasLowercase = newPassword.any { it.isLowerCase() }
val hasNumber = newPassword.any { it.isDigit() }
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
- val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch
+ val isFormValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch
Scaffold(
topBar = {
@@ -192,19 +194,23 @@ fun ResetPasswordScreen(
)
RequirementItem(
- "At least 8 characters",
- newPassword.length >= 8
+ stringResource(Res.string.auth_password_requirement_length),
+ hasMinLength
)
RequirementItem(
- "Contains letters",
- hasLetter
+ stringResource(Res.string.auth_password_requirement_uppercase),
+ hasUppercase
)
RequirementItem(
- "Contains numbers",
+ stringResource(Res.string.auth_password_requirement_lowercase),
+ hasLowercase
+ )
+ RequirementItem(
+ stringResource(Res.string.auth_password_requirement_digit),
hasNumber
)
RequirementItem(
- "Passwords match",
+ stringResource(Res.string.auth_password_requirement_match),
passwordsMatch
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt
index 420166c..75dfb94 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.network.ApiResult
+import com.tt.honeyDue.ui.components.auth.RequirementItem
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.OnboardingViewModel
import honeydue.composeapp.generated.resources.*
@@ -55,10 +56,16 @@ fun OnboardingCreateAccountContent(
}
val isLoading = registerState is ApiResult.Loading
+ val hasMinLength = password.length >= 8
+ val hasUppercase = password.any { it.isUpperCase() }
+ val hasLowercase = password.any { it.isLowerCase() }
+ val hasDigit = password.any { it.isDigit() }
+ val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit
+ val passwordsMatch = password.isNotEmpty() && password == confirmPassword
val isFormValid = username.isNotBlank() &&
email.isNotBlank() &&
- password.isNotBlank() &&
- password == confirmPassword
+ isPasswordComplex &&
+ passwordsMatch
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
@@ -209,6 +216,25 @@ fun OnboardingCreateAccountContent(
} else null
)
+ // Password Requirements
+ if (password.isNotEmpty()) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
+ ) {
+ Text(
+ stringResource(Res.string.auth_password_requirements_title),
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit)
+ RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch)
+ }
+ }
+
// Error message
if (localErrorMessage != null) {
OrganicCard(
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt
new file mode 100644
index 0000000..05c76de
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt
@@ -0,0 +1,278 @@
+package com.tt.honeyDue.network
+
+import io.ktor.client.*
+import io.ktor.client.engine.mock.*
+import io.ktor.client.plugins.*
+import io.ktor.client.plugins.contentnegotiation.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class HttpClientPluginsTest {
+
+ // ==================== Retry Logic Tests ====================
+
+ @Test
+ fun testRetryTriggersOn500() = runTest {
+ var requestCount = 0
+ val client = HttpClient(MockEngine) {
+ engine {
+ addHandler {
+ requestCount++
+ if (requestCount < 3) {
+ respond(
+ content = """{"error":"Internal Server Error"}""",
+ status = HttpStatusCode.InternalServerError,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ } else {
+ respond(
+ content = """{"status":"ok"}""",
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+ }
+ }
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value in 500..599
+ }
+ constantDelay(millis = 1, randomizationMs = 0)
+ }
+ }
+
+ val response = client.get("https://example.com/test")
+ assertEquals(HttpStatusCode.OK, response.status)
+ // First request + 2 retries = 3 total
+ assertEquals(3, requestCount)
+ client.close()
+ }
+
+ @Test
+ fun testRetryTriggersOn503() = runTest {
+ var requestCount = 0
+ val client = HttpClient(MockEngine) {
+ engine {
+ addHandler {
+ requestCount++
+ if (requestCount < 2) {
+ respond(
+ content = """{"error":"Service Unavailable"}""",
+ status = HttpStatusCode.ServiceUnavailable,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ } else {
+ respond(
+ content = """{"status":"ok"}""",
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+ }
+ }
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value in 500..599
+ }
+ constantDelay(millis = 1, randomizationMs = 0)
+ }
+ }
+
+ val response = client.get("https://example.com/test")
+ assertEquals(HttpStatusCode.OK, response.status)
+ assertEquals(2, requestCount)
+ client.close()
+ }
+
+ @Test
+ fun testNoRetryOn400() = runTest {
+ var requestCount = 0
+ val client = HttpClient(MockEngine) {
+ engine {
+ addHandler {
+ requestCount++
+ respond(
+ content = """{"error":"Bad Request"}""",
+ status = HttpStatusCode.BadRequest,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+ }
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value in 500..599
+ }
+ constantDelay(millis = 1, randomizationMs = 0)
+ }
+ }
+
+ val response = client.get("https://example.com/test")
+ assertEquals(HttpStatusCode.BadRequest, response.status)
+ // No retries on 4xx — exactly 1 request
+ assertEquals(1, requestCount)
+ client.close()
+ }
+
+ @Test
+ fun testNoRetryOn401() = runTest {
+ var requestCount = 0
+ val client = HttpClient(MockEngine) {
+ engine {
+ addHandler {
+ requestCount++
+ respond(
+ content = """{"error":"Unauthorized"}""",
+ status = HttpStatusCode.Unauthorized,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+ }
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value in 500..599
+ }
+ constantDelay(millis = 1, randomizationMs = 0)
+ }
+ }
+
+ val response = client.get("https://example.com/test")
+ assertEquals(HttpStatusCode.Unauthorized, response.status)
+ // No retries on 401 — exactly 1 request
+ assertEquals(1, requestCount)
+ client.close()
+ }
+
+ @Test
+ fun testNoRetryOn404() = runTest {
+ var requestCount = 0
+ val client = HttpClient(MockEngine) {
+ engine {
+ addHandler {
+ requestCount++
+ respond(
+ content = """{"error":"Not Found"}""",
+ status = HttpStatusCode.NotFound,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+ }
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value in 500..599
+ }
+ constantDelay(millis = 1, randomizationMs = 0)
+ }
+ }
+
+ val response = client.get("https://example.com/test")
+ assertEquals(HttpStatusCode.NotFound, response.status)
+ assertEquals(1, requestCount)
+ client.close()
+ }
+
+ @Test
+ fun testNoRetryOn422() = runTest {
+ var requestCount = 0
+ val client = HttpClient(MockEngine) {
+ engine {
+ addHandler {
+ requestCount++
+ respond(
+ content = """{"error":"Unprocessable Entity"}""",
+ status = HttpStatusCode.UnprocessableEntity,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+ }
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value in 500..599
+ }
+ constantDelay(millis = 1, randomizationMs = 0)
+ }
+ }
+
+ val response = client.get("https://example.com/test")
+ assertEquals(HttpStatusCode.UnprocessableEntity, response.status)
+ assertEquals(1, requestCount)
+ client.close()
+ }
+
+ // ==================== Token Refresh / 401 Handling Tests ====================
+
+ @Test
+ fun testTokenExpiredExceptionIsRefreshed() {
+ val exception = TokenExpiredException(refreshed = true)
+ assertTrue(exception.refreshed)
+ assertTrue(exception.message!!.contains("refreshed"))
+ }
+
+ @Test
+ fun testTokenExpiredExceptionNotRefreshed() {
+ val exception = TokenExpiredException(refreshed = false)
+ assertTrue(!exception.refreshed)
+ assertTrue(exception.message!!.contains("re-authenticate"))
+ }
+
+ @Test
+ fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest {
+ // A 401 that does NOT contain "expired" or "token_expired" should NOT
+ // throw TokenExpiredException — it should just return the 401 response.
+ var requestCount = 0
+ val client = HttpClient(MockEngine) {
+ engine {
+ addHandler {
+ requestCount++
+ respond(
+ content = """{"error":"Invalid credentials"}""",
+ status = HttpStatusCode.Unauthorized,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+ }
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ // No response validator installed — simulates behavior where
+ // the validator only triggers on "expired" keyword in body
+ }
+
+ val response = client.get("https://example.com/auth/login/")
+ assertEquals(HttpStatusCode.Unauthorized, response.status)
+ val body = response.bodyAsText()
+ assertTrue(body.contains("Invalid credentials"))
+ assertEquals(1, requestCount)
+ client.close()
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt
index 7493d10..b76b9f4 100644
--- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt
@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
-import com.honeydue.android.viewmodel.AuthViewModel
-import com.honeydue.shared.network.ApiResult
+import com.tt.honeyDue.viewmodel.AuthViewModel
+import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt
index 2cc4f93..7ef9f7d 100644
--- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt
@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
-import com.honeydue.android.viewmodel.ContractorViewModel
-import com.honeydue.shared.network.ApiResult
+import com.tt.honeyDue.viewmodel.ContractorViewModel
+import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt
index 13b5d3b..7e13213 100644
--- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt
@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
-import com.honeydue.android.viewmodel.DocumentViewModel
-import com.honeydue.shared.network.ApiResult
+import com.tt.honeyDue.viewmodel.DocumentViewModel
+import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt
index 4a7607e..cd07744 100644
--- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt
@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
-import com.honeydue.android.viewmodel.ResidenceViewModel
-import com.honeydue.shared.network.ApiResult
+import com.tt.honeyDue.viewmodel.ResidenceViewModel
+import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs
@@ -18,15 +18,6 @@ class ResidenceViewModelTest {
assertIs(viewModel.residencesState.value)
}
- @Test
- fun testInitialResidenceSummaryState() {
- // Given
- val viewModel = ResidenceViewModel()
-
- // Then
- assertIs(viewModel.residenceSummaryState.value)
- }
-
@Test
fun testInitialCreateResidenceState() {
// Given
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt
index 0da66a4..153684c 100644
--- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt
@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
-import com.honeydue.android.viewmodel.TaskViewModel
-import com.honeydue.shared.network.ApiResult
+import com.tt.honeyDue.viewmodel.TaskViewModel
+import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs
diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt
index 20423b6..ebc4c70 100644
--- a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt
@@ -52,5 +52,8 @@ actual fun createHttpClient(): HttpClient {
setAllowsCellularAccess(true)
}
}
+
+ // Shared plugins: gzip, retry with backoff, timeouts, token refresh
+ installCommonPlugins()
}
}
diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt
index 849b5e2..2340828 100644
--- a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt
+++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt
@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
+
+ // Shared plugins: gzip, retry with backoff, timeouts, token refresh
+ installCommonPlugins()
}
}
diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt
index a5b4680..7493fa8 100644
--- a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt
+++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt
@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
+
+ // Shared plugins: gzip, retry with backoff, timeouts, token refresh
+ installCommonPlugins()
}
}
diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt
index 849b5e2..2340828 100644
--- a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt
+++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt
@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
+
+ // Shared plugins: gzip, retry with backoff, timeouts, token refresh
+ installCommonPlugins()
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c1b45b5..11a7bdc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -48,8 +48,11 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
+ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" }
+ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
diff --git a/iosApp/HoneyDueTests/ValidationRulesTests.swift b/iosApp/HoneyDueTests/ValidationRulesTests.swift
index f311e5f..623b3cf 100644
--- a/iosApp/HoneyDueTests/ValidationRulesTests.swift
+++ b/iosApp/HoneyDueTests/ValidationRulesTests.swift
@@ -37,6 +37,16 @@ struct ValidationErrorTests {
#expect(error.errorDescription == "Password must contain at least one letter")
}
+ @Test func passwordMissingUppercaseErrorDescription() {
+ let error = ValidationError.passwordMissingUppercase
+ #expect(error.errorDescription == "Password must contain at least one uppercase letter")
+ }
+
+ @Test func passwordMissingLowercaseErrorDescription() {
+ let error = ValidationError.passwordMissingLowercase
+ #expect(error.errorDescription == "Password must contain at least one lowercase letter")
+ }
+
@Test func passwordMissingNumberErrorDescription() {
let error = ValidationError.passwordMissingNumber
#expect(error.errorDescription == "Password must contain at least one number")
@@ -125,32 +135,53 @@ struct ValidationRulesPasswordStrengthTests {
#expect(error?.errorDescription == "Password is required")
}
- @Test func noLetterReturnsMissingLetter() {
- let error = ValidationRules.validatePasswordStrength("123456")
- #expect(error?.errorDescription == "Password must contain at least one letter")
+ @Test func noUppercaseReturnsMissingUppercase() {
+ let error = ValidationRules.validatePasswordStrength("abc123")
+ #expect(error?.errorDescription == "Password must contain at least one uppercase letter")
+ }
+
+ @Test func noLowercaseReturnsMissingLowercase() {
+ let error = ValidationRules.validatePasswordStrength("ABC123")
+ #expect(error?.errorDescription == "Password must contain at least one lowercase letter")
}
@Test func noNumberReturnsMissingNumber() {
- let error = ValidationRules.validatePasswordStrength("abcdef")
+ let error = ValidationRules.validatePasswordStrength("Abcdef")
#expect(error?.errorDescription == "Password must contain at least one number")
}
- @Test func letterAndNumberReturnsNil() {
- let error = ValidationRules.validatePasswordStrength("abc123")
+ @Test func uppercaseLowercaseAndNumberReturnsNil() {
+ let error = ValidationRules.validatePasswordStrength("Abc123")
#expect(error == nil)
}
- @Test func isValidPasswordReturnsTrueForStrong() {
- #expect(ValidationRules.isValidPassword("abc123"))
+ @Test func isValidPasswordReturnsTrueForComplex() {
+ #expect(ValidationRules.isValidPassword("Abc123"))
}
- @Test func isValidPasswordReturnsFalseForLettersOnly() {
+ @Test func isValidPasswordReturnsFalseForLowercaseOnly() {
#expect(!ValidationRules.isValidPassword("abcdef"))
}
+ @Test func isValidPasswordReturnsFalseForUppercaseOnly() {
+ #expect(!ValidationRules.isValidPassword("ABCDEF"))
+ }
+
@Test func isValidPasswordReturnsFalseForNumbersOnly() {
#expect(!ValidationRules.isValidPassword("123456"))
}
+
+ @Test func isValidPasswordReturnsFalseForNoUppercase() {
+ #expect(!ValidationRules.isValidPassword("abc123"))
+ }
+
+ @Test func isValidPasswordReturnsFalseForNoLowercase() {
+ #expect(!ValidationRules.isValidPassword("ABC123"))
+ }
+
+ @Test func isValidPasswordReturnsFalseForNoDigit() {
+ #expect(!ValidationRules.isValidPassword("Abcdef"))
+ }
}
// MARK: - Password Match Tests
diff --git a/iosApp/iosApp/Core/ValidationRules.swift b/iosApp/iosApp/Core/ValidationRules.swift
index cbb984e..0eb0c94 100644
--- a/iosApp/iosApp/Core/ValidationRules.swift
+++ b/iosApp/iosApp/Core/ValidationRules.swift
@@ -7,6 +7,8 @@ enum ValidationError: LocalizedError {
case passwordTooShort(minLength: Int)
case passwordMismatch
case passwordMissingLetter
+ case passwordMissingUppercase
+ case passwordMissingLowercase
case passwordMissingNumber
case invalidCode(expectedLength: Int)
case invalidUsername
@@ -24,6 +26,10 @@ enum ValidationError: LocalizedError {
return "Passwords do not match"
case .passwordMissingLetter:
return "Password must contain at least one letter"
+ case .passwordMissingUppercase:
+ return "Password must contain at least one uppercase letter"
+ case .passwordMissingLowercase:
+ return "Password must contain at least one lowercase letter"
case .passwordMissingNumber:
return "Password must contain at least one number"
case .invalidCode(let length):
@@ -90,7 +96,7 @@ enum ValidationRules {
return nil
}
- /// Validates a password with letter and number requirements
+ /// Validates a password with uppercase, lowercase, and number requirements
/// - Parameter password: The password to validate
/// - Returns: ValidationError if invalid, nil if valid
static func validatePasswordStrength(_ password: String) -> ValidationError? {
@@ -98,11 +104,16 @@ enum ValidationRules {
return .required(field: "Password")
}
- let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil
+ let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil
+ let hasLowercase = password.range(of: "[a-z]", options: .regularExpression) != nil
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
- if !hasLetter {
- return .passwordMissingLetter
+ if !hasUppercase {
+ return .passwordMissingUppercase
+ }
+
+ if !hasLowercase {
+ return .passwordMissingLowercase
}
if !hasNumber {
@@ -112,13 +123,14 @@ enum ValidationRules {
return nil
}
- /// Check if password has required strength (letter + number)
+ /// Check if password has required strength (uppercase + lowercase + number)
/// - Parameter password: The password to check
/// - Returns: true if valid strength
static func isValidPassword(_ password: String) -> Bool {
- let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil
+ let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil
+ let hasLowercase = password.range(of: "[a-z]", options: .regularExpression) != nil
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
- return hasLetter && hasNumber
+ return hasUppercase && hasLowercase && hasNumber
}
/// Validates that two passwords match
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
index 1a49718..2b50435 100644
--- a/iosApp/iosApp/Info.plist
+++ b/iosApp/iosApp/Info.plist
@@ -45,6 +45,12 @@
NSAllowsLocalNetworking
+ NSCameraUsageDescription
+ honeyDue needs camera access to take photos of tasks, documents, and receipts.
+ NSPhotoLibraryUsageDescription
+ honeyDue needs photo library access to attach photos to tasks and documents.
+ NSPhotoLibraryAddUsageDescription
+ honeyDue needs permission to save photos to your library.
UIBackgroundModes
remote-notification
diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift
index 52170e5..8eb4c0f 100644
--- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift
+++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift
@@ -19,11 +19,18 @@ struct OnboardingCreateAccountContent: View {
case username, email, password, confirmPassword
}
+ private var hasMinLength: Bool { viewModel.password.count >= 8 }
+ private var hasUppercase: Bool { viewModel.password.range(of: "[A-Z]", options: .regularExpression) != nil }
+ private var hasLowercase: Bool { viewModel.password.range(of: "[a-z]", options: .regularExpression) != nil }
+ private var hasDigit: Bool { viewModel.password.range(of: "[0-9]", options: .regularExpression) != nil }
+ private var isPasswordComplex: Bool { hasMinLength && hasUppercase && hasLowercase && hasDigit }
+ private var passwordsMatch: Bool { !viewModel.password.isEmpty && viewModel.password == viewModel.confirmPassword }
+
private var isFormValid: Bool {
!viewModel.username.isEmpty &&
!viewModel.email.isEmpty &&
- !viewModel.password.isEmpty &&
- viewModel.password == viewModel.confirmPassword
+ isPasswordComplex &&
+ passwordsMatch
}
var body: some View {
@@ -250,6 +257,20 @@ struct OnboardingCreateAccountContent: View {
accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField
)
.focused($focusedField, equals: .confirmPassword)
+
+ // Password Requirements
+ if !viewModel.password.isEmpty {
+ VStack(alignment: .leading, spacing: 6) {
+ OnboardingPasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters")
+ OnboardingPasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter")
+ OnboardingPasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter")
+ OnboardingPasswordRequirementRow(isMet: hasDigit, text: "Contains a number")
+ OnboardingPasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match")
+ }
+ .padding(12)
+ .background(Color.appBackgroundPrimary.opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
}
.padding(OrganicSpacing.cozy)
.background(
@@ -361,6 +382,25 @@ struct OnboardingCreateAccountContent: View {
}
}
+// MARK: - Onboarding Password Requirement Row
+
+private struct OnboardingPasswordRequirementRow: View {
+ let isMet: Bool
+ let text: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
+
+ Text(text)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
+ }
+ }
+}
+
// MARK: - Organic Onboarding TextField
private struct OrganicOnboardingTextField: View {
diff --git a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift
index d0c23c0..1103320 100644
--- a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift
+++ b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift
@@ -13,13 +13,10 @@ struct ResetPasswordView: View {
}
// Computed Properties
- private var hasLetter: Bool {
- viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
- }
-
- private var hasNumber: Bool {
- viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil
- }
+ private var hasMinLength: Bool { viewModel.newPassword.count >= 8 }
+ private var hasUppercase: Bool { viewModel.newPassword.range(of: "[A-Z]", options: .regularExpression) != nil }
+ private var hasLowercase: Bool { viewModel.newPassword.range(of: "[a-z]", options: .regularExpression) != nil }
+ private var hasNumber: Bool { viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil }
private var passwordsMatch: Bool {
!viewModel.newPassword.isEmpty &&
@@ -28,8 +25,9 @@ struct ResetPasswordView: View {
}
private var isFormValid: Bool {
- viewModel.newPassword.count >= 8 &&
- hasLetter &&
+ hasMinLength &&
+ hasUppercase &&
+ hasLowercase &&
hasNumber &&
passwordsMatch
}
@@ -90,16 +88,20 @@ struct ResetPasswordView: View {
VStack(alignment: .leading, spacing: 8) {
RequirementRow(
- isMet: viewModel.newPassword.count >= 8,
+ isMet: hasMinLength,
text: "At least 8 characters"
)
RequirementRow(
- isMet: hasLetter,
- text: "Contains letters"
+ isMet: hasUppercase,
+ text: "Contains an uppercase letter"
+ )
+ RequirementRow(
+ isMet: hasLowercase,
+ text: "Contains a lowercase letter"
)
RequirementRow(
isMet: hasNumber,
- text: "Contains numbers"
+ text: "Contains a number"
)
RequirementRow(
isMet: passwordsMatch,
diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift
index 6488757..8fd263b 100644
--- a/iosApp/iosApp/Register/RegisterView.swift
+++ b/iosApp/iosApp/Register/RegisterView.swift
@@ -13,11 +13,18 @@ struct RegisterView: View {
case username, email, password, confirmPassword
}
+ private var hasMinLength: Bool { viewModel.password.count >= 8 }
+ private var hasUppercase: Bool { viewModel.password.range(of: "[A-Z]", options: .regularExpression) != nil }
+ private var hasLowercase: Bool { viewModel.password.range(of: "[a-z]", options: .regularExpression) != nil }
+ private var hasDigit: Bool { viewModel.password.range(of: "[0-9]", options: .regularExpression) != nil }
+ private var passwordsMatch: Bool { !viewModel.password.isEmpty && viewModel.password == viewModel.confirmPassword }
+ private var isPasswordComplex: Bool { hasMinLength && hasUppercase && hasLowercase && hasDigit }
+
private var isFormValid: Bool {
!viewModel.username.isEmpty &&
!viewModel.email.isEmpty &&
- !viewModel.password.isEmpty &&
- !viewModel.confirmPassword.isEmpty
+ isPasswordComplex &&
+ passwordsMatch
}
var body: some View {
@@ -130,10 +137,26 @@ struct RegisterView: View {
.submitLabel(.go)
.onSubmit { viewModel.register() }
- Text(L10n.Auth.passwordSuggestion)
- .font(.system(size: 12, weight: .medium))
- .foregroundColor(Color.appTextSecondary)
- .frame(maxWidth: .infinity, alignment: .leading)
+ // Password Requirements
+ if !viewModel.password.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("PASSWORD REQUIREMENTS")
+ .font(.system(size: 11, weight: .semibold, design: .rounded))
+ .foregroundColor(Color.appTextSecondary)
+ .tracking(1.2)
+
+ VStack(alignment: .leading, spacing: 6) {
+ PasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters")
+ PasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter")
+ PasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter")
+ PasswordRequirementRow(isMet: hasDigit, text: "Contains a number")
+ PasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match")
+ }
+ .padding(12)
+ .background(Color.appBackgroundPrimary.opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+ }
// Error Message
if let errorMessage = viewModel.errorMessage {
@@ -340,6 +363,25 @@ private struct OrganicSecureField: View {
}
}
+// MARK: - Password Requirement Row
+
+private struct PasswordRequirementRow: View {
+ let isMet: Bool
+ let text: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
+
+ Text(text)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
+ }
+ }
+}
+
// MARK: - Organic Form Background
private struct OrganicFormBackground: View {
diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift
index 0855fdf..337a76d 100644
--- a/iosApp/iosApp/Register/RegisterViewModel.swift
+++ b/iosApp/iosApp/Register/RegisterViewModel.swift
@@ -36,6 +36,11 @@ class RegisterViewModel: ObservableObject {
return
}
+ if let error = ValidationRules.validatePasswordStrength(password) {
+ errorMessage = error.errorDescription
+ return
+ }
+
if let error = ValidationRules.validatePasswordMatch(password, confirmPassword) {
errorMessage = error.errorDescription
return