diff --git a/android/app/src/main/java/com/chronomind/app/MainActivity.kt b/android/app/src/main/java/com/chronomind/app/MainActivity.kt index 1929c5f..4548601 100644 --- a/android/app/src/main/java/com/chronomind/app/MainActivity.kt +++ b/android/app/src/main/java/com/chronomind/app/MainActivity.kt @@ -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) } } } diff --git a/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt b/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt new file mode 100644 index 0000000..102dc79 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt @@ -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.Loading) + val state: StateFlow = _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(), + kotlinx.serialization.builtins.serializer(), + ), + 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(), + kotlinx.serialization.builtins.serializer(), + ), + 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(), + kotlinx.serialization.builtins.serializer(), + ), + 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(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(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 + } + } +} diff --git a/android/app/src/main/java/com/chronomind/app/auth/LoginScreen.kt b/android/app/src/main/java/com/chronomind/app/auth/LoginScreen.kt new file mode 100644 index 0000000..73fa1e9 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/auth/LoginScreen.kt @@ -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, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt b/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt index cec4b6c..574d323 100644 --- a/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt +++ b/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt @@ -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") } diff --git a/android/app/src/main/java/com/chronomind/app/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/chronomind/app/ui/screens/SettingsScreen.kt index 253941b..9e478a0 100644 --- a/android/app/src/main/java/com/chronomind/app/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/chronomind/app/ui/screens/SettingsScreen.kt @@ -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") { diff --git a/ios/ChronoMind.xcodeproj/project.pbxproj b/ios/ChronoMind.xcodeproj/project.pbxproj index b6b4312..3a83cc3 100644 --- a/ios/ChronoMind.xcodeproj/project.pbxproj +++ b/ios/ChronoMind.xcodeproj/project.pbxproj @@ -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 = ""; }; EE814566D06D5ED5DE214765 /* CountdownRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownRing.swift; sourceTree = ""; }; F991E825657AE91E039404AD /* AlarmOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmOverlay.swift; sourceTree = ""; }; + BB2200001111AAAA33335555 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + BB2200001111AAAA33337777 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; + BB2200001111AAAA33339999 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; /* 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 = ""; }; + BB2200001111AAAA0000CCCC /* Cloud */ = { + isa = PBXGroup; + children = ( + BB2200001111AAAA33337777 /* AuthService.swift */, + BB2200001111AAAA33335555 /* KeychainHelper.swift */, + ); + path = Cloud; + sourceTree = ""; + }; 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; }; diff --git a/ios/ChronoMind/App/ChronoMindApp.swift b/ios/ChronoMind/App/ChronoMindApp.swift index eed868f..c680c1b 100644 --- a/ios/ChronoMind/App/ChronoMindApp.swift +++ b/ios/ChronoMind/App/ChronoMindApp.swift @@ -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) } } } diff --git a/ios/ChronoMind/Shared/Cloud/AuthService.swift b/ios/ChronoMind/Shared/Cloud/AuthService.swift new file mode 100644 index 0000000..5dd7b60 --- /dev/null +++ b/ios/ChronoMind/Shared/Cloud/AuthService.swift @@ -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 + } + } +} diff --git a/ios/ChronoMind/Shared/Cloud/KeychainHelper.swift b/ios/ChronoMind/Shared/Cloud/KeychainHelper.swift new file mode 100644 index 0000000..f11a859 --- /dev/null +++ b/ios/ChronoMind/Shared/Cloud/KeychainHelper.swift @@ -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 + } +} diff --git a/ios/ChronoMind/Views/Settings/LoginView.swift b/ios/ChronoMind/Views/Settings/LoginView.swift new file mode 100644 index 0000000..12f1604 --- /dev/null +++ b/ios/ChronoMind/Views/Settings/LoginView.swift @@ -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) + } +} diff --git a/ios/ChronoMind/Views/Settings/SettingsView.swift b/ios/ChronoMind/Views/Settings/SettingsView.swift index 0219346..77ec039 100644 --- a/ios/ChronoMind/Views/Settings/SettingsView.swift +++ b/ios/ChronoMind/Views/Settings/SettingsView.swift @@ -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 {