feat(mobile): add auth login/register flow for iOS and Android
- iOS: Add KeychainHelper.swift for secure token storage - iOS: Add AuthService.swift (CMAuthService) with login/register/refresh/logout - iOS: Add LoginView.swift (CMLoginView) with ChronoMind theme - iOS: Wire auth gate in ChronoMindApp.swift (LoginView vs ContentView) - iOS: Add Account section to SettingsView with email/plan/sign-out - iOS: Add Cloud group + 3 files to Xcode project.pbxproj - Android: Add AuthService.kt with Hilt @Singleton, login/register/refresh/logout - Android: Add LoginScreen.kt with Compose login/register form - Android: Wire auth gate in MainActivity via Hilt-injected AuthService - Android: Add Account section to SettingsScreen via HiltViewModel - Android: Add x-product-id header to PlatformApiClient
This commit is contained in:
parent
5e8cbbf556
commit
6a41cc9f48
@ -6,24 +6,39 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.chronomind.app.auth.AuthService
|
||||||
|
import com.chronomind.app.auth.AuthState
|
||||||
|
import com.chronomind.app.auth.LoginScreen
|
||||||
import com.chronomind.app.ui.navigation.ChronoMindNavHost
|
import com.chronomind.app.ui.navigation.ChronoMindNavHost
|
||||||
import com.chronomind.app.ui.theme.CMColors
|
import com.chronomind.app.ui.theme.CMColors
|
||||||
import com.chronomind.app.ui.theme.ChronoMindTheme
|
import com.chronomind.app.ui.theme.ChronoMindTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
@Inject lateinit var authService: AuthService
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
authService.checkExistingSession()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ChronoMindTheme {
|
ChronoMindTheme {
|
||||||
Surface(
|
val authState by authService.state.collectAsState()
|
||||||
modifier = Modifier.fillMaxSize(),
|
if (authState is AuthState.LoggedIn) {
|
||||||
color = CMColors.bg
|
Surface(
|
||||||
) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
ChronoMindNavHost()
|
color = CMColors.bg
|
||||||
|
) {
|
||||||
|
ChronoMindNavHost()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LoginScreen(authService = authService)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
247
android/app/src/main/java/com/chronomind/app/auth/AuthService.kt
Normal file
247
android/app/src/main/java/com/chronomind/app/auth/AuthService.kt
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
package com.chronomind.app.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private const val PREFS_NAME = "chronomind_auth"
|
||||||
|
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||||
|
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||||
|
private const val KEY_USER_EMAIL = "user_email"
|
||||||
|
private const val KEY_USER_NAME = "user_name"
|
||||||
|
private const val KEY_USER_PLAN = "user_plan"
|
||||||
|
private const val KEY_USER_ID = "user_id"
|
||||||
|
|
||||||
|
private const val PRODUCT_ID = "chronomind"
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthUser(
|
||||||
|
val id: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
@SerialName("displayName")
|
||||||
|
val name: String = "",
|
||||||
|
val plan: String = "free",
|
||||||
|
val role: String = "user",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TokenResponse(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val user: AuthUser,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RefreshResponse(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class AuthState {
|
||||||
|
data object Loading : AuthState()
|
||||||
|
data object LoggedOut : AuthState()
|
||||||
|
data class LoggedIn(val user: AuthUser) : AuthState()
|
||||||
|
data class Error(val message: String) : AuthState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthService @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
|
||||||
|
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
val isLoggedIn: Boolean
|
||||||
|
get() = _state.value is AuthState.LoggedIn
|
||||||
|
|
||||||
|
val currentUser: AuthUser?
|
||||||
|
get() = (_state.value as? AuthState.LoggedIn)?.user
|
||||||
|
|
||||||
|
fun getAccessToken(): String? {
|
||||||
|
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||||
|
return if (token.isNullOrBlank()) null else token
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBaseUrl(): String {
|
||||||
|
return context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE)
|
||||||
|
.getString("base_url", null) ?: "https://api.chronomind.app"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkExistingSession() {
|
||||||
|
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||||
|
val email = prefs.getString(KEY_USER_EMAIL, null)
|
||||||
|
if (!token.isNullOrBlank() && !email.isNullOrBlank()) {
|
||||||
|
val name = prefs.getString(KEY_USER_NAME, "") ?: ""
|
||||||
|
val plan = prefs.getString(KEY_USER_PLAN, "free") ?: "free"
|
||||||
|
val id = prefs.getString(KEY_USER_ID, "") ?: ""
|
||||||
|
_state.value = AuthState.LoggedIn(AuthUser(id = id, email = email, name = name, plan = plan))
|
||||||
|
wireSyncToken()
|
||||||
|
} else {
|
||||||
|
_state.value = AuthState.LoggedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(email: String, password: String) {
|
||||||
|
_state.value = AuthState.Loading
|
||||||
|
val body = json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("email" to email, "password" to password, "productId" to PRODUCT_ID),
|
||||||
|
)
|
||||||
|
val result = postAuth("/auth/login", body)
|
||||||
|
if (result == null) {
|
||||||
|
_state.value = AuthState.Error("Invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleAuthResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(name: String, email: String, password: String) {
|
||||||
|
_state.value = AuthState.Loading
|
||||||
|
val body = json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf(
|
||||||
|
"email" to email,
|
||||||
|
"displayName" to name,
|
||||||
|
"password" to password,
|
||||||
|
"productId" to PRODUCT_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = postAuth("/auth/register", body)
|
||||||
|
if (result == null) {
|
||||||
|
_state.value = AuthState.Error("Registration failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleAuthResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove(KEY_ACCESS_TOKEN)
|
||||||
|
.remove(KEY_REFRESH_TOKEN)
|
||||||
|
.remove(KEY_USER_EMAIL)
|
||||||
|
.remove(KEY_USER_NAME)
|
||||||
|
.remove(KEY_USER_PLAN)
|
||||||
|
.remove(KEY_USER_ID)
|
||||||
|
.apply()
|
||||||
|
_state.value = AuthState.LoggedOut
|
||||||
|
// Clear sync token
|
||||||
|
context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE)
|
||||||
|
.edit().remove("auth_token").apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val rt = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return@withContext false
|
||||||
|
val baseUrl = getBaseUrl()
|
||||||
|
|
||||||
|
val body = json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("refreshToken" to rt),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val url = URL("$baseUrl/auth/refresh")
|
||||||
|
val conn = url.openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = "POST"
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.setRequestProperty("X-Product-Id", PRODUCT_ID)
|
||||||
|
conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString())
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 10_000
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.outputStream.use { it.write(body.toByteArray()) }
|
||||||
|
|
||||||
|
if (conn.responseCode == 200) {
|
||||||
|
val responseBody = conn.inputStream.bufferedReader().readText()
|
||||||
|
val refreshResp = json.decodeFromString<RefreshResponse>(responseBody)
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACCESS_TOKEN, refreshResp.accessToken)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, refreshResp.refreshToken)
|
||||||
|
.apply()
|
||||||
|
wireSyncToken()
|
||||||
|
true
|
||||||
|
} else if (conn.responseCode == 401) {
|
||||||
|
withContext(Dispatchers.Main) { logout() }
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleAuthResult(responseBody: String) {
|
||||||
|
try {
|
||||||
|
val tokenResp = json.decodeFromString<TokenResponse>(responseBody)
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACCESS_TOKEN, tokenResp.accessToken)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, tokenResp.refreshToken)
|
||||||
|
.putString(KEY_USER_EMAIL, tokenResp.user.email)
|
||||||
|
.putString(KEY_USER_NAME, tokenResp.user.name)
|
||||||
|
.putString(KEY_USER_PLAN, tokenResp.user.plan)
|
||||||
|
.putString(KEY_USER_ID, tokenResp.user.id)
|
||||||
|
.apply()
|
||||||
|
_state.value = AuthState.LoggedIn(tokenResp.user)
|
||||||
|
wireSyncToken()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = AuthState.Error(e.message ?: "Parse error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wireSyncToken() {
|
||||||
|
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||||
|
context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE)
|
||||||
|
.edit().putString("auth_token", token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun postAuth(path: String, body: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
val baseUrl = getBaseUrl()
|
||||||
|
try {
|
||||||
|
val url = URL("$baseUrl$path")
|
||||||
|
val conn = url.openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = "POST"
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.setRequestProperty("X-Product-Id", PRODUCT_ID)
|
||||||
|
conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString())
|
||||||
|
conn.connectTimeout = 15_000
|
||||||
|
conn.readTimeout = 15_000
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.outputStream.use { it.write(body.toByteArray()) }
|
||||||
|
|
||||||
|
if (conn.responseCode in 200..201) {
|
||||||
|
conn.inputStream.bufferedReader().readText()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
208
android/app/src/main/java/com/chronomind/app/auth/LoginScreen.kt
Normal file
208
android/app/src/main/java/com/chronomind/app/auth/LoginScreen.kt
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package com.chronomind.app.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.chronomind.app.ui.theme.CMColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(authService: AuthService) {
|
||||||
|
val authState by authService.state.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var isRegister by remember { mutableStateOf(false) }
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val emailRegex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
||||||
|
val isValidEmail = emailRegex.matches(email)
|
||||||
|
val isValidPassword = password.length >= 8
|
||||||
|
&& password.any { it.isUpperCase() }
|
||||||
|
&& password.any { it.isLowerCase() }
|
||||||
|
&& password.any { it.isDigit() }
|
||||||
|
val isValidName = !isRegister || name.trim().isNotEmpty()
|
||||||
|
val canSubmit = isValidEmail && isValidPassword && isValidName && authState !is AuthState.Loading
|
||||||
|
|
||||||
|
val fieldColors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = CMColors.text,
|
||||||
|
unfocusedTextColor = CMColors.text,
|
||||||
|
focusedContainerColor = CMColors.surface,
|
||||||
|
unfocusedContainerColor = CMColors.surface,
|
||||||
|
focusedBorderColor = CMColors.accent,
|
||||||
|
unfocusedBorderColor = Color.Transparent,
|
||||||
|
focusedLabelColor = CMColors.accent,
|
||||||
|
unfocusedLabelColor = CMColors.textSecondary,
|
||||||
|
cursorColor = CMColors.accent,
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Brush.verticalGradient(listOf(CMColors.bg, CMColors.surface)))
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⏱",
|
||||||
|
fontSize = 48.sp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "ChronoMind",
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = CMColors.text,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (isRegister) "Create your account" else "Sign in to your account",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = CMColors.textSecondary,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
if (isRegister) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Full Name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = fieldColors,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("Email") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = fieldColors,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = fieldColors,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (password.isNotEmpty() && isRegister && !isValidPassword) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Password needs: 8+ chars, uppercase, lowercase, digit",
|
||||||
|
color = CMColors.warning,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState is AuthState.Error) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = (authState as AuthState.Error).message,
|
||||||
|
color = CMColors.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (isRegister) authService.register(name, email, password)
|
||||||
|
else authService.login(email, password)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
enabled = canSubmit,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent),
|
||||||
|
) {
|
||||||
|
if (authState is AuthState.Loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = if (isRegister) "Create Account" else "Sign In",
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = if (isRegister) "Already have an account?" else "Don't have an account?",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = CMColors.textSecondary,
|
||||||
|
)
|
||||||
|
TextButton(onClick = { isRegister = !isRegister }) {
|
||||||
|
Text(
|
||||||
|
text = if (isRegister) "Sign In" else "Register",
|
||||||
|
color = CMColors.accent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -149,6 +149,7 @@ class PlatformApiClient(
|
|||||||
conn.requestMethod = method
|
conn.requestMethod = method
|
||||||
conn.setRequestProperty("Content-Type", "application/json")
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
conn.setRequestProperty("x-request-id", java.util.UUID.randomUUID().toString())
|
conn.setRequestProperty("x-request-id", java.util.UUID.randomUUID().toString())
|
||||||
|
conn.setRequestProperty("x-product-id", "chronomind")
|
||||||
conn.connectTimeout = 15_000
|
conn.connectTimeout = 15_000
|
||||||
conn.readTimeout = 15_000
|
conn.readTimeout = 15_000
|
||||||
authToken?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
authToken?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||||
|
|||||||
@ -9,13 +9,25 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.chronomind.app.auth.AuthService
|
||||||
|
import com.chronomind.app.auth.AuthState
|
||||||
import com.chronomind.app.engine.CascadePreset
|
import com.chronomind.app.engine.CascadePreset
|
||||||
import com.chronomind.app.engine.UrgencyLevel
|
import com.chronomind.app.engine.UrgencyLevel
|
||||||
import com.chronomind.app.engine.getUrgencyConfig
|
import com.chronomind.app.engine.getUrgencyConfig
|
||||||
import com.chronomind.app.ui.theme.CMColors
|
import com.chronomind.app.ui.theme.CMColors
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
val authService: AuthService,
|
||||||
|
) : ViewModel()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen() {
|
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
|
||||||
|
val authState by viewModel.authService.state.collectAsState()
|
||||||
var defaultUrgency by remember { mutableStateOf(UrgencyLevel.STANDARD) }
|
var defaultUrgency by remember { mutableStateOf(UrgencyLevel.STANDARD) }
|
||||||
var defaultCascade by remember { mutableStateOf(CascadePreset.STANDARD) }
|
var defaultCascade by remember { mutableStateOf(CascadePreset.STANDARD) }
|
||||||
var hapticEnabled by remember { mutableStateOf(true) }
|
var hapticEnabled by remember { mutableStateOf(true) }
|
||||||
@ -36,6 +48,29 @@ fun SettingsScreen() {
|
|||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Account
|
||||||
|
SettingsSection("Account") {
|
||||||
|
if (authState is AuthState.LoggedIn) {
|
||||||
|
val user = (authState as AuthState.LoggedIn).user
|
||||||
|
SettingsRow("Email") {
|
||||||
|
Text(user.email, color = CMColors.textMuted, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
SettingsRow("Plan") {
|
||||||
|
Text(user.plan.replaceFirstChar { it.uppercase() }, color = CMColors.textMuted, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { viewModel.authService.logout() }) {
|
||||||
|
Text("Sign Out", color = CMColors.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Timer Defaults
|
// Timer Defaults
|
||||||
SettingsSection("Timer Defaults") {
|
SettingsSection("Timer Defaults") {
|
||||||
SettingsRow("Default Urgency") {
|
SettingsRow("Default Urgency") {
|
||||||
|
|||||||
@ -34,6 +34,9 @@
|
|||||||
DFBCDAD7322F6552D82EC73C /* HapticEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4535FFCA7608DECAFC332C5 /* HapticEngine.swift */; };
|
DFBCDAD7322F6552D82EC73C /* HapticEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4535FFCA7608DECAFC332C5 /* HapticEngine.swift */; };
|
||||||
E364FCB29C50C5780AB6BDED /* Urgency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8775EEA5055E7416149B8384 /* Urgency.swift */; };
|
E364FCB29C50C5780AB6BDED /* Urgency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8775EEA5055E7416149B8384 /* Urgency.swift */; };
|
||||||
EC0CEB1B4418DCD090CD431B /* CascadeProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA0B050D86B6910385A7A7B /* CascadeProgressBar.swift */; };
|
EC0CEB1B4418DCD090CD431B /* CascadeProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA0B050D86B6910385A7A7B /* CascadeProgressBar.swift */; };
|
||||||
|
BB2200001111AAAA33334444 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2200001111AAAA33335555 /* KeychainHelper.swift */; };
|
||||||
|
BB2200001111AAAA33336666 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2200001111AAAA33337777 /* AuthService.swift */; };
|
||||||
|
BB2200001111AAAA33338888 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2200001111AAAA33339999 /* LoginView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -77,6 +80,9 @@
|
|||||||
E7C2F36FE2E4FEAD385B6860 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
|
E7C2F36FE2E4FEAD385B6860 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
|
||||||
EE814566D06D5ED5DE214765 /* CountdownRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownRing.swift; sourceTree = "<group>"; };
|
EE814566D06D5ED5DE214765 /* CountdownRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownRing.swift; sourceTree = "<group>"; };
|
||||||
F991E825657AE91E039404AD /* AlarmOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmOverlay.swift; sourceTree = "<group>"; };
|
F991E825657AE91E039404AD /* AlarmOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmOverlay.swift; sourceTree = "<group>"; };
|
||||||
|
BB2200001111AAAA33335555 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
||||||
|
BB2200001111AAAA33337777 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
|
||||||
|
BB2200001111AAAA33339999 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@ -96,6 +102,7 @@
|
|||||||
2ABA85855E88EF8E5AE2C296 /* Settings */ = {
|
2ABA85855E88EF8E5AE2C296 /* Settings */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
BB2200001111AAAA33339999 /* LoginView.swift */,
|
||||||
DA9019324C6A38DF943E1FF6 /* SettingsView.swift */,
|
DA9019324C6A38DF943E1FF6 /* SettingsView.swift */,
|
||||||
);
|
);
|
||||||
path = Settings;
|
path = Settings;
|
||||||
@ -158,9 +165,19 @@
|
|||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
BB2200001111AAAA0000CCCC /* Cloud */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BB2200001111AAAA33337777 /* AuthService.swift */,
|
||||||
|
BB2200001111AAAA33335555 /* KeychainHelper.swift */,
|
||||||
|
);
|
||||||
|
path = Cloud;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
889806888E26EEDFA679B318 /* Shared */ = {
|
889806888E26EEDFA679B318 /* Shared */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
BB2200001111AAAA0000CCCC /* Cloud */,
|
||||||
AEBBD21D7F7F55BAA99CA1C5 /* Haptics */,
|
AEBBD21D7F7F55BAA99CA1C5 /* Haptics */,
|
||||||
5AB52EC93294F076818CB0DA /* Notifications */,
|
5AB52EC93294F076818CB0DA /* Notifications */,
|
||||||
3C5FDEC2037E5CC602490C47 /* Store */,
|
3C5FDEC2037E5CC602490C47 /* Store */,
|
||||||
@ -358,6 +375,9 @@
|
|||||||
006E8EA280AC7CAC5495951E /* TimerStore.swift in Sources */,
|
006E8EA280AC7CAC5495951E /* TimerStore.swift in Sources */,
|
||||||
E364FCB29C50C5780AB6BDED /* Urgency.swift in Sources */,
|
E364FCB29C50C5780AB6BDED /* Urgency.swift in Sources */,
|
||||||
6770745F2FBB9478094DC205 /* UrgencyBadge.swift in Sources */,
|
6770745F2FBB9478094DC205 /* UrgencyBadge.swift in Sources */,
|
||||||
|
BB2200001111AAAA33334444 /* KeychainHelper.swift in Sources */,
|
||||||
|
BB2200001111AAAA33336666 /* AuthService.swift in Sources */,
|
||||||
|
BB2200001111AAAA33338888 /* LoginView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,33 +8,40 @@ struct ChronoMindApp: App {
|
|||||||
@StateObject private var timerStore = TimerStore()
|
@StateObject private var timerStore = TimerStore()
|
||||||
@StateObject private var notificationManager = CMNotificationManager.shared
|
@StateObject private var notificationManager = CMNotificationManager.shared
|
||||||
@StateObject private var gamification = GamificationStore.shared
|
@StateObject private var gamification = GamificationStore.shared
|
||||||
|
@StateObject private var authService = CMAuthService.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ZStack {
|
Group {
|
||||||
ContentView()
|
if authService.isLoggedIn {
|
||||||
.environmentObject(timerStore)
|
ZStack {
|
||||||
.environmentObject(notificationManager)
|
ContentView()
|
||||||
.environmentObject(gamification)
|
.environmentObject(timerStore)
|
||||||
.preferredColorScheme(.dark)
|
.environmentObject(notificationManager)
|
||||||
.task {
|
.environmentObject(gamification)
|
||||||
notificationManager.registerCategories()
|
.preferredColorScheme(.dark)
|
||||||
await notificationManager.requestPermission()
|
.task {
|
||||||
}
|
notificationManager.registerCategories()
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .chronoMindTimersDidChange)) { _ in
|
await notificationManager.requestPermission()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
}
|
||||||
}
|
.onReceive(NotificationCenter.default.publisher(for: .chronoMindTimersDidChange)) { _ in
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
// Badge celebration overlay
|
// Badge celebration overlay
|
||||||
if let badge = gamification.newBadge {
|
if let badge = gamification.newBadge {
|
||||||
BadgeCelebrationOverlay(badge: badge) {
|
BadgeCelebrationOverlay(badge: badge) {
|
||||||
gamification.clearNewBadge()
|
gamification.clearNewBadge()
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(100)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.animation(.easeInOut, value: gamification.newBadge != nil)
|
||||||
.zIndex(100)
|
} else {
|
||||||
|
CMLoginView(authService: authService)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut, value: gamification.newBadge != nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
274
ios/ChronoMind/Shared/Cloud/AuthService.swift
Normal file
274
ios/ChronoMind/Shared/Cloud/AuthService.swift
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
// ── Auth Service ──────────────────────────────────────────────
|
||||||
|
// Login, register, refresh, logout via platform-service /auth/* endpoints.
|
||||||
|
// Stores tokens in Keychain; wires into PlatformSyncManager.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AuthUser: Codable {
|
||||||
|
let id: String
|
||||||
|
let email: String
|
||||||
|
let name: String
|
||||||
|
let plan: String
|
||||||
|
let role: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, email, plan, role
|
||||||
|
case name = "displayName"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, email: String, name: String, plan: String, role: String = "user") {
|
||||||
|
self.id = id
|
||||||
|
self.email = email
|
||||||
|
self.name = name
|
||||||
|
self.plan = plan
|
||||||
|
self.role = role
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
email = try c.decode(String.self, forKey: .email)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free"
|
||||||
|
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TokenResponse: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
let user: AuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RefreshResponse: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CMAuthState {
|
||||||
|
case loading
|
||||||
|
case loggedOut
|
||||||
|
case loggedIn(AuthUser)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CMAuthService: ObservableObject {
|
||||||
|
static let shared = CMAuthService()
|
||||||
|
|
||||||
|
@Published var state: CMAuthState = .loading
|
||||||
|
@AppStorage("cm_user_email") private var userEmail = ""
|
||||||
|
@AppStorage("cm_user_name") private var userName = ""
|
||||||
|
@AppStorage("cm_user_plan") private var userPlan = "free"
|
||||||
|
|
||||||
|
private var accessToken: String {
|
||||||
|
get { KeychainHelper.read(key: "cm_access_token") ?? "" }
|
||||||
|
set {
|
||||||
|
if newValue.isEmpty { KeychainHelper.delete(key: "cm_access_token") }
|
||||||
|
else { KeychainHelper.save(key: "cm_access_token", value: newValue) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshToken: String {
|
||||||
|
get { KeychainHelper.read(key: "cm_refresh_token") ?? "" }
|
||||||
|
set {
|
||||||
|
if newValue.isEmpty { KeychainHelper.delete(key: "cm_refresh_token") }
|
||||||
|
else { KeychainHelper.save(key: "cm_refresh_token", value: newValue) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
|
||||||
|
private var baseURL: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String
|
||||||
|
?? "https://api.chronomind.app"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let productId = "chronomind"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
checkExistingSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkExistingSession() {
|
||||||
|
if !accessToken.isEmpty, !userEmail.isEmpty {
|
||||||
|
state = .loggedIn(AuthUser(id: "", email: userEmail, name: userName, plan: userPlan))
|
||||||
|
wireSyncToken()
|
||||||
|
startRefreshTimer()
|
||||||
|
Task { await fetchCurrentUser() }
|
||||||
|
} else if !accessToken.isEmpty {
|
||||||
|
Task { await fetchCurrentUser() }
|
||||||
|
} else {
|
||||||
|
state = .loggedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
func login(email: String, password: String) async {
|
||||||
|
state = .loading
|
||||||
|
let body: [String: String] = [
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"productId": productId,
|
||||||
|
]
|
||||||
|
guard let result = await postAuth(path: "/auth/login", body: body) else {
|
||||||
|
state = .error("Invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveSession(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(name: String, email: String, password: String) async {
|
||||||
|
state = .loading
|
||||||
|
let body: [String: String] = [
|
||||||
|
"email": email,
|
||||||
|
"displayName": name,
|
||||||
|
"password": password,
|
||||||
|
"productId": productId,
|
||||||
|
]
|
||||||
|
guard let result = await postAuth(path: "/auth/register", body: body) else {
|
||||||
|
state = .error("Registration failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveSession(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
stopRefreshTimer()
|
||||||
|
accessToken = ""
|
||||||
|
refreshToken = ""
|
||||||
|
userEmail = ""
|
||||||
|
userName = ""
|
||||||
|
state = .loggedOut
|
||||||
|
PlatformSyncManager.shared.setAuthToken(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
if case .loggedIn = state { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentUser: AuthUser? {
|
||||||
|
if case .loggedIn(let user) = state { return user }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccessToken() -> String? {
|
||||||
|
let t = accessToken
|
||||||
|
return t.isEmpty ? nil : t
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token Refresh
|
||||||
|
|
||||||
|
func refreshAccessToken() async -> Bool {
|
||||||
|
guard !refreshToken.isEmpty else { return false }
|
||||||
|
let body: [String: String] = ["refreshToken": refreshToken]
|
||||||
|
guard let url = URL(string: "\(baseURL)/auth/refresh"),
|
||||||
|
let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return false }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
||||||
|
request.httpBody = jsonData
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||||
|
if let http = response as? HTTPURLResponse, http.statusCode == 401 { logout() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let r = try JSONDecoder().decode(RefreshResponse.self, from: data)
|
||||||
|
accessToken = r.accessToken
|
||||||
|
refreshToken = r.refreshToken
|
||||||
|
wireSyncToken()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRefreshTimer() {
|
||||||
|
stopRefreshTimer()
|
||||||
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: 45 * 60, repeats: true) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in _ = await self.refreshAccessToken() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopRefreshTimer() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func wireSyncToken() {
|
||||||
|
PlatformSyncManager.shared.setAuthToken(accessToken.isEmpty ? nil : accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postAuth(path: String, body: [String: String]) async -> TokenResponse? {
|
||||||
|
guard let url = URL(string: "\(baseURL)\(path)"),
|
||||||
|
let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
||||||
|
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id")
|
||||||
|
request.httpBody = jsonData
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, (200...201).contains(http.statusCode) else { return nil }
|
||||||
|
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSession(_ resp: TokenResponse) {
|
||||||
|
accessToken = resp.accessToken
|
||||||
|
refreshToken = resp.refreshToken
|
||||||
|
userEmail = resp.user.email
|
||||||
|
userName = resp.user.name
|
||||||
|
userPlan = resp.user.plan
|
||||||
|
state = .loggedIn(resp.user)
|
||||||
|
wireSyncToken()
|
||||||
|
startRefreshTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentUser() async {
|
||||||
|
guard !accessToken.isEmpty, let url = URL(string: "\(baseURL)/auth/me") else { return }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue(productId, forHTTPHeaderField: "X-Product-Id")
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||||
|
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
||||||
|
let ok = await refreshAccessToken()
|
||||||
|
if ok { await fetchCurrentUser() } else { state = .loggedOut }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
struct MeResponse: Codable {
|
||||||
|
let id: String; let email: String; let displayName: String
|
||||||
|
let plan: String?; let role: String?
|
||||||
|
}
|
||||||
|
let info = try JSONDecoder().decode(MeResponse.self, from: data)
|
||||||
|
let plan = info.plan ?? "free"
|
||||||
|
userPlan = plan; userEmail = info.email; userName = info.displayName
|
||||||
|
state = .loggedIn(AuthUser(id: info.id, email: info.email, name: info.displayName, plan: plan, role: info.role ?? "user"))
|
||||||
|
wireSyncToken()
|
||||||
|
} catch {
|
||||||
|
// Keep existing state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
ios/ChronoMind/Shared/Cloud/KeychainHelper.swift
Normal file
52
ios/ChronoMind/Shared/Cloud/KeychainHelper.swift
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// ── Keychain Helper ───────────────────────────────────────────
|
||||||
|
// Lightweight wrapper for storing auth tokens securely in iOS Keychain.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainHelper {
|
||||||
|
|
||||||
|
private static let service = "com.saravana.chronomind"
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func save(key: String, value: String) -> Bool {
|
||||||
|
guard let data = value.data(using: .utf8) else { return false }
|
||||||
|
delete(key: key)
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||||
|
]
|
||||||
|
|
||||||
|
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
static func read(key: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func delete(key: String) -> Bool {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
return status == errSecSuccess || status == errSecItemNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
146
ios/ChronoMind/Views/Settings/LoginView.swift
Normal file
146
ios/ChronoMind/Views/Settings/LoginView.swift
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// ── Login / Register View ─────────────────────────────────────
|
||||||
|
// Authentication form for ChronoMind via platform-service.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CMLoginView: View {
|
||||||
|
@ObservedObject var authService = CMAuthService.shared
|
||||||
|
|
||||||
|
@State private var isRegister = false
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var password = ""
|
||||||
|
|
||||||
|
private var isLoading: Bool {
|
||||||
|
if case .loading = authService.state { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var errorMessage: String? {
|
||||||
|
if case .error(let msg) = authService.state { return msg }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isValidEmail: Bool {
|
||||||
|
let p = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
|
||||||
|
return email.range(of: p, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isValidPassword: Bool {
|
||||||
|
password.count >= 8
|
||||||
|
&& password.rangeOfCharacter(from: .uppercaseLetters) != nil
|
||||||
|
&& password.rangeOfCharacter(from: .lowercaseLetters) != nil
|
||||||
|
&& password.rangeOfCharacter(from: .decimalDigits) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isValid: Bool {
|
||||||
|
isValidEmail && isValidPassword && (!isRegister || !name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
CMColors.bg.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
|
||||||
|
Image(systemName: "clock.badge.checkmark")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
|
||||||
|
Text("ChronoMind")
|
||||||
|
.font(CMFonts.display(size: 28))
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
|
||||||
|
Text(isRegister ? "Create your account" : "Sign in to your account")
|
||||||
|
.font(CMFonts.body(size: 15))
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
if isRegister {
|
||||||
|
TextField("Full Name", text: $name)
|
||||||
|
.textContentType(.name)
|
||||||
|
.autocapitalization(.words)
|
||||||
|
.padding(14)
|
||||||
|
.background(CMColors.surface)
|
||||||
|
.cornerRadius(CMRadius.md)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Email", text: $email)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.padding(14)
|
||||||
|
.background(CMColors.surface)
|
||||||
|
.cornerRadius(CMRadius.md)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.textContentType(isRegister ? .newPassword : .password)
|
||||||
|
.padding(14)
|
||||||
|
.background(CMColors.surface)
|
||||||
|
.cornerRadius(CMRadius.md)
|
||||||
|
.foregroundColor(CMColors.text)
|
||||||
|
|
||||||
|
if !password.isEmpty && isRegister && !isValidPassword {
|
||||||
|
Text("Password needs: 8+ chars, uppercase, lowercase, digit")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.error)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
if isRegister {
|
||||||
|
await authService.register(name: name, email: email, password: password)
|
||||||
|
} else {
|
||||||
|
await authService.login(email: email, password: password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
} else {
|
||||||
|
Text(isRegister ? "Create Account" : "Sign In")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(CMColors.accent)
|
||||||
|
.disabled(!isValid || isLoading)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(isRegister ? "Already have an account?" : "Don't have an account?")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.textSecondary)
|
||||||
|
Button(isRegister ? "Sign In" : "Register") {
|
||||||
|
withAnimation { isRegister.toggle() }
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(CMColors.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject var notificationManager: CMNotificationManager
|
@EnvironmentObject var notificationManager: CMNotificationManager
|
||||||
@ObservedObject private var cloudSync = CloudKitSyncManager.shared
|
@ObservedObject private var cloudSync = CloudKitSyncManager.shared
|
||||||
@ObservedObject private var crashReporter = CrashReporter.shared
|
@ObservedObject private var crashReporter = CrashReporter.shared
|
||||||
|
@ObservedObject private var authService = CMAuthService.shared
|
||||||
|
|
||||||
@AppStorage("cm_defaultUrgency") private var defaultUrgency = "standard"
|
@AppStorage("cm_defaultUrgency") private var defaultUrgency = "standard"
|
||||||
@AppStorage("cm_defaultCascade") private var defaultCascade = "standard"
|
@AppStorage("cm_defaultCascade") private var defaultCascade = "standard"
|
||||||
@ -21,6 +22,37 @@ struct SettingsView: View {
|
|||||||
CMColors.bg.ignoresSafeArea()
|
CMColors.bg.ignoresSafeArea()
|
||||||
|
|
||||||
List {
|
List {
|
||||||
|
// Account
|
||||||
|
Section {
|
||||||
|
if let user = authService.currentUser {
|
||||||
|
HStack {
|
||||||
|
Label("Email", systemImage: "envelope.fill")
|
||||||
|
.foregroundStyle(CMColors.text)
|
||||||
|
Spacer()
|
||||||
|
Text(user.email)
|
||||||
|
.font(CMFonts.body(size: 13))
|
||||||
|
.foregroundStyle(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Label("Plan", systemImage: "creditcard.fill")
|
||||||
|
.foregroundStyle(CMColors.text)
|
||||||
|
Spacer()
|
||||||
|
Text(user.plan.capitalized)
|
||||||
|
.font(CMFonts.body(size: 13))
|
||||||
|
.foregroundStyle(CMColors.textSecondary)
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
authService.logout()
|
||||||
|
} label: {
|
||||||
|
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Account")
|
||||||
|
.foregroundStyle(CMColors.textMuted)
|
||||||
|
}
|
||||||
|
.listRowBackground(CMColors.surface)
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user