Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions api/Android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,16 @@ dependencies {
// DataStore for token persistence
implementation("androidx.datastore:datastore-preferences:1.1.1")

// Room
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
// annotationProcessor("androidx.room:room-compiler:$roomVersion") // Use KSP if available

// WorkManager
val workVersion = "2.9.0"
implementation("androidx.work:work-runtime-ktx:$workVersion")

debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ object ApiClient {

fun init(session: SessionStore) {
val authInterceptor = Interceptor { chain ->
val token = runBlocking { session.token() }
// DataStore access via runBlocking is generally safe in OkHttp Interceptors
// as they run on background threads. Added try-catch for extra safety.
val token = try {
runBlocking { session.token() }
} catch (e: Exception) {
null
}
val builder = chain.request().newBuilder()
if (!token.isNullOrBlank()) {
builder.addHeader("Authorization", "Bearer $token")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class NetworkMonitor(context: Context) {
fun currentlyOnline(): Boolean {
val active = connectivityManager.activeNetwork ?: return false
val caps = connectivityManager.getNetworkCapabilities(active) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.IOException
import kotlinx.serialization.json.Json

private val Context.dataStore by preferencesDataStore(name = "ai_notebook_session")
Expand All @@ -22,15 +24,23 @@ class SessionStore(private val context: Context) {
private val json = Json { ignoreUnknownKeys = true }
}

val tokenFlow: Flow<String?> = context.dataStore.data.map { it[TOKEN_KEY] }
val tokenFlow: Flow<String?> = context.dataStore.data
.catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it }
.map { it[TOKEN_KEY] }

val userFlow: Flow<User?> = context.dataStore.data.map { prefs ->
prefs[USER_KEY]?.let {
runCatching { json.decodeFromString<User>(it) }.getOrNull()
val userFlow: Flow<User?> = context.dataStore.data
.catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it }
.map { prefs ->
prefs[USER_KEY]?.let {
runCatching { json.decodeFromString<User>(it) }.getOrNull()
}
}
}

suspend fun token(): String? = context.dataStore.data.first()[TOKEN_KEY]
suspend fun token(): String? = try {
context.dataStore.data.first()[TOKEN_KEY]
} catch (e: IOException) {
null
}

suspend fun save(token: String, user: User) {
context.dataStore.edit { prefs ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,7 @@ object StreamClient {
.addHeader("Accept", "text/event-stream")
.addHeader("Cache-Control", "no-cache")
.post(payload.toRequestBody(mediaType))
if (!token.isNullOrBlank()) {
reqBuilder.addHeader("Authorization", "Bearer $token")
}
// Authorization header is already added by the shared ApiClient interceptor.
return streamingClient.newCall(reqBuilder.build())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException

private val Context.themeStore by preferencesDataStore(name = "ai_notebook_prefs")

Expand All @@ -26,22 +28,28 @@ class ThemePreferences(private val context: Context) {
private val MODEL_KEY = stringPreferencesKey("pref_ai_model")
}

val themeMode: Flow<ThemeMode> = context.themeStore.data.map { prefs ->
when (prefs[THEME_KEY]) {
"LIGHT" -> ThemeMode.LIGHT
"DARK" -> ThemeMode.DARK
else -> ThemeMode.DARK // premium dark-first default, like Perplexity/Claude
val themeMode: Flow<ThemeMode> = context.themeStore.data
.catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it }
.map { prefs ->
when (prefs[THEME_KEY]) {
"LIGHT" -> ThemeMode.LIGHT
"DARK" -> ThemeMode.DARK
"SYSTEM" -> ThemeMode.SYSTEM
else -> ThemeMode.DARK // premium dark-first default
}
}
}

val dynamicColor: Flow<Boolean> =
context.themeStore.data.map { it[DYNAMIC_KEY] ?: false }
val dynamicColor: Flow<Boolean> = context.themeStore.data
.catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it }
.map { it[DYNAMIC_KEY] ?: false }

val hapticsEnabled: Flow<Boolean> =
context.themeStore.data.map { it[HAPTICS_KEY] ?: true }
val hapticsEnabled: Flow<Boolean> = context.themeStore.data
.catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it }
.map { it[HAPTICS_KEY] ?: true }

val aiModel: Flow<String> =
context.themeStore.data.map { it[MODEL_KEY] ?: "auto" }
val aiModel: Flow<String> = context.themeStore.data
.catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it }
.map { it[MODEL_KEY] ?: "auto" }

suspend fun setThemeMode(mode: ThemeMode) {
context.themeStore.edit { it[THEME_KEY] = mode.name }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ object Routes {
const val SIGNUP = "signup"
const val DASHBOARD = "dashboard"
const val CHAT = "chat"
const val CHAT_DETAIL = "chat?chatId={chatId}"
const val TOOLS = "tools"
const val PROFILE = "profile"
}
Expand Down Expand Up @@ -196,7 +197,7 @@ private fun MainShell(factory: VMFactory) {
)
}
composable(
route = "${Routes.CHAT}?chatId={chatId}",
route = Routes.CHAT_DETAIL,
arguments = listOf(navArgument("chatId") {
type = NavType.IntType; defaultValue = -1
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.ainotebook.app.data.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class AuthUiState(
Expand All @@ -23,24 +24,24 @@ class AuthViewModel(private val repo: Repository) : ViewModel() {
val userFlow = repo.userFlow

fun clearError() {
_state.value = _state.value.copy(error = null)
_state.update { it.copy(error = null) }
}

private fun run(block: suspend () -> User) {
viewModelScope.launch {
_state.value = AuthUiState(loading = true)
_state.update { AuthUiState(loading = true) }
try {
block()
_state.value = AuthUiState(success = true)
_state.update { AuthUiState(success = true) }
} catch (e: Exception) {
_state.value = AuthUiState(error = friendly(e))
_state.update { AuthUiState(error = friendly(e)) }
}
}
}

fun login(identifier: String, password: String) {
if (identifier.isBlank() || password.isBlank()) {
_state.value = AuthUiState(error = "Please enter your email/username and password.")
_state.update { AuthUiState(error = "Please enter your email/username and password.") }
return
}
run { repo.login(identifier.trim(), password) }
Expand All @@ -52,13 +53,13 @@ class AuthViewModel(private val repo: Repository) : ViewModel() {
) {
when {
name.isBlank() || username.isBlank() || email.isBlank() ->
_state.value = AuthUiState(error = "Please fill in all fields.")
!email.contains("@") || !email.contains(".") -> // ← ADD THIS
_state.value = AuthUiState(error = "Please enter a valid email address.")
_state.update { AuthUiState(error = "Please fill in all fields.") }
!email.contains("@") || !email.contains(".") ->
_state.update { AuthUiState(error = "Please enter a valid email address.") }
password.length < 6 ->
_state.value = AuthUiState(error = "Password must be at least 6 characters.")
_state.update { AuthUiState(error = "Password must be at least 6 characters.") }
password != confirm ->
_state.value = AuthUiState(error = "Passwords do not match.")
_state.update { AuthUiState(error = "Passwords do not match.") }
else -> run {
repo.signup(name.trim(), username.trim(), email.trim(), password, confirm)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.ainotebook.app.data.Stats
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class DashboardUiState(
Expand All @@ -24,15 +25,17 @@ class DashboardViewModel(private val repo: Repository) : ViewModel() {

fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
_state.update { it.copy(loading = true, error = null) }
try {
val s = repo.stats()
_state.value = DashboardUiState(loading = false, stats = s)
_state.update { DashboardUiState(loading = false, stats = s) }
} catch (e: Exception) {
_state.value = DashboardUiState(
loading = false,
error = e.message ?: "Could not load your dashboard."
)
_state.update {
DashboardUiState(
loading = false,
error = e.message ?: "Could not load your dashboard."
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.ainotebook.app.data.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class ProfileUiState(
Expand All @@ -27,53 +28,53 @@ class ProfileViewModel(private val repo: Repository) : ViewModel() {
fun refresh() {
viewModelScope.launch {
try {
_state.value = _state.value.copy(user = repo.me())
_state.update { it.copy(user = repo.me()) }
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message)
_state.update { it.copy(error = e.message) }
}
}
}

fun updateName(name: String) {
if (name.isBlank()) {
_state.value = _state.value.copy(error = "Please enter your name.")
_state.update { it.copy(error = "Please enter your name.") }
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, message = null, error = null)
_state.update { it.copy(loading = true, message = null, error = null) }
try {
val u = repo.updateProfile(name.trim())
_state.value = _state.value.copy(loading = false, user = u, message = "Profile updated.")
_state.update { it.copy(loading = false, user = u, message = "Profile updated.") }
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false, error = e.message)
_state.update { it.copy(loading = false, error = e.message) }
}
}
}

fun changePassword(current: String, new: String) {
if (new.length < 6) {
_state.value = _state.value.copy(error = "New password must be at least 6 characters.")
_state.update { it.copy(error = "New password must be at least 6 characters.") }
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, message = null, error = null)
_state.update { it.copy(loading = true, message = null, error = null) }
try {
val msg = repo.changePassword(current, new)
_state.value = _state.value.copy(loading = false, message = msg)
_state.update { it.copy(loading = false, message = msg) }
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false, error = e.message)
_state.update { it.copy(loading = false, error = e.message) }
}
}
}

fun logout() {
viewModelScope.launch {
repo.logout()
_state.value = _state.value.copy(loggedOut = true)
_state.update { it.copy(loggedOut = true) }
}
}

fun clearMessages() {
_state.value = _state.value.copy(message = null, error = null)
_state.update { it.copy(message = null, error = null) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.ainotebook.app.data.Repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

/** Generic result holder for the study tools. */
Expand All @@ -27,23 +28,23 @@ class ToolsViewModel(private val repo: Repository) : ViewModel() {
val state: StateFlow<ToolsUiState> = _state.asStateFlow()

fun reset() {
_state.value = ToolsUiState()
_state.update { ToolsUiState() }
}

private fun begin() {
_state.value = ToolsUiState(loading = true)
_state.update { ToolsUiState(loading = true) }
}

private fun fail(e: Exception) {
_state.value = ToolsUiState(error = e.message ?: "Something went wrong.")
_state.update { ToolsUiState(error = e.message ?: "Something went wrong.") }
}

fun notes(topic: String) = launch { _state.value = ToolsUiState(textResult = repo.generateNotes(topic).content) }
fun plan(goal: String, days: Int) = launch { _state.value = ToolsUiState(textResult = repo.generatePlan(goal, days).content) }
fun summarize(text: String) = launch { _state.value = ToolsUiState(textResult = repo.summarize(text).summary) }
fun homework(q: String) = launch { _state.value = ToolsUiState(textResult = repo.homework(q).answer) }
fun quiz(topic: String, n: Int) = launch { _state.value = ToolsUiState(quiz = repo.generateQuiz(topic, n).questions) }
fun flashcards(topic: String, n: Int) = launch { _state.value = ToolsUiState(flashcards = repo.generateFlashcards(topic, n).cards) }
fun notes(topic: String) = launch { _state.update { ToolsUiState(textResult = repo.generateNotes(topic).content) } }
fun plan(goal: String, days: Int) = launch { _state.update { ToolsUiState(textResult = repo.generatePlan(goal, days).content) } }
fun summarize(text: String) = launch { _state.update { ToolsUiState(textResult = repo.summarize(text).summary) } }
fun homework(q: String) = launch { _state.update { ToolsUiState(textResult = repo.homework(q).answer) } }
fun quiz(topic: String, n: Int) = launch { _state.update { ToolsUiState(quiz = repo.generateQuiz(topic, n).questions) } }
fun flashcards(topic: String, n: Int) = launch { _state.update { ToolsUiState(flashcards = repo.generateFlashcards(topic, n).cards) } }

private fun launch(block: suspend () -> Unit) {
viewModelScope.launch {
Expand Down
Empty file modified api/Android/gradlew
100644 → 100755
Empty file.