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:
saravanakumardb1 2026-02-28 03:22:23 -08:00
parent 5e8cbbf556
commit 6a41cc9f48
11 changed files with 1063 additions and 26 deletions

View File

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

View 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
}
}
}

View 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,
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View 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
}
}
}

View 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
}
}

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

View File

@ -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 {