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.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.theme.CMColors
|
||||
import com.chronomind.app.ui.theme.ChronoMindTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var authService: AuthService
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
authService.checkExistingSession()
|
||||
|
||||
setContent {
|
||||
ChronoMindTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = CMColors.bg
|
||||
) {
|
||||
ChronoMindNavHost()
|
||||
val authState by authService.state.collectAsState()
|
||||
if (authState is AuthState.LoggedIn) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("x-request-id", java.util.UUID.randomUUID().toString())
|
||||
conn.setRequestProperty("x-product-id", "chronomind")
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
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.unit.dp
|
||||
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.UrgencyLevel
|
||||
import com.chronomind.app.engine.getUrgencyConfig
|
||||
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
|
||||
fun SettingsScreen() {
|
||||
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
|
||||
val authState by viewModel.authService.state.collectAsState()
|
||||
var defaultUrgency by remember { mutableStateOf(UrgencyLevel.STANDARD) }
|
||||
var defaultCascade by remember { mutableStateOf(CascadePreset.STANDARD) }
|
||||
var hapticEnabled by remember { mutableStateOf(true) }
|
||||
@ -36,6 +48,29 @@ fun SettingsScreen() {
|
||||
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
|
||||
SettingsSection("Timer Defaults") {
|
||||
SettingsRow("Default Urgency") {
|
||||
|
||||
@ -34,6 +34,9 @@
|
||||
DFBCDAD7322F6552D82EC73C /* HapticEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4535FFCA7608DECAFC332C5 /* HapticEngine.swift */; };
|
||||
E364FCB29C50C5780AB6BDED /* Urgency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8775EEA5055E7416149B8384 /* Urgency.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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -77,6 +80,9 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@ -96,6 +102,7 @@
|
||||
2ABA85855E88EF8E5AE2C296 /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB2200001111AAAA33339999 /* LoginView.swift */,
|
||||
DA9019324C6A38DF943E1FF6 /* SettingsView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
@ -158,9 +165,19 @@
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB2200001111AAAA0000CCCC /* Cloud */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB2200001111AAAA33337777 /* AuthService.swift */,
|
||||
BB2200001111AAAA33335555 /* KeychainHelper.swift */,
|
||||
);
|
||||
path = Cloud;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
889806888E26EEDFA679B318 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB2200001111AAAA0000CCCC /* Cloud */,
|
||||
AEBBD21D7F7F55BAA99CA1C5 /* Haptics */,
|
||||
5AB52EC93294F076818CB0DA /* Notifications */,
|
||||
3C5FDEC2037E5CC602490C47 /* Store */,
|
||||
@ -358,6 +375,9 @@
|
||||
006E8EA280AC7CAC5495951E /* TimerStore.swift in Sources */,
|
||||
E364FCB29C50C5780AB6BDED /* Urgency.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;
|
||||
};
|
||||
|
||||
@ -8,33 +8,40 @@ struct ChronoMindApp: App {
|
||||
@StateObject private var timerStore = TimerStore()
|
||||
@StateObject private var notificationManager = CMNotificationManager.shared
|
||||
@StateObject private var gamification = GamificationStore.shared
|
||||
@StateObject private var authService = CMAuthService.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ZStack {
|
||||
ContentView()
|
||||
.environmentObject(timerStore)
|
||||
.environmentObject(notificationManager)
|
||||
.environmentObject(gamification)
|
||||
.preferredColorScheme(.dark)
|
||||
.task {
|
||||
notificationManager.registerCategories()
|
||||
await notificationManager.requestPermission()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chronoMindTimersDidChange)) { _ in
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
Group {
|
||||
if authService.isLoggedIn {
|
||||
ZStack {
|
||||
ContentView()
|
||||
.environmentObject(timerStore)
|
||||
.environmentObject(notificationManager)
|
||||
.environmentObject(gamification)
|
||||
.preferredColorScheme(.dark)
|
||||
.task {
|
||||
notificationManager.registerCategories()
|
||||
await notificationManager.requestPermission()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chronoMindTimersDidChange)) { _ in
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
// Badge celebration overlay
|
||||
if let badge = gamification.newBadge {
|
||||
BadgeCelebrationOverlay(badge: badge) {
|
||||
gamification.clearNewBadge()
|
||||
// Badge celebration overlay
|
||||
if let badge = gamification.newBadge {
|
||||
BadgeCelebrationOverlay(badge: badge) {
|
||||
gamification.clearNewBadge()
|
||||
}
|
||||
.transition(.opacity)
|
||||
.zIndex(100)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.zIndex(100)
|
||||
.animation(.easeInOut, value: gamification.newBadge != nil)
|
||||
} 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
|
||||
@ObservedObject private var cloudSync = CloudKitSyncManager.shared
|
||||
@ObservedObject private var crashReporter = CrashReporter.shared
|
||||
@ObservedObject private var authService = CMAuthService.shared
|
||||
|
||||
@AppStorage("cm_defaultUrgency") private var defaultUrgency = "standard"
|
||||
@AppStorage("cm_defaultCascade") private var defaultCascade = "standard"
|
||||
@ -21,6 +22,37 @@ struct SettingsView: View {
|
||||
CMColors.bg.ignoresSafeArea()
|
||||
|
||||
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
|
||||
Section {
|
||||
HStack {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user