diff --git a/api/Android/.gitignore b/api/Android/.gitignore deleted file mode 100644 index fc783a2..0000000 --- a/api/Android/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Android / Gradle build artifacts (machine-specific, never committed) -*.iml -.gradle/ -/local.properties -/.idea -.DS_Store -/build -app/build/ -captures/ -.externalNativeBuild/ -.cxx/ diff --git a/api/Android/app/build.gradle.kts b/api/Android/app/build.gradle.kts deleted file mode 100644 index 26c1d55..0000000 --- a/api/Android/app/build.gradle.kts +++ /dev/null @@ -1,109 +0,0 @@ -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.serialization") -} - -android { - namespace = "com.ainotebook.app" - compileSdk = 34 - - defaultConfig { - applicationId = "com.ainotebook.app" - minSdk = 24 - targetSdk = 34 - versionCode = 2 - versionName = "1.1" - - vectorDrawables { - useSupportLibrary = true - } - - // Default backend URL. Override with the `apiBaseUrl` Gradle property - // (e.g. -PapiBaseUrl=https://your-backend.example.com) or per build - // type below. Falls back to the current production deployment. - val apiBaseUrl = (project.findProperty("apiBaseUrl") as String?) - ?: "https://study-sphere-ai-mwlq.vercel.app" - buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"") - } - - buildTypes { - debug { - // Point debug builds at your local/dev backend if you like. - val debugApiBaseUrl = (project.findProperty("apiBaseUrlDebug") as String?) - ?: (project.findProperty("apiBaseUrl") as String?) - ?: "https://study-sphere-ai-mwlq.vercel.app" - buildConfigField("String", "API_BASE_URL", "\"$debugApiBaseUrl\"") - } - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { - compose = true - buildConfig = true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -dependencies { - val composeBom = platform("androidx.compose:compose-bom:2024.06.00") - implementation(composeBom) - - implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3") - implementation("androidx.activity:activity-compose:1.9.0") - - // Android 12+ Splash Screen API (backwards compatible) - implementation("androidx.core:core-splashscreen:1.0.1") - - // Compose - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.compose.animation:animation") - implementation("androidx.compose.animation:animation-graphics") - - // Navigation - implementation("androidx.navigation:navigation-compose:2.7.7") - - // Networking - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") - implementation("com.squareup.retrofit2:retrofit:2.11.0") - - // JSON - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") - implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") - - // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") - - // DataStore for token persistence - implementation("androidx.datastore:datastore-preferences:1.1.1") - - debugImplementation("androidx.compose.ui:ui-tooling") -} diff --git a/api/Android/app/proguard-rules.pro b/api/Android/app/proguard-rules.pro deleted file mode 100644 index be634bf..0000000 --- a/api/Android/app/proguard-rules.pro +++ /dev/null @@ -1,16 +0,0 @@ -# Keep kotlinx.serialization --keepattributes *Annotation*, InnerClasses --dontnote kotlinx.serialization.AnnotationsKt --keepclassmembers class kotlinx.serialization.json.** { - *** Companion; -} --keepclasseswithmembers class kotlinx.serialization.json.** { - kotlinx.serialization.KSerializer serializer(...); -} --keep,includedescriptorclasses class com.ainotebook.app.**$$serializer { *; } --keepclassmembers class com.ainotebook.app.** { - *** Companion; -} --keepclasseswithmembers class com.ainotebook.app.** { - kotlinx.serialization.KSerializer serializer(...); -} diff --git a/api/Android/app/src/main/AndroidManifest.xml b/api/Android/app/src/main/AndroidManifest.xml deleted file mode 100644 index b93364d..0000000 --- a/api/Android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/api/Android/app/src/main/java/com/ainotebook/app/AiNotebookApp.kt b/api/Android/app/src/main/java/com/ainotebook/app/AiNotebookApp.kt deleted file mode 100644 index 660a180..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/AiNotebookApp.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.ainotebook.app - -import android.app.Application -import com.ainotebook.app.data.ApiClient -import com.ainotebook.app.data.NetworkMonitor -import com.ainotebook.app.data.Repository -import com.ainotebook.app.data.SessionStore -import com.ainotebook.app.data.ThemePreferences - -/** - * Application class that wires up the singletons (session store, API client, - * repository) used across the app. - */ -class AiNotebookApp : Application() { - - lateinit var session: SessionStore - private set - lateinit var repository: Repository - private set - lateinit var themePrefs: ThemePreferences - private set - lateinit var networkMonitor: NetworkMonitor - private set - - override fun onCreate() { - super.onCreate() - session = SessionStore(this) - ApiClient.init(session) - repository = Repository(session) - themePrefs = ThemePreferences(this) - networkMonitor = NetworkMonitor(this) - instance = this - } - - companion object { - lateinit var instance: AiNotebookApp - private set - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/MainActivity.kt b/api/Android/app/src/main/java/com/ainotebook/app/MainActivity.kt deleted file mode 100644 index e25eb0d..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/MainActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.ainotebook.app - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.getValue -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ainotebook.app.data.ThemeMode -import com.ainotebook.app.ui.AppRoot -import com.ainotebook.app.ui.LocalAppPrefs -import com.ainotebook.app.ui.LocalNetworkMonitor -import com.ainotebook.app.ui.theme.AiNotebookTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - // Install the AndroidX splash screen before super.onCreate() so the - // branded launch screen shows the official logo while the app warms up. - val splash = installSplashScreen() - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - val app = application as AiNotebookApp - val repo = app.repository - val prefs = app.themePrefs - val network = app.networkMonitor - - // Keep the splash on-screen for a brief, deliberate beat (premium feel). - var keepSplash = true - splash.setKeepOnScreenCondition { keepSplash } - window.decorView.postDelayed({ keepSplash = false }, 350) - - setContent { - val mode by prefs.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.DARK) - val dynamic by prefs.dynamicColor.collectAsStateWithLifecycle(initialValue = false) - - val dark = when (mode) { - ThemeMode.LIGHT -> false - ThemeMode.DARK -> true - ThemeMode.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() - } - - AiNotebookTheme(darkTheme = dark, dynamicColor = dynamic) { - androidx.compose.runtime.CompositionLocalProvider( - LocalAppPrefs provides prefs, - LocalNetworkMonitor provides network - ) { - AppRoot(repo) - } - } - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/ApiClient.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/ApiClient.kt deleted file mode 100644 index 2b0b159..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/ApiClient.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.ainotebook.app.data - -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import com.ainotebook.app.BuildConfig -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.Json -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import java.util.concurrent.TimeUnit - -/** - * Builds the shared OkHttp + Retrofit stack. The auth interceptor injects the - * current JWT (read from [SessionStore]) into every request's Authorization - * header, matching the web app's `Bearer ` scheme. - */ -object ApiClient { - - private const val BASE = BuildConfig.API_BASE_URL - - val json = Json { - ignoreUnknownKeys = true - coerceInputValues = true - isLenient = true - } - - @Volatile - private var service: ApiService? = null - - @Volatile - lateinit var okHttp: OkHttpClient - private set - - fun baseUrl(): String = if (BASE.endsWith("/")) BASE else "$BASE/" - - fun init(session: SessionStore) { - val authInterceptor = Interceptor { chain -> - val token = runBlocking { session.token() } - val builder = chain.request().newBuilder() - if (!token.isNullOrBlank()) { - builder.addHeader("Authorization", "Bearer $token") - } - chain.proceed(builder.build()) - } - - val logging = HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC - else HttpLoggingInterceptor.Level.NONE - } - - okHttp = OkHttpClient.Builder() - .addInterceptor(authInterceptor) - .addInterceptor(logging) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) // long for SSE streaming - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - val contentType = "application/json".toMediaType() - service = Retrofit.Builder() - .baseUrl(baseUrl()) - .client(okHttp) - .addConverterFactory(json.asConverterFactory(contentType)) - .build() - .create(ApiService::class.java) - } - - val api: ApiService - get() = service ?: error("ApiClient.init() must be called first") -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/ApiService.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/ApiService.kt deleted file mode 100644 index 2159ad4..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/ApiService.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.ainotebook.app.data - -import retrofit2.http.Body -import retrofit2.http.DELETE -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.PUT -import retrofit2.http.Path - -/** - * Retrofit declaration of the AI Notebook REST API. - * (Streaming chat is handled separately via raw OkHttp in [StreamClient].) - */ -interface ApiService { - - /* ---------- Auth ---------- */ - @POST("api/auth/login") - suspend fun login(@Body body: LoginRequest): AuthResponse - - @POST("api/auth/signup") - suspend fun signup(@Body body: SignupRequest): AuthResponse - - @POST("api/auth/guest") - suspend fun guest(): AuthResponse - - @GET("api/auth/me") - suspend fun me(): MeResponse - - @PUT("api/auth/profile") - suspend fun updateProfile(@Body body: ProfileRequest): MeResponse - - @PUT("api/auth/change-password") - suspend fun changePassword(@Body body: ChangePasswordRequest): MessageResponse - - @DELETE("api/auth/account") - suspend fun deleteAccount(): MessageResponse - - /* ---------- Chats (CRUD ONLY) ---------- - * - * SINGLE SOURCE OF TRUTH: - * Sending a message and receiving an AI reply is STREAMING-ONLY and lives - * exclusively in [StreamClient] via POST api/chats/{id}/stream (SSE). - * - * The backend exposes NO REST "send message" endpoint. Do NOT add a - * `@POST("api/chats/{id}")` here — that route only accepts GET/PUT/DELETE - * server-side and would fail with 405 Method Not Allowed / HTML fallback. - * Keep these declarations limited to chat metadata CRUD. - */ - @GET("api/chats") - suspend fun listChats(): ChatListResponse - - @POST("api/chats") - suspend fun newChat(@Body body: NewChatRequest): ChatResponse - - @GET("api/chats/{id}") - suspend fun getChat(@Path("id") id: Int): ChatDetailResponse - - @PUT("api/chats/{id}") - suspend fun renameChat(@Path("id") id: Int, @Body body: RenameChatRequest): ChatResponse - - @DELETE("api/chats/{id}") - suspend fun deleteChat(@Path("id") id: Int): Map - - /* ---------- AI model selection ---------- */ - @GET("api/ai/models") - suspend fun aiModels(): ModelsResponse - - @PUT("api/ai/model") - suspend fun setAiModel(@Body body: SetModelRequest): SetModelResponse - - /* ---------- Dashboard ---------- */ - @GET("api/stats") - suspend fun stats(): Stats - - /* ---------- Study tools ---------- */ - @POST("api/tools/notes") - suspend fun generateNotes(@Body body: TopicRequest): NotesResponse - - @POST("api/tools/quiz") - suspend fun generateQuiz(@Body body: QuizRequest): QuizGenResponse - - @POST("api/tools/flashcards") - suspend fun generateFlashcards(@Body body: FlashRequest): FlashResponse - - @POST("api/tools/plan") - suspend fun generatePlan(@Body body: PlanRequest): PlanResponse - - @POST("api/tools/summarize") - suspend fun summarize(@Body body: TextRequest): SummaryResponse - - @POST("api/tools/homework") - suspend fun homework(@Body body: QuestionRequest): HomeworkResponse -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/Models.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/Models.kt deleted file mode 100644 index 70f5ea4..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/Models.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.ainotebook.app.data - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -/* ========================================================================= - * Data models that mirror the AI Notebook FastAPI backend contracts. - * ========================================================================= */ - -@Serializable -data class User( - val id: Int = 0, - val name: String = "", - val username: String? = null, - val email: String = "", - val created_at: String? = null, - val last_login: String? = null, - val is_guest: Boolean = false -) - -@Serializable -data class AuthResponse( - val token: String = "", - val user: User = User(), - val guest: Boolean = false -) - -@Serializable -data class MeResponse(val user: User = User()) - -@Serializable -data class MessageResponse(val message: String = "") - -/* ---------- Auth request bodies ---------- */ -@Serializable -data class LoginRequest(val identifier: String, val password: String) - -@Serializable -data class SignupRequest( - val name: String, - val username: String, - val email: String, - val password: String, - val confirm_password: String -) - -@Serializable -data class ProfileRequest(val name: String) - -@Serializable -data class ChangePasswordRequest( - val current_password: String, - val new_password: String -) - -/* ---------- Chat ---------- */ -@Serializable -data class Chat( - val id: Int = 0, - val title: String = "New Chat", - val created_at: String? = null, - val updated_at: String? = null -) - -@Serializable -data class ChatResponse(val chat: Chat = Chat()) - -@Serializable -data class ChatListResponse(val chats: List = emptyList()) - -@Serializable -data class ChatMessage( - val id: Int = 0, - val role: String = "user", - val content: String = "", - val created_at: String? = null -) - -@Serializable -data class ChatDetailResponse( - val chat: Chat = Chat(), - val messages: List = emptyList() -) - -@Serializable -data class NewChatRequest(val title: String? = null) - -@Serializable -data class RenameChatRequest(val title: String) - -@Serializable -data class StreamRequest( - val content: String, - val model: String? = null // optional per-message provider override -) - -/* ---------- AI model selection (matches /api/ai/models + /api/ai/model) ---------- */ -@Serializable -data class AiProvider( - val id: String = "", - @SerialName("label") - val name: String = "", // ← backend sends "label" not "name", this fixes it - val configured: Boolean = false -) - -@Serializable -data class ModelsResponse( - val selected: String = "auto", - val options: List = listOf("auto"), - val providers: List = emptyList(), - @SerialName("display_names") - val displayNames: Map = emptyMap() // ← ADD THIS -) - -@Serializable -data class SetModelRequest(val model: String) - -@Serializable -data class SetModelResponse(val selected: String = "auto") - -/* ---------- Dashboard stats ---------- */ -@Serializable -data class Stats( - val total_chats: Int = 0, - val total_messages: Int = 0, - val ai_responses: Int = 0, - val notes: Int = 0, - val quizzes: Int = 0, - val recent_chats: List = emptyList(), - val daily_activity: List = emptyList() -) - -@Serializable -data class DailyActivity( - val day: String = "", - val count: Int = 0 -) - -/* ---------- Study tools ---------- */ -@Serializable -data class TopicRequest(val topic: String) - -@Serializable -data class QuizRequest(val topic: String, val num_questions: Int = 5) - -@Serializable -data class FlashRequest(val topic: String, val num_cards: Int = 8) - -@Serializable -data class PlanRequest(val goal: String, val days: Int = 7) - -@Serializable -data class TextRequest(val text: String) - -@Serializable -data class QuestionRequest(val question: String) - -@Serializable -data class NotesResponse( - val id: Int = 0, - val topic: String = "", - val content: String = "" -) - -@Serializable -data class QuizQuestion( - val question: String = "", - val options: List = emptyList(), - val answer: Int = 0, - val explanation: String = "" -) - -@Serializable -data class QuizGenResponse( - val id: Int = 0, - val topic: String = "", - val questions: List = emptyList() -) - -@Serializable -data class Flashcard( - val front: String = "", - val back: String = "" -) - -@Serializable -data class FlashResponse( - val topic: String = "", - val cards: List = emptyList() -) - -@Serializable -data class PlanResponse( - val goal: String = "", - val days: Int = 0, - val content: String = "" -) - -@Serializable -data class SummaryResponse(val summary: String = "") - -@Serializable -data class HomeworkResponse(val answer: String = "") diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/NetworkMonitor.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/NetworkMonitor.kt deleted file mode 100644 index ab8f54d..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/NetworkMonitor.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.ainotebook.app.data - -import android.content.Context -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged - -/** - * Observes the device's connectivity state and emits `true` while the app has a - * validated internet connection. Used to drive the in-app offline banner and to - * surface graceful "you're offline" states (premium reliability behaviour, like - * ChatGPT/Gemini), without changing any networking/API logic. - */ -class NetworkMonitor(context: Context) { - - private val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - val isOnline: Flow = callbackFlow { - val callback = object : ConnectivityManager.NetworkCallback() { - private val networks = mutableSetOf() - - override fun onAvailable(network: Network) { - networks += network - trySend(true) - } - - override fun onLost(network: Network) { - networks -= network - trySend(networks.isNotEmpty()) - } - - override fun onCapabilitiesChanged( - network: Network, - caps: NetworkCapabilities - ) { - val hasInternet = - caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - if (hasInternet) networks += network else networks -= network - trySend(networks.isNotEmpty()) - } - } - - // Emit the current state immediately so the UI starts in the right state. - trySend(currentlyOnline()) - - val request = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - connectivityManager.registerNetworkCallback(request, callback) - - awaitClose { connectivityManager.unregisterNetworkCallback(callback) } - }.distinctUntilChanged() - - fun currentlyOnline(): Boolean { - val active = connectivityManager.activeNetwork ?: return false - val caps = connectivityManager.getNetworkCapabilities(active) ?: return false - return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/Repository.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/Repository.kt deleted file mode 100644 index 262cf2e..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/Repository.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.ainotebook.app.data - -import kotlinx.coroutines.flow.Flow - -/** - * Thin repository wrapping [ApiClient] + [SessionStore]. Holds the session - * lifecycle so view models stay free of networking details. - * - * All network calls are suspend (main-safe via ApiClient's dispatcher); - * [streamMessage] intentionally returns a cold [Flow] and is NOT suspend. - */ -class Repository(private val session: SessionStore) { - - val tokenFlow get() = session.tokenFlow - val userFlow get() = session.userFlow - - suspend fun currentToken(): String? = session.token() - - /* ---------- Auth ---------- */ - suspend fun login(identifier: String, password: String): User { - val res = ApiClient.api.login(LoginRequest(identifier, password)) - session.save(res.token, res.user) - return res.user - } - - suspend fun signup( - name: String, username: String, email: String, - password: String, confirm: String - ): User { - val res = ApiClient.api.signup( - SignupRequest(name, username, email, password, confirm) - ) - session.save(res.token, res.user) - return res.user - } - - suspend fun guest(): User { - val res = ApiClient.api.guest() - // Persist the guest flag locally in case the backend omits it, - // but keep every other field exactly as the server returned it. - val guestUser = res.user.copy(is_guest = true) - session.save(res.token, guestUser) - return guestUser - } - - suspend fun me(): User { - val res = ApiClient.api.me() - session.saveUser(res.user) - return res.user - } - - suspend fun updateProfile(name: String): User { - val res = ApiClient.api.updateProfile(ProfileRequest(name)) - session.saveUser(res.user) - return res.user - } - - suspend fun changePassword(current: String, new: String): String = - ApiClient.api.changePassword(ChangePasswordRequest(current, new)).message - - /** - * Deletes the account server-side, then ALWAYS clears the local session. - * The session is cleared in a `finally` block so a failed/partial network - * call can never leave a stale token or user behind. - */ - suspend fun deleteAccount(): String { - return try { - ApiClient.api.deleteAccount().message - } finally { - session.clear() - } - } - - /** Clears the local session. Resilient: never propagates a clear failure. */ - suspend fun logout() { - session.clear() - } - - /* ---------- Chats ---------- */ - suspend fun listChats() = ApiClient.api.listChats().chats - - suspend fun newChat(title: String? = null) = - ApiClient.api.newChat(NewChatRequest(title)).chat - - suspend fun getChat(id: Int) = ApiClient.api.getChat(id) - - suspend fun renameChat(id: Int, title: String) = - ApiClient.api.renameChat(id, RenameChatRequest(title)).chat - - suspend fun deleteChat(id: Int) = ApiClient.api.deleteChat(id) - - /** - * Streams an assistant reply for [chatId]. Returns a cold [Flow] so the - * caller controls collection/cancellation. If [token] is null, the stored - * session token is used to keep streaming consistent with REST calls. - */ - suspend fun streamMessage( - chatId: Int, - content: String, - token: String? = null, - model: String? = null - ): Flow { - val authToken = token ?: session.token() - return StreamClient.streamMessage(chatId, content, authToken, model) - } - - /* ---------- AI model selection ---------- */ - suspend fun aiModels() = ApiClient.api.aiModels() - - suspend fun setAiModel(model: String) = - ApiClient.api.setAiModel(SetModelRequest(model)).selected - - /* ---------- Dashboard ---------- */ - suspend fun stats() = ApiClient.api.stats() - - /* ---------- Study tools ---------- */ - suspend fun generateNotes(topic: String) = - ApiClient.api.generateNotes(TopicRequest(topic)) - - suspend fun generateQuiz(topic: String, n: Int) = - ApiClient.api.generateQuiz(QuizRequest(topic, n)) - - suspend fun generateFlashcards(topic: String, n: Int) = - ApiClient.api.generateFlashcards(FlashRequest(topic, n)) - - suspend fun generatePlan(goal: String, days: Int) = - ApiClient.api.generatePlan(PlanRequest(goal, days)) - - suspend fun summarize(text: String) = - ApiClient.api.summarize(TextRequest(text)) - - suspend fun homework(question: String) = - ApiClient.api.homework(QuestionRequest(question)) -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/SessionStore.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/SessionStore.kt deleted file mode 100644 index 37495b1..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/SessionStore.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.ainotebook.app.data - -import android.content.Context -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.first -import kotlinx.coroutines.flow.map -import kotlinx.serialization.json.Json - -private val Context.dataStore by preferencesDataStore(name = "ai_notebook_session") - -/** - * Persists the JWT token and the cached user profile using Jetpack DataStore. - */ -class SessionStore(private val context: Context) { - - companion object { - private val TOKEN_KEY = stringPreferencesKey("ss_token") - private val USER_KEY = stringPreferencesKey("ss_user") - private val json = Json { ignoreUnknownKeys = true } - } - - val tokenFlow: Flow = context.dataStore.data.map { it[TOKEN_KEY] } - - val userFlow: Flow = context.dataStore.data.map { prefs -> - prefs[USER_KEY]?.let { - runCatching { json.decodeFromString(it) }.getOrNull() - } - } - - suspend fun token(): String? = context.dataStore.data.first()[TOKEN_KEY] - - suspend fun save(token: String, user: User) { - context.dataStore.edit { prefs -> - prefs[TOKEN_KEY] = token - prefs[USER_KEY] = json.encodeToString(User.serializer(), user) - } - } - - suspend fun saveUser(user: User) { - context.dataStore.edit { prefs -> - prefs[USER_KEY] = json.encodeToString(User.serializer(), user) - } - } - - suspend fun clear() { - context.dataStore.edit { it.clear() } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/StreamClient.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/StreamClient.kt deleted file mode 100644 index 78bcf12..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/StreamClient.kt +++ /dev/null @@ -1,414 +0,0 @@ -package com.ainotebook.app.data - -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.isActive -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.ProducerScope -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.flowOn -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.Call -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okio.BufferedSource -import java.io.IOException -import java.io.InterruptedIOException -import java.net.SocketTimeoutException -import java.util.concurrent.TimeUnit - -/** - * Consumes the backend's Server-Sent Events stream from - * POST /api/chats/{id}/stream and emits incremental tokens. - * - * Provider-agnostic: tolerates the slightly different SSE payload shapes used - * by Groq, Gemini and Kimi (and OpenAI-compatible gateways), e.g. - * data: {"token":"..."} - * data: {"delta":"..."} - * data: {"content":"..."} - * data: {"text":"..."} - * data: {"choices":[{"delta":{"content":"..."}}]} (OpenAI / Groq / Kimi) - * data: {"done":true,"message_id":123} - * data: [DONE] (OpenAI-style sentinel) - * - * Guarantees: - * - Always returns a clean Flow. - * - Never throws into the collector; failures are emitted as StreamEvent.Error. - * - Always reaches exactly one terminal event (Done or Error) before closing. - * - Cancels the underlying HTTP call on close so no socket leaks. - */ -sealed class StreamEvent { - data class Token(val text: String) : StreamEvent() - data class Done(val messageId: Int?) : StreamEvent() - data class Error(val message: String, val retryable: Boolean = false) : StreamEvent() -} - -object StreamClient { - - private val json = Json { ignoreUnknownKeys = true; isLenient = true } - - // Tuning knobs - private const val MAX_RETRIES = 2 - private const val INITIAL_BACKOFF_MS = 600L - private const val MAX_BACKOFF_MS = 4_000L - private const val CALL_TIMEOUT_SECONDS = 0L // 0 = no overall cap (streaming can be long) - private const val READ_TIMEOUT_SECONDS = 90L // idle read timeout between tokens - private const val CONNECT_TIMEOUT_SECONDS = 20L - - fun streamMessage( - chatId: Int, - content: String, - token: String?, - model: String? = null - ): Flow = callbackFlow { - // Tracks whether we already emitted Done/Error so close() is always clean. - var terminated = false - - fun emitTerminal(event: StreamEvent) { - if (terminated) return - terminated = true - if (!isClosedForSend) trySend(event) - } - - // ---- Build request once; reused across retries ---- - val url = ApiClient.baseUrl() + "api/chats/$chatId/stream" - val mediaType = "application/json".toMediaType() - // Only send an override when a concrete provider is chosen ("auto" - // lets the backend pick via the fallback chain, matching the web app). - val override = model?.takeIf { it.isNotBlank() && it != "auto" } - val payload = runCatching { - json.encodeToString(StreamRequest.serializer(), StreamRequest(content, override)) - }.getOrElse { - emitTerminal(StreamEvent.Error("Failed to build request: ${it.message}")) - close() - return@callbackFlow - } - - // Per-request OkHttp client with streaming-friendly timeouts. - val streamingClient = ApiClient.okHttp.newBuilder() - .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build() - - fun buildCall(): Call { - val reqBuilder = Request.Builder() - .url(url) - .addHeader("Accept", "text/event-stream") - .addHeader("Cache-Control", "no-cache") - .post(payload.toRequestBody(mediaType)) - if (!token.isNullOrBlank()) { - reqBuilder.addHeader("Authorization", "Bearer $token") - } - return streamingClient.newCall(reqBuilder.build()) - } - - // FIX 1: Removed the illegal nested `object StreamClient { ... }` block. - // `activeCall` is simply a local variable captured by the awaitClose lambda. - var activeCall: Call? = null - - // ---- Retry loop ---- - var attempt = 0 - var backoff = INITIAL_BACKOFF_MS - - while (attempt <= MAX_RETRIES && !terminated && currentCoroutineContext().isActive) { - - // Result of a single attempt: DONE = finished (terminal emitted or success), - // RETRY = retryable transient failure. - val attemptOutcome: AttemptResult = try { - - // FIX 2: Removed the duplicate `val call = buildCall()` declaration. - val call = buildCall() - activeCall = call - - call.execute().use { response -> - when { - response.code == 401 || response.code == 403 -> { - emitTerminal(StreamEvent.Error("Unauthorized (${response.code})", retryable = false)) - AttemptResult.DONE - } - response.code == 429 || response.code in 500..599 -> { - // Server-side transient — worth retrying. - AttemptResult.RETRY("Server busy (${response.code})") - } - !response.isSuccessful -> { - val errorBody = runCatching { - response.body?.string() - }.getOrNull() - - android.util.Log.e( - "API_ERROR", - "Code=${response.code} Body=$errorBody" - ) - - emitTerminal( - StreamEvent.Error( - "Request failed (${response.code}): $errorBody", - retryable = false - ) - ) - AttemptResult.DONE - } - else -> { - val source = response.body?.source() - if (source == null) { - emitTerminal(StreamEvent.Error("Empty response", retryable = false)) - AttemptResult.DONE - } else { - consumeStream(source, ::emitTerminal) { ev -> if (!isClosedForSend) trySend(ev) } - } - } - } - } - } catch (e: CancellationException) { - // Flow collector cancelled — do not emit, just stop. - throw e - } catch (e: SocketTimeoutException) { - AttemptResult.RETRY("Network timeout") - } catch (e: InterruptedIOException) { - AttemptResult.RETRY("Connection interrupted") - } catch (e: IOException) { - AttemptResult.RETRY(e.message ?: "Network error") - } catch (e: Exception) { - // Unexpected — never crash silently. - emitTerminal(StreamEvent.Error(e.message ?: "Stream interrupted", retryable = false)) - AttemptResult.DONE - } - - when (attemptOutcome) { - is AttemptResult.DONE -> break - is AttemptResult.RETRY -> { - attempt++ - if (attempt > MAX_RETRIES || !isActive) { - emitTerminal(StreamEvent.Error(attemptOutcome.reason, retryable = true)) - break - } - // Backoff before retrying; abort early if collector cancelled. - val waited = runCatching { delay(backoff) }.isSuccess - if (!waited || !isActive) break - backoff = (backoff * 2).coerceAtMost(MAX_BACKOFF_MS) - } - } - } - - // Safety net: if loop exited without any terminal event, emit Done so the - // collector is never left hanging (prevents perpetual "Streaming…" state). - if (!terminated) { - emitTerminal(StreamEvent.Done(null)) - } - - close() - - awaitClose { - runCatching { activeCall?.cancel() } - } - }.flowOn(Dispatchers.IO) - - /** - * Reads and parses the SSE body line-by-line. Emits Token/Done via [emit] and - * routes the single terminal event through [emitTerminal]. - * Returns DONE on a clean end, or RETRY if the stream ends abruptly without - * a terminal marker (so the caller can retry). - */ - private fun consumeStream( - source: BufferedSource, - emitTerminal: (StreamEvent) -> Unit, - emit: (StreamEvent) -> Unit - ): AttemptResult { - var sawDone = false - var sawAnyToken = false - // Buffer for multi-line SSE `data:` accumulation (Gemini can split frames). - val dataBuffer = StringBuilder() - - try { - while (true) { - val line = source.readUtf8Line() ?: break - - // Blank line == end of one SSE event; flush buffered data. - if (line.isBlank()) { - if (dataBuffer.isNotEmpty()) { - val handled = handleData(dataBuffer.toString().trim(), emitTerminal, emit) - if (handled.token) sawAnyToken = true - if (handled.done) { sawDone = true; break } - dataBuffer.setLength(0) - } - continue - } - - // Ignore SSE comments / event/id fields we don't use. - if (line.startsWith(":")) continue - if (!line.startsWith("data:")) continue - - val chunk = line.removePrefix("data:").trim() - if (chunk.isEmpty()) continue - - // OpenAI-style termination sentinel. - if (chunk == "[DONE]") { sawDone = true; break } - - // Accumulate; flushed on blank line, but also try eager parse - // for single-line JSON frames (Groq/Kimi/most providers). - if (dataBuffer.isEmpty()) { - val handled = handleData(chunk, emitTerminal, emit) - if (handled.token) sawAnyToken = true - if (handled.done) { sawDone = true; break } - // If it wasn't valid JSON on its own, buffer for multi-line join. - if (!handled.parsed) dataBuffer.append(chunk) - } else { - dataBuffer.append(chunk) - } - } - } catch (e: SocketTimeoutException) { - return AttemptResult.RETRY("Network timeout") - } catch (e: IOException) { - // Abrupt end: retry only if we never got a clean terminal. - return if (sawDone) AttemptResult.DONE else AttemptResult.RETRY(e.message ?: "Connection lost") - } - - if (sawDone) { - emitTerminal(StreamEvent.Done(null)) - return AttemptResult.DONE - } - // Stream ended without explicit [DONE]/done flag. - return if (sawAnyToken) { - // We received content but no terminal marker — treat as complete. - emitTerminal(StreamEvent.Done(null)) - AttemptResult.DONE - } else { - // Nothing at all — let caller retry. - AttemptResult.RETRY("Stream ended unexpectedly") - } - } - - private data class DataResult( - val parsed: Boolean, - val token: Boolean, - val done: Boolean - ) - - /** - * Parses one SSE data payload across provider formats and emits the - * appropriate event. Returns whether it parsed and what it contained. - */ - private fun handleData( - data: String, - emitTerminal: (StreamEvent) -> Unit, - emit: (StreamEvent) -> Unit - ): DataResult { - if (data.isEmpty()) return DataResult(parsed = false, token = false, done = false) - if (data == "[DONE]") { - emitTerminal(StreamEvent.Done(null)) - return DataResult(parsed = true, token = false, done = true) - } - - val obj = runCatching { json.parseToJsonElement(data).jsonObject }.getOrNull() - ?: return DataResult(parsed = false, token = false, done = false) - - // Explicit completion flag (backend / Gemini finishReason). - val doneFlag = obj["done"]?.jsonPrimitive?.booleanOrNull == true - val finishReason = obj["finish_reason"]?.jsonPrimitive?.contentOrNull - ?: obj["finishReason"]?.jsonPrimitive?.contentOrNull - ?: extractChoiceFinishReason(obj) - - // Provider error frame inside the stream. - extractError(obj)?.let { errMsg -> - emitTerminal(StreamEvent.Error(errMsg, retryable = false)) - return DataResult(parsed = true, token = false, done = true) - } - - // Extract token text across known shapes. - val text = extractToken(obj) - if (!text.isNullOrEmpty()) { - emit(StreamEvent.Token(text)) - } - - if (doneFlag || finishReason != null && finishReason != "null") { - val mid = obj["message_id"]?.jsonPrimitive?.intOrNull - ?: obj["messageId"]?.jsonPrimitive?.intOrNull - emitTerminal(StreamEvent.Done(mid)) - return DataResult(parsed = true, token = !text.isNullOrEmpty(), done = true) - } - - return DataResult(parsed = true, token = !text.isNullOrEmpty(), done = false) - } - - /** Pulls incremental text from any supported provider payload shape. */ - private fun extractToken(obj: JsonObject): String? { - // 1. Backend/simple shapes. - obj["token"]?.jsonPrimitive?.contentOrNull?.let { return it } - obj["delta"]?.let { el -> - // delta may be a string (simple) or an object (OpenAI). - el.jsonPrimitive.contentOrNull?.let { return it } - } - obj["content"]?.jsonPrimitive?.contentOrNull?.let { return it } - obj["text"]?.jsonPrimitive?.contentOrNull?.let { return it } - - // 2. OpenAI / Groq / Kimi: choices[].delta.content (or .text). - (obj["choices"] as? JsonArray)?.let { choices -> - val sb = StringBuilder() - for (choice in choices) { - val c = choice as? JsonObject ?: continue - val delta = c["delta"] as? JsonObject - val piece = delta?.get("content")?.jsonPrimitive?.contentOrNull - ?: c["text"]?.jsonPrimitive?.contentOrNull - ?: (c["message"] as? JsonObject)?.get("content")?.jsonPrimitive?.contentOrNull - if (!piece.isNullOrEmpty()) sb.append(piece) - } - if (sb.isNotEmpty()) return sb.toString() - } - - // 3. Gemini: candidates[].content.parts[].text - (obj["candidates"] as? JsonArray)?.let { candidates -> - val sb = StringBuilder() - for (cand in candidates) { - val c = cand as? JsonObject ?: continue - val parts = (c["content"] as? JsonObject)?.get("parts") as? JsonArray ?: continue - for (part in parts) { - (part as? JsonObject)?.get("text")?.jsonPrimitive?.contentOrNull?.let { sb.append(it) } - } - } - if (sb.isNotEmpty()) return sb.toString() - } - - return null - } - - /** Detects an error object embedded in a streamed frame. */ - private fun extractError(obj: JsonObject): String? { - (obj["error"])?.let { el -> - (el as? JsonObject)?.let { eo -> - return eo["message"]?.jsonPrimitive?.contentOrNull ?: "Provider error" - } - el.jsonPrimitive.contentOrNull?.let { return it } - } - return null - } - - private fun extractChoiceFinishReason(obj: JsonObject): String? { - val choices = obj["choices"] as? JsonArray ?: return null - for (choice in choices) { - (choice as? JsonObject)?.get("finish_reason")?.jsonPrimitive?.contentOrNull?.let { - if (it != "null") return it - } - } - return null - } - - /** Internal result type for the retry loop / stream consumer. */ - private sealed class AttemptResult { - data object DONE : AttemptResult() - data class RETRY(val reason: String) : AttemptResult() - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/ThemePreferences.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/ThemePreferences.kt deleted file mode 100644 index 1ef131a..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/ThemePreferences.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.ainotebook.app.data - -import android.content.Context -import androidx.datastore.preferences.core.booleanPreferencesKey -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.map - -private val Context.themeStore by preferencesDataStore(name = "ai_notebook_prefs") - -/** User-selectable theme mode for the app (mirrors ChatGPT/Gemini appearance settings). */ -enum class ThemeMode { SYSTEM, LIGHT, DARK } - -/** - * Persists user appearance + assistant preferences using Jetpack DataStore. - * Purely additive — does not touch the existing session/auth flow. - */ -class ThemePreferences(private val context: Context) { - - companion object { - private val THEME_KEY = stringPreferencesKey("pref_theme_mode") - private val DYNAMIC_KEY = booleanPreferencesKey("pref_dynamic_color") - private val HAPTICS_KEY = booleanPreferencesKey("pref_haptics") - private val MODEL_KEY = stringPreferencesKey("pref_ai_model") - } - - val themeMode: Flow = 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 dynamicColor: Flow = - context.themeStore.data.map { it[DYNAMIC_KEY] ?: false } - - val hapticsEnabled: Flow = - context.themeStore.data.map { it[HAPTICS_KEY] ?: true } - - val aiModel: Flow = - context.themeStore.data.map { it[MODEL_KEY] ?: "auto" } - - suspend fun setThemeMode(mode: ThemeMode) { - context.themeStore.edit { it[THEME_KEY] = mode.name } - } - - suspend fun setDynamicColor(enabled: Boolean) { - context.themeStore.edit { it[DYNAMIC_KEY] = enabled } - } - - suspend fun setHaptics(enabled: Boolean) { - context.themeStore.edit { it[HAPTICS_KEY] = enabled } - } - - suspend fun setAiModel(model: String) { - context.themeStore.edit { it[MODEL_KEY] = model } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/AppLocals.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/AppLocals.kt deleted file mode 100644 index 067836b..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/AppLocals.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.compose.runtime.staticCompositionLocalOf -import com.ainotebook.app.data.NetworkMonitor -import com.ainotebook.app.data.ThemePreferences - -/** - * App-wide composition locals so deep screens (settings, chat) can reach the - * appearance preferences and connectivity monitor without prop-drilling. - */ -val LocalAppPrefs = staticCompositionLocalOf { - error("ThemePreferences not provided") -} - -val LocalNetworkMonitor = staticCompositionLocalOf { - error("NetworkMonitor not provided") -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/AppNav.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/AppNav.kt deleted file mode 100644 index 92ad126..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/AppNav.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Widgets -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.ainotebook.app.data.Repository -import com.ainotebook.app.ui.components.OfflineBanner -import com.ainotebook.app.ui.components.SpaceBackground -import com.ainotebook.app.ui.components.rememberHaptics -import com.ainotebook.app.ui.screens.ChatScreen -import com.ainotebook.app.ui.screens.DashboardScreen -import com.ainotebook.app.ui.screens.LoginScreen -import com.ainotebook.app.ui.screens.ProfileScreen -import com.ainotebook.app.ui.screens.SignupScreen -import com.ainotebook.app.ui.screens.ToolsScreen -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.MutedText - -object Routes { - const val LOGIN = "login" - const val SIGNUP = "signup" - const val DASHBOARD = "dashboard" - const val CHAT = "chat" - const val TOOLS = "tools" - const val PROFILE = "profile" -} - -private data class NavItem(val route: String, val label: String, val icon: ImageVector) - -private val BOTTOM_ITEMS = listOf( - NavItem(Routes.DASHBOARD, "Home", Icons.Default.Dashboard), - NavItem(Routes.CHAT, "Chat", Icons.AutoMirrored.Filled.Chat), - NavItem(Routes.TOOLS, "Tools", Icons.Default.Widgets), - NavItem(Routes.PROFILE, "Profile", Icons.Default.Person), -) - -@Composable -fun AppRoot(repo: Repository) { - val factory = remember(repo) { VMFactory(repo) } - val token by repo.tokenFlow.collectAsState(initial = null) - val isAuthed = !token.isNullOrBlank() - - if (isAuthed) { - MainShell(factory) - } else { - AuthFlow(factory) - } -} - -@Composable -private fun AuthFlow(factory: VMFactory) { - val nav = rememberNavController() - val authVm: AuthViewModel = viewModel(factory = factory) - - NavHost( - navController = nav, - startDestination = Routes.LOGIN, - enterTransition = { slideInHorizontally(tween(300)) { it / 2 } + fadeIn(tween(300)) }, - exitTransition = { fadeOut(tween(200)) }, - popEnterTransition = { fadeIn(tween(300)) }, - popExitTransition = { slideOutHorizontally(tween(300)) { it / 2 } + fadeOut(tween(200)) } - ) { - composable(Routes.LOGIN) { - LoginScreen( - vm = authVm, - onLoggedIn = { /* token flow flips AppRoot to MainShell */ }, - onGoSignup = { nav.navigate(Routes.SIGNUP) } - ) - } - composable(Routes.SIGNUP) { - SignupScreen( - vm = authVm, - onSignedUp = { }, - onGoLogin = { nav.popBackStack() } - ) - } - } -} - -@Composable -private fun MainShell(factory: VMFactory) { - val nav = rememberNavController() - val backStack by nav.currentBackStackEntryAsState() - val current = backStack?.destination?.route - val haptic = rememberHaptics() - - val dashboardVm: DashboardViewModel = viewModel(factory = factory) - val chatVm: ChatViewModel = viewModel(factory = factory) - val toolsVm: ToolsViewModel = viewModel(factory = factory) - val profileVm: ProfileViewModel = viewModel(factory = factory) - - val userState by profileVm.state.collectAsState() - val networkMonitor = LocalNetworkMonitor.current - val isOnline by networkMonitor.isOnline.collectAsState(initial = networkMonitor.currentlyOnline()) - - SpaceBackground { - Scaffold( - containerColor = androidx.compose.ui.graphics.Color.Transparent, - bottomBar = { - NavigationBar( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), - tonalElevation = 0.dp - ) { - BOTTOM_ITEMS.forEach { item -> - val selected = current?.startsWith(item.route) == true - NavigationBarItem( - selected = selected, - onClick = { - if (!selected) { - haptic() - nav.navigate(item.route) { - popUpTo(Routes.DASHBOARD) { saveState = true } - launchSingleTop = true - restoreState = true - } - } - }, - icon = { Icon(item.icon, contentDescription = item.label) }, - label = { - Text( - item.label, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal - ) - }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Indigo, - selectedTextColor = Indigo, - unselectedIconColor = MutedText, - unselectedTextColor = MutedText, - indicatorColor = Indigo.copy(alpha = 0.16f) - ) - ) - } - } - } - ) { padding -> - Column(Modifier.padding(padding)) { - OfflineBanner(isOnline = isOnline) - NavHost( - navController = nav, - startDestination = Routes.DASHBOARD, - enterTransition = { fadeIn(tween(220)) + slideInHorizontally(tween(260)) { it / 6 } }, - exitTransition = { fadeOut(tween(160)) }, - popEnterTransition = { fadeIn(tween(220)) }, - popExitTransition = { fadeOut(tween(160)) } - ) { - composable(Routes.DASHBOARD) { - DashboardScreen( - vm = dashboardVm, - userName = userState.user?.name ?: "", - onOpenChat = { id -> nav.navigate("${Routes.CHAT}?chatId=$id") }, - onNewChat = { - nav.navigate(Routes.CHAT) { - popUpTo(Routes.DASHBOARD) { saveState = true } - launchSingleTop = true - } - }, - onOpenTools = { - nav.navigate(Routes.TOOLS) { - popUpTo(Routes.DASHBOARD) { saveState = true } - launchSingleTop = true - } - } - ) - } - composable( - route = "${Routes.CHAT}?chatId={chatId}", - arguments = listOf(navArgument("chatId") { - type = NavType.IntType; defaultValue = -1 - }) - ) { entry -> - val chatId = entry.arguments?.getInt("chatId") ?: -1 - ChatScreen(vm = chatVm, initialChatId = if (chatId > 0) chatId else null) - } - composable(Routes.CHAT) { - ChatScreen(vm = chatVm, initialChatId = null) - } - composable(Routes.TOOLS) { - ToolsScreen(vm = toolsVm) - } - composable(Routes.PROFILE) { - ProfileScreen( - vm = profileVm, - onLoggedOut = { /* token flow flips AppRoot to AuthFlow */ } - ) - } - } - } - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/AuthViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/AuthViewModel.kt deleted file mode 100644 index 8c6f0d2..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/AuthViewModel.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ainotebook.app.data.Repository -import com.ainotebook.app.data.User -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -data class AuthUiState( - val loading: Boolean = false, - val error: String? = null, - val success: Boolean = false -) - -class AuthViewModel(private val repo: Repository) : ViewModel() { - - private val _state = MutableStateFlow(AuthUiState()) - val state: StateFlow = _state.asStateFlow() - - val userFlow = repo.userFlow - - fun clearError() { - _state.value = _state.value.copy(error = null) - } - - private fun run(block: suspend () -> User) { - viewModelScope.launch { - _state.value = AuthUiState(loading = true) - try { - block() - _state.value = AuthUiState(success = true) - } catch (e: Exception) { - _state.value = 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.") - return - } - run { repo.login(identifier.trim(), password) } - } - - fun signup( - name: String, username: String, email: String, - password: String, confirm: String -) { - 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.") - password.length < 6 -> - _state.value = AuthUiState(error = "Password must be at least 6 characters.") - password != confirm -> - _state.value = AuthUiState(error = "Passwords do not match.") - else -> run { - repo.signup(name.trim(), username.trim(), email.trim(), password, confirm) - } - } -} - - fun guest() = run { repo.guest() } - - private fun friendly(e: Exception): String { - if (e is retrofit2.HttpException) { - val code = e.code() - val detail = try { - val body = e.response()?.errorBody()?.string() - if (!body.isNullOrBlank()) { - org.json.JSONObject(body).optString("detail", null) - } else null - } catch (ignored: Exception) { null } - - return when (code) { - 400 -> detail ?: "Invalid request. Please check your details." - 401 -> detail ?: "Invalid credentials." - 409 -> detail ?: "That account already exists." - 422 -> "Please check all fields and try again." - 429 -> "Too many requests. Please slow down." - 500 -> "Server error. Please try again later." - else -> detail ?: "Request failed ($code)." - } - } - - return when { - e.message?.contains("Unable to resolve host", true) == true || - e.message?.contains("timeout", true) == true || - e.message?.contains("failed to connect", true) == true -> - "Cannot reach the server. Check your connection." - else -> e.message ?: "Something went wrong." - } -} -} - diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/ChatViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/ChatViewModel.kt deleted file mode 100644 index 54fd47d..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/ChatViewModel.kt +++ /dev/null @@ -1,319 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ainotebook.app.data.Chat -import com.ainotebook.app.data.ChatMessage -import com.ainotebook.app.data.Repository -import com.ainotebook.app.data.StreamEvent -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update // ← required for atomic CAS updates -import kotlinx.coroutines.launch - -data class ChatUiState( - val chats: List = emptyList(), - val currentChatId: Int? = null, - val currentTitle: String = "New Chat", - val messages: List = emptyList(), - val streaming: Boolean = false, - val loading: Boolean = false, - val error: String? = null, - val model: String = "auto", - val modelOptions: List = listOf("auto"), - val pinnedChatIds: Set = emptySet(), - val searchQuery: String = "" -) { - val visibleChats: List - get() { - val filtered = if (searchQuery.isBlank()) chats - else chats.filter { it.title.contains(searchQuery, ignoreCase = true) } - // Pinned chats float to the top, preserving recency order within each group. - return filtered.sortedByDescending { it.id in pinnedChatIds } - } -} - -class ChatViewModel(private val repo: Repository) : ViewModel() { - - private val _state = MutableStateFlow(ChatUiState()) - val state: StateFlow = _state.asStateFlow() - - init { loadModelOptions() } - - // ─── Chat list ──────────────────────────────────────────────────────────── - - fun loadChats() { - viewModelScope.launch { - try { - val chats = repo.listChats() - _state.update { it.copy(chats = chats) } - } catch (e: Exception) { - _state.update { it.copy(error = friendly(e)) } - } - } - } - - // ─── Model selection ────────────────────────────────────────────────────── - - private fun loadModelOptions() { - viewModelScope.launch { - try { - val res = repo.aiModels() - _state.update { - it.copy( - model = res.selected.ifBlank { "auto" }, - modelOptions = res.options.ifEmpty { listOf("auto") } - ) - } - } catch (_: Exception) { - // Non-fatal — defaults already set in ChatUiState. - } - } - } - - fun selectModel(model: String) { - _state.update { it.copy(model = model) } - viewModelScope.launch { - try { repo.setAiModel(model) } catch (_: Exception) { /* best-effort persist */ } - } - } - - // ─── Search / pin ───────────────────────────────────────────────────────── - - fun setSearchQuery(q: String) { - _state.update { it.copy(searchQuery = q) } - } - - fun togglePin(chatId: Int) { - // FIX: entire read-modify-write is inside update {} so it cannot race. - _state.update { current -> - val pins = current.pinnedChatIds.toMutableSet() - if (!pins.add(chatId)) pins.remove(chatId) - current.copy(pinnedChatIds = pins) - } - } - - // ─── Chat lifecycle ─────────────────────────────────────────────────────── - - fun openChat(chatId: Int) { - viewModelScope.launch { - _state.update { it.copy(loading = true, error = null) } - try { - val detail = repo.getChat(chatId) - _state.update { - it.copy( - loading = false, - currentChatId = detail.chat.id, - currentTitle = detail.chat.title, - messages = detail.messages - ) - } - } catch (e: Exception) { - _state.update { it.copy(loading = false, error = friendly(e)) } - } - } - } - - fun startNewChat() { - _state.update { - it.copy( - currentChatId = null, - currentTitle = "New Chat", - messages = emptyList(), - error = null - ) - } - } - - fun deleteChat(chatId: Int) { - viewModelScope.launch { - try { - repo.deleteChat(chatId) - // FIX: capture wasCurrentChat BEFORE mutating state so we don't - // read a value we just changed. - val wasCurrentChat = _state.value.currentChatId == chatId - _state.update { current -> - val pins = current.pinnedChatIds.toMutableSet().apply { remove(chatId) } - current.copy(pinnedChatIds = pins) - } - if (wasCurrentChat) startNewChat() - loadChats() - } catch (e: Exception) { - _state.update { it.copy(error = friendly(e)) } - } - } - } - - fun renameChat(chatId: Int, title: String) { - if (title.isBlank()) return - viewModelScope.launch { - try { - val chat = repo.renameChat(chatId, title.trim()) - _state.update { current -> - if (current.currentChatId == chatId) current.copy(currentTitle = chat.title) - else current - } - loadChats() - } catch (e: Exception) { - _state.update { it.copy(error = friendly(e)) } - } - } - } - - // ─── Messaging ──────────────────────────────────────────────────────────── - - /** - * Send a message — creating a chat first if needed — and stream the reply. - */ - fun send(content: String) { - if (content.isBlank() || _state.value.streaming) return - viewModelScope.launch { - try { - // Resolve or create a chat ID before touching the message list. - val chatId = _state.value.currentChatId ?: run { - val chat = repo.newChat() - _state.update { - it.copy(currentChatId = chat.id, currentTitle = chat.title) - } - chat.id - } - - // FIX: append both bubbles AND set streaming=true in a single atomic - // update so the UI never sees a partial state (e.g. streaming=false - // while the assistant bubble already exists). - val userMsg = ChatMessage(id = -1, role = "user", content = content) - val assistantMsg = ChatMessage(id = -2, role = "assistant", content = "") - _state.update { - it.copy( - // FIX: read it.messages inside the lambda — not _state.value.messages - // from an outer scope — to guarantee we append to the latest list. - messages = it.messages + userMsg + assistantMsg, - streaming = true, - error = null - ) - } - - streamReply(chatId, content) - } catch (e: Exception) { - _state.update { it.copy(streaming = false, error = friendly(e)) } - } - } - } - - /** - * Regenerate the latest assistant reply: re-send the most recent user - * message and stream a fresh answer in place (ChatGPT/Gemini behaviour). - */ - fun regenerateLast() { - if (_state.value.streaming) return - // Snapshot outside the coroutine; these values are immutable data. - val currentState = _state.value - val lastUser = currentState.messages.lastOrNull { it.role == "user" } ?: return - val chatId = currentState.currentChatId ?: return - - viewModelScope.launch { - // FIX: added try-catch — without it, any exception thrown by streamReply - // (e.g. from repo.currentToken()) would leave streaming=true forever and - // swallow the error silently. - try { - // FIX: atomic swap of the trailing assistant bubble. - _state.update { current -> - val trimmed = current.messages.toMutableList() - if (trimmed.isNotEmpty() && trimmed.last().role == "assistant") { - trimmed.removeAt(trimmed.lastIndex) - } - trimmed.add(ChatMessage(id = -2, role = "assistant", content = "")) - current.copy(messages = trimmed, streaming = true, error = null) - } - streamReply(chatId, lastUser.content) - } catch (e: Exception) { - _state.update { it.copy(streaming = false, error = friendly(e)) } - } - } - } - - /** - * Edit the last user message and resend it (Claude/ChatGPT-style edit). - * Removes the old user+assistant pair and submits the new text. - */ - fun editAndResend(newContent: String) { - if (newContent.isBlank() || _state.value.streaming) return - val lastUserIdx = _state.value.messages.indexOfLast { it.role == "user" } - if (lastUserIdx < 0) { - send(newContent) - return - } - // FIX: trim happens atomically inside update {}; the subsequent send() will - // then read the already-trimmed list when it appends the new bubbles. - _state.update { current -> - val trimmed = current.messages.toMutableList() - while (trimmed.size > lastUserIdx) trimmed.removeAt(trimmed.lastIndex) - current.copy(messages = trimmed) - } - send(newContent) - } - - // ─── Internal streaming ─────────────────────────────────────────────────── - - private suspend fun streamReply(chatId: Int, content: String) { - val token = repo.currentToken() - val sb = StringBuilder() - try { - repo.streamMessage(chatId, content, token, _state.value.model).collect { event -> - when (event) { - is StreamEvent.Token -> { - sb.append(event.text) - updateLastAssistant(sb.toString()) - } - is StreamEvent.Done -> { - _state.update { it.copy(streaming = false) } - loadChats() - } - is StreamEvent.Error -> { - if (sb.isEmpty()) updateLastAssistant("⚠️ ${event.message}") - _state.update { it.copy(streaming = false) } - } - } - } - } finally { - // FIX: conditional update avoids emitting a spurious state change when - // Done/Error already cleared the flag. Coroutine cancellation is also - // covered — streaming will always be false when this scope exits. - _state.update { if (it.streaming) it.copy(streaming = false) else it } - } - } - - /** - * Atomically patch the last assistant bubble's text. - * FIX: using update {} prevents a torn read-modify-write on every streaming - * token, which was the highest-frequency race in the original code. - */ - private fun updateLastAssistant(text: String) { - _state.update { current -> - val msgs = current.messages.toMutableList() - val idx = msgs.indexOfLast { it.role == "assistant" } - if (idx >= 0) { - msgs[idx] = msgs[idx].copy(content = text) - current.copy(messages = msgs) - } else current // guard: no-op if the bubble is somehow missing - } - } - - // ─── Misc ───────────────────────────────────────────────────────────────── - - fun clearError() { - _state.update { it.copy(error = null) } - } - - private fun friendly(e: Exception): String { - val msg = e.message ?: "Something went wrong." - return when { - msg.contains("Unable to resolve host", true) || - msg.contains("timeout", true) || - msg.contains("failed to connect", true) -> - "Cannot reach the server. Check your connection and try again." - else -> msg - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/DashboardViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/DashboardViewModel.kt deleted file mode 100644 index 28fdca6..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/DashboardViewModel.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ainotebook.app.data.Repository -import com.ainotebook.app.data.Stats -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -data class DashboardUiState( - val loading: Boolean = true, - val stats: Stats? = null, - val error: String? = null -) - -class DashboardViewModel(private val repo: Repository) : ViewModel() { - - private val _state = MutableStateFlow(DashboardUiState()) - val state: StateFlow = _state.asStateFlow() - - init { load() } - - fun load() { - viewModelScope.launch { - _state.value = _state.value.copy(loading = true, error = null) - try { - val s = repo.stats() - _state.value = DashboardUiState(loading = false, stats = s) - } catch (e: Exception) { - _state.value = DashboardUiState( - loading = false, - error = e.message ?: "Could not load your dashboard." - ) - } - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/ProfileViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/ProfileViewModel.kt deleted file mode 100644 index 3c1e1d6..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/ProfileViewModel.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ainotebook.app.data.Repository -import com.ainotebook.app.data.User -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -data class ProfileUiState( - val user: User? = null, - val loading: Boolean = false, - val message: String? = null, - val error: String? = null, - val loggedOut: Boolean = false -) - -class ProfileViewModel(private val repo: Repository) : ViewModel() { - - private val _state = MutableStateFlow(ProfileUiState()) - val state: StateFlow = _state.asStateFlow() - - init { refresh() } - - fun refresh() { - viewModelScope.launch { - try { - _state.value = _state.value.copy(user = repo.me()) - } catch (e: Exception) { - _state.value = _state.value.copy(error = e.message) - } - } - } - - fun updateName(name: String) { - if (name.isBlank()) { - _state.value = _state.value.copy(error = "Please enter your name.") - return - } - viewModelScope.launch { - _state.value = _state.value.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.") - } catch (e: Exception) { - _state.value = _state.value.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.") - return - } - viewModelScope.launch { - _state.value = _state.value.copy(loading = true, message = null, error = null) - try { - val msg = repo.changePassword(current, new) - _state.value = _state.value.copy(loading = false, message = msg) - } catch (e: Exception) { - _state.value = _state.value.copy(loading = false, error = e.message) - } - } - } - - fun logout() { - viewModelScope.launch { - repo.logout() - _state.value = _state.value.copy(loggedOut = true) - } - } - - fun clearMessages() { - _state.value = _state.value.copy(message = null, error = null) - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/ToolsViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/ToolsViewModel.kt deleted file mode 100644 index be64e13..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/ToolsViewModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ainotebook.app.data.Flashcard -import com.ainotebook.app.data.QuizQuestion -import com.ainotebook.app.data.Repository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** Generic result holder for the study tools. */ -data class ToolsUiState( - val loading: Boolean = false, - val error: String? = null, - val textResult: String? = null, // notes / plan / summary / homework - val quiz: List? = null, - val flashcards: List? = null -) - -enum class Tool { NOTES, QUIZ, FLASHCARDS, PLAN, SUMMARIZE, HOMEWORK } - -class ToolsViewModel(private val repo: Repository) : ViewModel() { - - private val _state = MutableStateFlow(ToolsUiState()) - val state: StateFlow = _state.asStateFlow() - - fun reset() { - _state.value = ToolsUiState() - } - - private fun begin() { - _state.value = ToolsUiState(loading = true) - } - - private fun fail(e: Exception) { - _state.value = 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) } - - private fun launch(block: suspend () -> Unit) { - viewModelScope.launch { - begin() - try { - block() - } catch (e: Exception) { - fail(e) - } - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/ViewModelFactory.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/ViewModelFactory.kt deleted file mode 100644 index 5878653..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/ViewModelFactory.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.ainotebook.app.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.ainotebook.app.data.Repository - -/** - * Single factory able to construct every view model in the app from the shared - * [Repository]. Keeps things dependency-injection-framework-free. - */ -class VMFactory(private val repo: Repository) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return when { - modelClass.isAssignableFrom(AuthViewModel::class.java) -> AuthViewModel(repo) as T - modelClass.isAssignableFrom(DashboardViewModel::class.java) -> DashboardViewModel(repo) as T - modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(repo) as T - modelClass.isAssignableFrom(ToolsViewModel::class.java) -> ToolsViewModel(repo) as T - modelClass.isAssignableFrom(ProfileViewModel::class.java) -> ProfileViewModel(repo) as T - else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}") - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Common.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Common.kt deleted file mode 100644 index 110f2d1..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Common.kt +++ /dev/null @@ -1,228 +0,0 @@ -package com.ainotebook.app.ui.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CloudOff -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.animation.AnimatedVisibility as AnimatedVisibilityCommon -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.ainotebook.app.R -import com.ainotebook.app.ui.theme.Cyan -import com.ainotebook.app.ui.theme.HairlineOutline -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.SpaceBg -import com.ainotebook.app.ui.theme.Violet - -/** A subtle space gradient background used on every screen. */ -@Composable -fun SpaceBackground(content: @Composable () -> Unit) { - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - listOf(SpaceBg, Color(0xFF0C1226), SpaceBg) - ) - ) - ) { content() } -} - -/** Glassmorphism-ish card with a hairline border for the assistant-app look. */ -@Composable -fun GlassCard( - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { - Card( - modifier = modifier.border( - width = 1.dp, - color = HairlineOutline.copy(alpha = 0.6f), - shape = RoundedCornerShape(20.dp) - ), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f) - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) - ) { content() } -} - -/** - * The official AI Notebook logo, shown cleanly with preserved proportions. - * Uses the uploaded brand asset (res/drawable-nodpi/ss_logo.png) — no generated - * or alternative branding. - */ -@Composable -fun BrandLogo( - size: Dp, - modifier: Modifier = Modifier -) { - Image( - painter = painterResource(id = R.drawable.ss_logo), - contentDescription = "AI Notebook", - contentScale = ContentScale.Fit, - modifier = modifier.size(size) - ) -} - -/** - * Circular assistant avatar that displays the official logo — used next to AI - * replies in chat (ChatGPT/Gemini/Perplexity-style message rows). - */ -@Composable -fun AssistantAvatar(size: Dp = 30.dp, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(size) - .clip(CircleShape) - .background(Brush.linearGradient(listOf(Indigo, Violet))) - .border(1.dp, HairlineOutline, CircleShape), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(id = R.drawable.ss_logo), - contentDescription = "AI Notebook", - contentScale = ContentScale.Fit, - modifier = Modifier.size(size * 0.66f) - ) - } -} - -/** Animated three-dot "thinking" indicator used while the AI streams a reply. */ -@Composable -fun TypingDots(color: Color = Indigo) { - val transition = rememberInfiniteTransition(label = "typing") - Row(verticalAlignment = Alignment.CenterVertically) { - repeat(3) { i -> - val alpha by transition.animateFloat( - initialValue = 0.25f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(600, delayMillis = i * 180, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "dot$i" - ) - Box( - Modifier - .padding(horizontal = 2.dp) - .size(7.dp) - .alpha(alpha) - .clip(CircleShape) - .background(color) - ) - if (i < 2) Spacer(Modifier.size(2.dp)) - } - } -} - -@Composable -fun GradientBrush(): Brush = - Brush.horizontalGradient(listOf(Indigo, Violet, Cyan)) - -@Composable -fun LoadingBox(modifier: Modifier = Modifier) { - Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = Indigo) - } -} - -@Composable -fun ErrorBanner(message: String?) { - AnimatedVisibility(visible = message != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .background(Color(0x33FB7185), RoundedCornerShape(12.dp)) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Default.ErrorOutline, - contentDescription = null, - tint = Color(0xFFFB7185), - modifier = Modifier.size(20.dp) - ) - Text( - message ?: "", - color = Color(0xFFFCA5A5), - style = MaterialTheme.typography.bodyMedium - ) - } - } -} - -/** - * A slim, animated "You're offline" banner shown at the top of the shell when - * connectivity is lost — premium reliability behaviour like ChatGPT/Gemini. - */ -@Composable -fun OfflineBanner(isOnline: Boolean) { - AnimatedVisibilityCommon( - visible = !isOnline, - enter = expandVertically(), - exit = shrinkVertically() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF3A1F2B)) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Default.CloudOff, - contentDescription = null, - tint = Color(0xFFFCA5A5), - modifier = Modifier.size(18.dp) - ) - Text( - "You're offline — some features may be unavailable.", - color = Color(0xFFFCA5A5), - style = MaterialTheme.typography.bodySmall - ) - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Effects.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Effects.kt deleted file mode 100644 index 79114ba..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Effects.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.ainotebook.app.ui.components - -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp -import androidx.compose.material3.MaterialTheme -import android.view.HapticFeedbackConstants - -/** - * Shimmer brush used for skeleton loaders (ChatGPT/Gemini-style perceived-speed - * loading). Applies an animated diagonal gradient sweep. - */ -fun Modifier.shimmer(): Modifier = composed { - val base = MaterialTheme.colorScheme.surfaceContainerHigh - val highlight = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) - val transition = rememberInfiniteTransition(label = "shimmer") - val x by transition.animateFloat( - initialValue = -600f, - targetValue = 600f, - animationSpec = infiniteRepeatable( - animation = tween(1300), - repeatMode = RepeatMode.Restart - ), - label = "shimmer-x" - ) - background( - Brush.linearGradient( - colors = listOf(base, highlight, base), - start = Offset(x, 0f), - end = Offset(x + 300f, 300f) - ) - ) -} - -@Composable -fun SkeletonBox( - modifier: Modifier = Modifier, - cornerRadius: Int = 12 -) { - Spacer( - modifier - .clip(RoundedCornerShape(cornerRadius.dp)) - .shimmer() - ) -} - -/** Skeleton placeholder mirroring the dashboard layout while stats load. */ -@Composable -fun DashboardSkeleton(modifier: Modifier = Modifier) { - Column(modifier.padding(16.dp)) { - Row { - SkeletonBox(Modifier.size(44.dp), cornerRadius = 22) - Spacer(Modifier.size(12.dp)) - Column { - SkeletonBox(Modifier.size(width = 120.dp, height = 14.dp)) - Spacer(Modifier.height(8.dp)) - SkeletonBox(Modifier.size(width = 180.dp, height = 22.dp)) - } - } - Spacer(Modifier.height(20.dp)) - repeat(3) { - Row(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { - SkeletonBox(Modifier.weight(1f).height(96.dp)) - Spacer(Modifier.size(12.dp)) - SkeletonBox(Modifier.weight(1f).height(96.dp)) - } - } - Spacer(Modifier.height(8.dp)) - SkeletonBox(Modifier.size(width = 140.dp, height = 18.dp)) - Spacer(Modifier.height(12.dp)) - repeat(3) { - SkeletonBox(Modifier.fillMaxWidth().height(64.dp)) - Spacer(Modifier.height(10.dp)) - } - } -} - -/** Lightweight result-loading skeleton used by study tools. */ -@Composable -fun TextResultSkeleton(modifier: Modifier = Modifier) { - Column(modifier.fillMaxWidth()) { - SkeletonBox(Modifier.fillMaxWidth().height(16.dp)) - Spacer(Modifier.height(10.dp)) - SkeletonBox(Modifier.fillMaxWidth().height(16.dp)) - Spacer(Modifier.height(10.dp)) - SkeletonBox(Modifier.fillMaxWidth(0.7f).height(16.dp)) - } -} - -/** Performs a light haptic tick — used on key taps for a tactile, native feel. */ -@Composable -fun rememberHaptics(): () -> Unit { - val view = LocalView.current - return { - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Markdown.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Markdown.kt deleted file mode 100644 index a2da866..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/components/Markdown.kt +++ /dev/null @@ -1,429 +0,0 @@ -package com.ainotebook.app.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.ainotebook.app.ui.theme.Cyan -import com.ainotebook.app.ui.theme.Indigo - -/* ========================================================================= - * Lightweight, dependency-free Markdown renderer tuned for AI assistant - * replies (ChatGPT / Gemini / Claude / Perplexity style). Supports: - * - Headings (#, ##, ###) - * - Bold **x**, italic *x* / _x_, inline `code` - * - Bullet (-, *, •) and numbered (1.) lists - * - Fenced code blocks ``` with a copy button - * - Blockquotes (>) - * - Simple pipe tables - * - Horizontal rules (---) - * ========================================================================= */ - -private sealed interface MdBlock { - data class Heading(val level: Int, val text: String) : MdBlock - data class Paragraph(val text: String) : MdBlock - data class BulletItem(val text: String, val ordered: Boolean, val index: Int) : MdBlock - data class Code(val language: String, val code: String) : MdBlock - data class Quote(val text: String) : MdBlock - data class Table(val rows: List>, val hasHeader: Boolean) : MdBlock - data object Divider : MdBlock -} - -private fun parseMarkdown(src: String): List { - val blocks = mutableListOf() - val lines = src.replace("\r\n", "\n").split("\n") - var i = 0 - var orderedCounter = 0 - - while (i < lines.size) { - val raw = lines[i] - val line = raw.trimEnd() - val trimmed = line.trim() - - when { - // Fenced code block - trimmed.startsWith("```") -> { - val lang = trimmed.removePrefix("```").trim() - val sb = StringBuilder() - i++ - while (i < lines.size && !lines[i].trim().startsWith("```")) { - sb.appendLine(lines[i]) - i++ - } - i++ // closing fence - blocks += MdBlock.Code(lang, sb.toString().trimEnd('\n')) - orderedCounter = 0 - } - - trimmed.isEmpty() -> { - orderedCounter = 0 - i++ - } - - trimmed.startsWith("### ") -> { blocks += MdBlock.Heading(3, trimmed.removePrefix("### ")); i++ } - trimmed.startsWith("## ") -> { blocks += MdBlock.Heading(2, trimmed.removePrefix("## ")); i++ } - trimmed.startsWith("# ") -> { blocks += MdBlock.Heading(1, trimmed.removePrefix("# ")); i++ } - - trimmed == "---" || trimmed == "***" || trimmed == "___" -> { - blocks += MdBlock.Divider; i++ - } - - trimmed.startsWith("> ") -> { - blocks += MdBlock.Quote(trimmed.removePrefix("> ")); i++ - } - - // Table: a line with pipes followed by a separator row - trimmed.contains("|") && i + 1 < lines.size && - lines[i + 1].trim().matches(Regex("\\|?[\\s:|-]+\\|?")) && - lines[i + 1].contains("-") -> { - val rows = mutableListOf>() - rows += splitRow(trimmed) - i += 2 // skip header + separator - while (i < lines.size && lines[i].trim().contains("|") && lines[i].trim().isNotEmpty()) { - rows += splitRow(lines[i].trim()) - i++ - } - blocks += MdBlock.Table(rows, hasHeader = true) - orderedCounter = 0 - } - - trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.startsWith("• ") -> { - blocks += MdBlock.BulletItem(trimmed.drop(2), ordered = false, index = 0) - orderedCounter = 0 - i++ - } - - trimmed.matches(Regex("^\\d+\\.\\s.*")) -> { - orderedCounter++ - val content = trimmed.replaceFirst(Regex("^\\d+\\.\\s"), "") - blocks += MdBlock.BulletItem(content, ordered = true, index = orderedCounter) - i++ - } - - else -> { - blocks += MdBlock.Paragraph(trimmed) - orderedCounter = 0 - i++ - } - } - } - return blocks -} - -private fun splitRow(line: String): List = - line.trim().trim('|').split("|").map { it.trim() } - -/** Renders inline markdown (**bold**, *italic*, `code`) into an AnnotatedString. */ -private fun inline(text: String, codeColor: Color): AnnotatedString = buildAnnotatedString { - var i = 0 - while (i < text.length) { - val c = text[i] - when { - text.startsWith("**", i) -> { - val end = text.indexOf("**", i + 2) - if (end > 0) { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(text.substring(i + 2, end)) - } - i = end + 2 - } else { append(c); i++ } - } - c == '`' -> { - val end = text.indexOf('`', i + 1) - if (end > 0) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = codeColor.copy(alpha = 0.16f), - color = codeColor, - fontSize = 13.5.sp - ) - ) { append(text.substring(i + 1, end)) } - i = end + 1 - } else { append(c); i++ } - } - (c == '*' || c == '_') -> { - val end = text.indexOf(c, i + 1) - if (end > i + 1) { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(text.substring(i + 1, end)) - } - i = end + 1 - } else { append(c); i++ } - } - else -> { append(c); i++ } - } - } -} - -@Composable -fun MarkdownText( - markdown: String, - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colorScheme.onSurface -) { - val blocks = remember(markdown) { parseMarkdown(markdown) } - val codeColor = Cyan - - Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { - blocks.forEach { block -> - when (block) { - is MdBlock.Heading -> Text( - text = inline(block.text, codeColor), - color = color, - style = when (block.level) { - 1 -> MaterialTheme.typography.titleLarge - 2 -> MaterialTheme.typography.titleMedium - else -> MaterialTheme.typography.titleSmall - }, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 4.dp) - ) - - is MdBlock.Paragraph -> Text( - text = inline(block.text, codeColor), - color = color, - style = MaterialTheme.typography.bodyMedium - ) - - is MdBlock.BulletItem -> Row(Modifier.fillMaxWidth()) { - Text( - if (block.ordered) "${block.index}." else "•", - color = Indigo, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.width(if (block.ordered) 24.dp else 18.dp) - ) - Text( - text = inline(block.text, codeColor), - color = color, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - } - - is MdBlock.Code -> CodeBlock(block.language, block.code) - - is MdBlock.Quote -> Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)) - ) { - Box( - Modifier - .width(3.dp) - .background(Indigo) - .padding(vertical = 12.dp) - .size(width = 3.dp, height = 18.dp) - ) - Text( - text = inline(block.text, codeColor), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - fontStyle = FontStyle.Italic, - modifier = Modifier.padding(10.dp) - ) - } - - is MdBlock.Table -> MarkdownTable(block.rows, block.hasHeader, codeColor) - - MdBlock.Divider -> Box( - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .size(height = 1.dp, width = 1.dp) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) - ) - } - } - } -} - -@Composable -private fun CodeBlock(language: String, code: String) { - val clipboard = LocalClipboardManager.current - var copied by remember { mutableStateOf(false) } - - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFF0B0F1F)) - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) - ) { - Row( - Modifier - .fillMaxWidth() - .background(Color(0xFF11162A)) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - language.ifBlank { "code" }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .clickable { - clipboard.setText(AnnotatedString(code)) - copied = true - } - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Icon( - if (copied) Icons.Default.Check else Icons.Default.ContentCopy, - contentDescription = "Copy code", - tint = if (copied) Cyan else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp) - ) - Spacer(Modifier.width(4.dp)) - Text( - if (copied) "Copied" else "Copy", - style = MaterialTheme.typography.labelSmall, - color = if (copied) Cyan else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Box( - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - ) { - Text( - buildCodeHighlight(code), - modifier = Modifier.padding(12.dp), - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = Color(0xFFE6E9F5) - ) - } - } -} - -/** Very light syntax tinting: keywords, strings, numbers, comments. */ -private val CODE_KEYWORDS = setOf( - "fun", "val", "var", "if", "else", "for", "while", "return", "class", - "object", "import", "package", "public", "private", "void", "int", - "String", "def", "function", "const", "let", "new", "true", "false", - "null", "None", "True", "False", "print", "println", "in", "is", "when" -) - -private fun buildCodeHighlight(code: String): AnnotatedString = buildAnnotatedString { - val keywordColor = Color(0xFFA855F7) - val stringColor = Color(0xFF7DD3A0) - val numberColor = Color(0xFF22D3EE) - val commentColor = Color(0xFF6B7394) - - code.split("\n").forEachIndexed { idx, line -> - if (idx > 0) append("\n") - val commentIdx = lineCommentIndex(line) - val codePart = if (commentIdx >= 0) line.substring(0, commentIdx) else line - val comment = if (commentIdx >= 0) line.substring(commentIdx) else "" - - val tokens = Regex("(\"[^\"]*\"|'[^']*'|\\b\\w+\\b|\\W)").findAll(codePart) - for (m in tokens) { - val t = m.value - when { - t.startsWith("\"") || t.startsWith("'") -> - withStyle(SpanStyle(color = stringColor)) { append(t) } - t.matches(Regex("\\d+(\\.\\d+)?")) -> - withStyle(SpanStyle(color = numberColor)) { append(t) } - t in CODE_KEYWORDS -> - withStyle(SpanStyle(color = keywordColor, fontWeight = FontWeight.Bold)) { append(t) } - else -> append(t) - } - } - if (comment.isNotEmpty()) { - withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) { append(comment) } - } - } -} - -private fun lineCommentIndex(line: String): Int { - val slashes = line.indexOf("//") - val hash = line.indexOf("#") - return when { - slashes >= 0 && (hash < 0 || slashes < hash) -> slashes - hash >= 0 -> hash - else -> -1 - } -} - -@Composable -private fun MarkdownTable(rows: List>, hasHeader: Boolean, codeColor: Color) { - Column( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(10.dp)) - ) { - rows.forEachIndexed { rIdx, row -> - val isHeader = hasHeader && rIdx == 0 - Row( - Modifier - .fillMaxWidth() - .background( - if (isHeader) MaterialTheme.colorScheme.surfaceContainerHigh - else if (rIdx % 2 == 0) Color.Transparent - else MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.25f) - ) - ) { - row.forEach { cell -> - Text( - text = inline(cell, codeColor), - modifier = Modifier - .weight(1f) - .padding(horizontal = 10.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodySmall, - color = if (isHeader) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = if (isHeader) FontWeight.Bold else FontWeight.Normal - ) - } - } - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ChatScreen.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ChatScreen.kt deleted file mode 100644 index b87798b..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ChatScreen.kt +++ /dev/null @@ -1,647 +0,0 @@ -package com.ainotebook.app.ui.screens - -import android.content.Intent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -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.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.ainotebook.app.data.ChatMessage -import com.ainotebook.app.ui.ChatViewModel -import com.ainotebook.app.ui.components.AssistantAvatar -import com.ainotebook.app.ui.components.BrandLogo -import com.ainotebook.app.ui.components.MarkdownText -import com.ainotebook.app.ui.components.TypingDots -import com.ainotebook.app.ui.components.rememberHaptics -import com.ainotebook.app.ui.theme.HairlineOutline -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.MutedText -import com.ainotebook.app.ui.theme.Violet -import kotlinx.coroutines.launch - -private val SUGGESTIONS = listOf( - "Explain photosynthesis simply" to "🌱", - "Quiz me on world history" to "🏛️", - "Summarize the French Revolution" to "📜", - "Help with my calculus homework" to "∫" -) - -@Composable -fun ChatScreen( - vm: ChatViewModel, - initialChatId: Int? -) { - val state by vm.state.collectAsState() - var input by remember { mutableStateOf("") } - var editing by remember { mutableStateOf(null) } - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - val haptic = rememberHaptics() - - var showHistory by remember { mutableStateOf(false) } - var showModels by remember { mutableStateOf(false) } - - LaunchedEffect(initialChatId) { - vm.loadChats() - if (initialChatId != null && initialChatId > 0) vm.openChat(initialChatId) - else vm.startNewChat() - } - - // FIX (Bug 3 partial): Only depend on messages.size, not content, to avoid - // re-triggering animateScrollToItem on every streaming token — which was - // cancelling and restarting the scroll coroutine hundreds of times and could - // cause the list state to become inconsistent. - LaunchedEffect(state.messages.size) { - if (state.messages.isNotEmpty()) { - listState.animateScrollToItem(state.messages.size - 1) - } - } - - val showScrollDown by remember { - derivedStateOf { - val last = listState.layoutInfo.totalItemsCount - 1 - val visibleLast = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - last > 0 && visibleLast < last - 1 - } - } - - Box(Modifier.fillMaxSize()) { - Column( - Modifier - .fillMaxSize() - .imePadding() - ) { - ChatHeader( - title = if (state.streaming) "Thinking…" else state.currentTitle, - model = state.model, - onNewChat = { haptic(); vm.startNewChat(); editing = null; input = "" }, - onHistory = { haptic(); showHistory = true }, - onModel = { haptic(); showModels = true } - ) - - // FIX (Bug 2 + Bug 3): EmptyChat and LazyColumn both occupy the - // same weight(1f) slot so the Column can always resolve heights. - // ChatInputBar is now OUTSIDE the if/else so it is always present — - // this eliminates the layout thrash that occurred when the first - // message was sent and the entire bottom half of the screen was - // suddenly added in a single recomposition. - if (state.messages.isEmpty()) { - EmptyChat( - modifier = Modifier.weight(1f), - onSuggestion = { vm.send(it) } - ) - } else { - LazyColumn( - state = listState, - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - contentPadding = PaddingValues(vertical = 12.dp) - ) { - // FIX (Bug 1 — MAIN CRASH): Removed key = { it.id }. - // - // Room auto-generates IDs only after a row is inserted. - // While messages are in-flight (user message sent, assistant - // placeholder added), BOTH have id = 0. Compose's LazyColumn - // requires keys to be unique; two items sharing key=0 throws - // an IllegalArgumentException and crashes the app immediately. - // - // Chat messages are append-only, so position-based identity - // (the default when no key is supplied) is perfectly stable - // and correct here. - items(state.messages) { msg -> - MessageRow( - msg = msg, - streaming = state.streaming, - isLastAssistant = msg == state.messages.lastOrNull { it.role == "assistant" }, - onRegenerate = { haptic(); vm.regenerateLast() }, - onEdit = { editing = msg; input = msg.content } - ) - } - } - } - - // Always-visible input bar (moved out of else branch). - ChatInputBar( - value = input, - onValueChange = { input = it }, - streaming = state.streaming, - isEditing = editing != null, - onCancelEdit = { editing = null; input = "" }, - onSend = { - val text = input.trim() - if (text.isNotEmpty()) { - haptic() - if (editing != null) { - vm.editAndResend(text) - editing = null - } else { - vm.send(text) - } - input = "" - } - } - ) - } // End Column - - // Scroll-to-bottom FAB. - AnimatedVisibility( - visible = showScrollDown && !showHistory, - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut(), - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 92.dp) - ) { - Box( - Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .border(1.dp, HairlineOutline, CircleShape) - .clickable { - scope.launch { - if (state.messages.isNotEmpty()) - listState.animateScrollToItem(state.messages.size - 1) - } - }, - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.ArrowDownward, - contentDescription = "Scroll to bottom", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - } - } - - // Sheets overlaid on top of everything. - if (showHistory) { - ChatHistorySheet( - vm = vm, - onDismiss = { showHistory = false } - ) - } - - if (showModels) { - ModelPickerSheet( - options = state.modelOptions, - selected = state.model, - onSelect = { vm.selectModel(it); showModels = false }, - onDismiss = { showModels = false } - ) - } - } // End outer Box -} - -@Composable -private fun ChatHeader( - title: String, - model: String, - onNewChat: () -> Unit, - onHistory: () -> Unit, - onModel: () -> Unit -) { - Row( - Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onHistory) { - Icon( - Icons.Default.History, - contentDescription = "Chat history", - tint = MaterialTheme.colorScheme.onBackground - ) - } - AssistantAvatar(size = 32.dp) - Spacer(Modifier.size(10.dp)) - Column(Modifier.weight(1f)) { - Text( - "AI Notebook", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1 - ) - Text( - title, - style = MaterialTheme.typography.bodySmall, - color = MutedText, - maxLines = 1 - ) - } - Row( - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Indigo.copy(alpha = 0.16f)) - .clickable(onClick = onModel) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.AutoAwesome, - contentDescription = null, - tint = Indigo, - modifier = Modifier.size(15.dp) - ) - Spacer(Modifier.size(4.dp)) - Text( - when (model) { // ✅ correct place - "kimi" -> "AI Notebook Pro" - "groq" -> "AI Notebook Lite" - "auto" -> "Auto" - else -> model.replaceFirstChar { it.uppercase() } - }, - style = MaterialTheme.typography.labelMedium, - color = Indigo, - fontWeight = FontWeight.SemiBold - ) - } - IconButton(onClick = onNewChat) { - Icon(Icons.Default.Add, contentDescription = "New chat", tint = Indigo) - } - } -} -@Composable -private fun ChatInputBar( - value: String, - onValueChange: (String) -> Unit, - streaming: Boolean, - isEditing: Boolean, - onCancelEdit: () -> Unit, - onSend: () -> Unit -) { - Column { - AnimatedVisibility(visible = isEditing) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Edit, - contentDescription = null, - tint = Violet, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.size(6.dp)) - Text( - "Editing message", - style = MaterialTheme.typography.labelMedium, - color = Violet, - modifier = Modifier.weight(1f) - ) - Text( - "Cancel", - style = MaterialTheme.typography.labelMedium, - color = MutedText, - modifier = Modifier.clickable(onClick = onCancelEdit) - ) - } - } - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.Bottom - ) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - placeholder = { Text("Message AI Notebook…") }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(24.dp), - maxLines = 5, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Indigo, - unfocusedBorderColor = HairlineOutline, - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface - ) - ) - Spacer(Modifier.size(8.dp)) - val sendBrush = if (streaming || value.isBlank()) - Brush.linearGradient(listOf(Indigo.copy(alpha = 0.4f), Indigo.copy(alpha = 0.4f))) - else - Brush.linearGradient(listOf(Indigo, Violet)) - - Box( - Modifier - .size(50.dp) - .clip(CircleShape) - .background(sendBrush) - .clickable(enabled = !streaming && value.isNotBlank(), onClick = onSend), - contentAlignment = Alignment.Center - ) { - if (streaming) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", - tint = Color.White - ) - } - } - } - } -} - -// FIX (Bug 2): Added modifier parameter so the call site can pass weight(1f), -// letting the Column resolve heights without ambiguity. Changed internal Box -// from fillMaxSize() to fillMaxWidth() — height is now controlled by the -// caller via the modifier, not asserted from inside. -@Composable -private fun EmptyChat( - modifier: Modifier = Modifier, - onSuggestion: (String) -> Unit -) { - Box( - modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - BrandLogo(size = 88.dp) - Spacer(Modifier.size(16.dp)) - Text( - "How can I help you learn today?", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.size(6.dp)) - Text( - "Ask anything, or try one of these:", - style = MaterialTheme.typography.bodyMedium, - color = MutedText - ) - Spacer(Modifier.size(20.dp)) - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - SUGGESTIONS.forEach { (suggestion, emoji) -> - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)) - .border( - 1.dp, - HairlineOutline.copy(alpha = 0.6f), - RoundedCornerShape(16.dp) - ) - .clickable { onSuggestion(suggestion) } - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(emoji, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.size(12.dp)) - Text( - suggestion, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - } - } -} - -@Composable -private fun MessageRow( - msg: ChatMessage, - streaming: Boolean, - isLastAssistant: Boolean, - onRegenerate: () -> Unit, - onEdit: () -> Unit -) { - if (msg.role == "user") { - UserMessage(msg, onEdit) - } else { - AssistantMessage(msg, streaming, isLastAssistant, onRegenerate) - } -} - -@Composable -private fun UserMessage(msg: ChatMessage, onEdit: () -> Unit) { - var showActions by remember { mutableStateOf(false) } - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.Top - ) { - Box( - Modifier - .widthIn(max = 300.dp) - .clip( - RoundedCornerShape( - topStart = 18.dp, - topEnd = 18.dp, - bottomStart = 18.dp, - bottomEnd = 4.dp - ) - ) - .background(Brush.linearGradient(listOf(Indigo, Violet))) - .clickable { showActions = !showActions } - .padding(14.dp) - ) { - Text(msg.content, color = Color.White, style = MaterialTheme.typography.bodyMedium) - } - Spacer(Modifier.size(8.dp)) - Box( - Modifier - .size(30.dp) - .clip(CircleShape) - .background(Violet.copy(alpha = 0.25f)), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Person, - contentDescription = null, - tint = Violet, - modifier = Modifier.size(18.dp) - ) - } - } - AnimatedVisibility(visible = showActions && msg.id >= 0) { - Row(Modifier.padding(end = 38.dp, top = 4.dp)) { - MessageAction(Icons.Default.Edit, "Edit") { showActions = false; onEdit() } - } - } - } -} - -@Composable -private fun AssistantMessage( - msg: ChatMessage, - streaming: Boolean, - isLastAssistant: Boolean, - onRegenerate: () -> Unit -) { - val clipboard = LocalClipboardManager.current - val context = LocalContext.current - val isEmptyStreaming = msg.content.isBlank() && streaming - - Column(Modifier.fillMaxWidth()) { - Row(verticalAlignment = Alignment.CenterVertically) { - AssistantAvatar(size = 30.dp) - Spacer(Modifier.size(8.dp)) - Text( - "AI Notebook", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground - ) - } - Spacer(Modifier.size(6.dp)) - Box( - Modifier - .fillMaxWidth() - .clip( - RoundedCornerShape( - topStart = 4.dp, - topEnd = 18.dp, - bottomStart = 18.dp, - bottomEnd = 18.dp - ) - ) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)) - .border( - 1.dp, - HairlineOutline.copy(alpha = 0.5f), - RoundedCornerShape( - topStart = 4.dp, - topEnd = 18.dp, - bottomStart = 18.dp, - bottomEnd = 18.dp - ) - ) - .padding(14.dp) - ) { - if (isEmptyStreaming) { - TypingDots() - } else { - MarkdownText(markdown = msg.content) - } - } - // Action row for completed assistant replies. - if (!isEmptyStreaming && msg.content.isNotBlank()) { - Row( - Modifier.padding(top = 6.dp, start = 2.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - var copied by remember(msg.content) { mutableStateOf(false) } - MessageAction( - icon = if (copied) Icons.Default.Check else Icons.Default.ContentCopy, - label = if (copied) "Copied" else "Copy" - ) { - clipboard.setText(AnnotatedString(msg.content)) - copied = true - } - MessageAction(Icons.Default.Share, "Share") { - val send = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, msg.content) - putExtra(Intent.EXTRA_SUBJECT, "AI Notebook") - } - context.startActivity(Intent.createChooser(send, "Share response")) - } - if (isLastAssistant && !streaming) { - MessageAction(Icons.Default.Refresh, "Regenerate", onClick = onRegenerate) - } - } - } - } -} - -@Composable -private fun MessageAction( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, - onClick: () -> Unit -) { - Row( - Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(icon, contentDescription = label, tint = MutedText, modifier = Modifier.size(15.dp)) - Spacer(Modifier.size(4.dp)) - Text(label, style = MaterialTheme.typography.labelSmall, color = MutedText) - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ChatSheets.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ChatSheets.kt deleted file mode 100644 index fd00f97..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ChatSheets.kt +++ /dev/null @@ -1,274 +0,0 @@ -package com.ainotebook.app.ui.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.DriveFileRenameOutline -import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.ainotebook.app.data.Chat -import com.ainotebook.app.ui.ChatViewModel -import com.ainotebook.app.ui.theme.Cyan -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.MutedText -import com.ainotebook.app.ui.theme.Violet - -/** Native modal bottom sheet listing chat history with search, pin, rename, delete. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ChatHistorySheet(vm: ChatViewModel, onDismiss: () -> Unit) { - val state by vm.state.collectAsState() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var renameTarget by remember { mutableStateOf(null) } - var deleteTarget by remember { mutableStateOf(null) } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - Text( - "Your conversations", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.size(12.dp)) - OutlinedTextField( - value = state.searchQuery, - onValueChange = vm::setSearchQuery, - placeholder = { Text("Search chats") }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.size(12.dp)) - - val chats = state.visibleChats - if (chats.isEmpty()) { - Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { - Text( - if (state.searchQuery.isBlank()) "No conversations yet." - else "No chats match \"${state.searchQuery}\".", - color = MutedText - ) - } - } else { - LazyColumn( - Modifier.heightIn(max = 460.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(chats, key = { it.id }) { chat -> - ChatHistoryRow( - chat = chat, - pinned = chat.id in state.pinnedChatIds, - onOpen = { vm.openChat(chat.id); onDismiss() }, - onPin = { vm.togglePin(chat.id) }, - onRename = { renameTarget = chat }, - onDelete = { deleteTarget = chat } - ) - } - } - } - Spacer(Modifier.size(24.dp)) - } - } - - renameTarget?.let { chat -> - var newTitle by remember { mutableStateOf(chat.title) } - AlertDialog( - onDismissRequest = { renameTarget = null }, - title = { Text("Rename conversation") }, - text = { - OutlinedTextField( - value = newTitle, - onValueChange = { newTitle = it }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton(onClick = { vm.renameChat(chat.id, newTitle); renameTarget = null }) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = { renameTarget = null }) { Text("Cancel") } - } - ) - } - - deleteTarget?.let { chat -> - AlertDialog( - onDismissRequest = { deleteTarget = null }, - title = { Text("Delete conversation?") }, - text = { Text("\"${chat.title}\" will be permanently removed.") }, - confirmButton = { - TextButton(onClick = { vm.deleteChat(chat.id); deleteTarget = null }) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } - } - ) - } -} - -@Composable -private fun ChatHistoryRow( - chat: Chat, - pinned: Boolean, - onOpen: () -> Unit, - onPin: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit -) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)) - .clickable(onClick = onOpen) - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, tint = if (pinned) Cyan else Indigo, modifier = Modifier.size(20.dp)) - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text( - chat.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1 - ) - chat.updated_at?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MutedText, maxLines = 1) - } - } - IconButton(onClick = onPin, modifier = Modifier.size(34.dp)) { - Icon( - Icons.Default.PushPin, - contentDescription = "Pin", - tint = if (pinned) Cyan else MutedText, - modifier = Modifier.size(17.dp) - ) - } - IconButton(onClick = onRename, modifier = Modifier.size(34.dp)) { - Icon(Icons.Default.DriveFileRenameOutline, contentDescription = "Rename", tint = MutedText, modifier = Modifier.size(17.dp)) - } - IconButton(onClick = onDelete, modifier = Modifier.size(34.dp)) { - Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MutedText, modifier = Modifier.size(17.dp)) - } - } -} - -/** Native bottom sheet for picking the AI provider (Auto / Kimi / Gemini / Groq). */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ModelPickerSheet( - options: List, - selected: String, - onSelect: (String) -> Unit, - onDismiss: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - Text( - "Choose AI model", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - "\"Auto\" picks the best available provider automatically.", - style = MaterialTheme.typography.bodySmall, - color = MutedText - ) - Spacer(Modifier.size(12.dp)) - options.forEach { opt -> - val isSelected = opt.equals(selected, ignoreCase = true) - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(if (isSelected) Indigo.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.4f)) - .clickable { onSelect(opt) } - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - opt.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - modelDescription(opt), - style = MaterialTheme.typography.bodySmall, - color = MutedText - ) - } - if (isSelected) { - Icon(Icons.Default.Check, contentDescription = "Selected", tint = Indigo) - } - } - Spacer(Modifier.size(8.dp)) - } - Spacer(Modifier.size(20.dp)) - } - } -} - -private fun modelDescription(model: String): String = when (model.lowercase()) { - "auto" -> "Smart fallback across all providers" - "kimi" -> "Moonshot Kimi — fast, capable" - "gemini" -> "Google Gemini" - "groq" -> "Groq — ultra-low latency" - else -> "AI provider" -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/DashboardScreen.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/DashboardScreen.kt deleted file mode 100644 index 49bd6ed..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/DashboardScreen.kt +++ /dev/null @@ -1,362 +0,0 @@ -package com.ainotebook.app.ui.screens - -import androidx.compose.animation.core.animateIntAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.AutoStories -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.Quiz -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.material.icons.filled.Widgets -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshContainer -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.ainotebook.app.data.Chat -import com.ainotebook.app.data.Stats -import com.ainotebook.app.ui.DashboardViewModel -import com.ainotebook.app.ui.components.BrandLogo -import com.ainotebook.app.ui.components.DashboardSkeleton -import com.ainotebook.app.ui.components.ErrorBanner -import com.ainotebook.app.ui.components.GlassCard -import com.ainotebook.app.ui.components.rememberHaptics -import com.ainotebook.app.ui.theme.Cyan -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.MutedText -import com.ainotebook.app.ui.theme.Violet - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DashboardScreen( - vm: DashboardViewModel, - userName: String, - onOpenChat: (Int) -> Unit, - onNewChat: () -> Unit, - onOpenTools: () -> Unit -) { - val state by vm.state.collectAsState() - val haptic = rememberHaptics() - - if (state.loading && state.stats == null) { - DashboardSkeleton(Modifier.fillMaxSize()) - return - } - - val pullState = rememberPullToRefreshState() - if (pullState.isRefreshing) { - LaunchedEffect(true) { - haptic() - vm.load() - } - } - // End the indicator animation once the load completes. - LaunchedEffect(state.loading) { - if (!state.loading) pullState.endRefresh() - } - - Box(Modifier.fillMaxSize().nestedScroll(pullState.nestedScrollConnection)) { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - item { - Row(verticalAlignment = Alignment.CenterVertically) { - BrandLogo(size = 44.dp) - Spacer(Modifier.width(12.dp)) - Column { - Text( - greeting(), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - userName.ifBlank { "Explorer" }, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - } - } - Spacer(Modifier.height(8.dp)) - ErrorBanner(state.error) - } - - val s = state.stats - if (s != null) { - item { InsightBanner(s, onNewChat) } - - item { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - StatCard("Chats", s.total_chats, Icons.AutoMirrored.Filled.Chat, Indigo, Modifier.weight(1f)) - StatCard("Messages", s.total_messages, Icons.Default.Forum, Violet, Modifier.weight(1f)) - } - } - item { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - StatCard("AI replies", s.ai_responses, Icons.Default.SmartToy, Cyan, Modifier.weight(1f)) - StatCard("Notes", s.notes, Icons.Default.AutoStories, Indigo, Modifier.weight(1f)) - } - } - item { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - StatCard("Quizzes", s.quizzes, Icons.Default.Quiz, Violet, Modifier.weight(1f)) - QuickActionCard(onOpenTools, Modifier.weight(1f)) - } - } - - if (s.daily_activity.isNotEmpty()) { - item { - Text( - "Activity this week", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground - ) - } - item { ActivityChart(s.daily_activity.map { it.day to it.count }) } - } - - item { - Spacer(Modifier.height(4.dp)) - Text( - "Recent chats", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground - ) - } - - if (s.recent_chats.isEmpty()) { - item { - GlassCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(20.dp)) { - Text( - "No chats yet. Start your first conversation!", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - items(s.recent_chats) { chat -> RecentChatRow(chat, onOpenChat) } - } - } - } - - PullToRefreshContainer( - state = pullState, - modifier = Modifier.align(Alignment.TopCenter) - ) - } -} - -private fun greeting(): String { - val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) - return when (hour) { - in 5..11 -> "Good morning," - in 12..17 -> "Good afternoon," - else -> "Good evening," - } -} - -@Composable -private fun InsightBanner(s: Stats, onNewChat: () -> Unit) { - Box( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)) - .background(Brush.linearGradient(listOf(Indigo, Violet))) - .clickable(onClick = onNewChat) - .padding(18.dp) - ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Bolt, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(6.dp)) - Text( - "Keep your streak going", - color = Color.White, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - Spacer(Modifier.height(6.dp)) - Text( - insightText(s), - color = Color.White.copy(alpha = 0.9f), - style = MaterialTheme.typography.bodyMedium - ) - Spacer(Modifier.height(10.dp)) - Box( - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Color.White.copy(alpha = 0.2f)) - .padding(horizontal = 14.dp, vertical = 8.dp) - ) { - Text("Start a new chat →", color = Color.White, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - } -} - -private fun insightText(s: Stats): String = when { - s.total_chats == 0 -> "Ask your first question and I'll help you learn anything." - s.ai_responses >= 50 -> "You've had ${s.ai_responses} AI replies — you're on a roll!" - s.quizzes > 0 -> "Great work — you've completed ${s.quizzes} quizzes so far." - else -> "You've started ${s.total_chats} chats. Ready to explore more?" -} - -@Composable -private fun StatCard( - label: String, - value: Int, - icon: ImageVector, - accent: Color, - modifier: Modifier = Modifier -) { - // Count-up animation for a lively, premium feel. - val animated by animateIntAsState(targetValue = value, animationSpec = tween(900), label = "stat-$label") - GlassCard(modifier) { - Column(Modifier.padding(16.dp)) { - Box( - Modifier - .size(40.dp) - .background(accent.copy(alpha = 0.18f), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Icon(icon, contentDescription = null, tint = accent, modifier = Modifier.size(22.dp)) - } - Spacer(Modifier.height(10.dp)) - Text( - animated.toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } -} - -@Composable -private fun QuickActionCard(onOpenTools: () -> Unit, modifier: Modifier = Modifier) { - GlassCard(modifier.clickable(onClick = onOpenTools)) { - Column(Modifier.padding(16.dp)) { - Box( - Modifier - .size(40.dp) - .background(Cyan.copy(alpha = 0.18f), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Default.Widgets, contentDescription = null, tint = Cyan, modifier = Modifier.size(22.dp)) - } - Spacer(Modifier.height(10.dp)) - Text("Study Tools", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) - Text("Notes, quizzes & more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } -} - -/** Minimal native bar chart for weekly activity (no external chart deps). */ -@Composable -private fun ActivityChart(data: List>) { - val max = (data.maxOfOrNull { it.second } ?: 0).coerceAtLeast(1) - GlassCard(Modifier.fillMaxWidth()) { - Row( - Modifier - .fillMaxWidth() - .height(140.dp) - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Bottom - ) { - data.takeLast(7).forEach { (day, count) -> - val fraction = count.toFloat() / max - Column( - Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Bottom - ) { - val animated by animateIntAsState(count, tween(800), label = "bar-$day") - Text(animated.toString(), style = MaterialTheme.typography.labelSmall, color = MutedText) - Spacer(Modifier.height(4.dp)) - Box( - Modifier - .fillMaxWidth() - .height((90 * fraction).dp.coerceAtLeast(4.dp)) - .clip(RoundedCornerShape(6.dp)) - .background(Brush.verticalGradient(listOf(Indigo, Violet))) - ) - Spacer(Modifier.height(4.dp)) - Text(day.take(3), style = MaterialTheme.typography.labelSmall, color = MutedText) - } - } - } - } -} - -@Composable -private fun RecentChatRow(chat: Chat, onOpenChat: (Int) -> Unit) { - GlassCard(Modifier.fillMaxWidth().clickable { onOpenChat(chat.id) }) { - Row( - Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - Modifier - .size(36.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Indigo.copy(alpha = 0.16f)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, tint = Indigo, modifier = Modifier.size(18.dp)) - } - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text( - chat.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1 - ) - chat.updated_at?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) - } - } - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/LoginScreen.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/LoginScreen.kt deleted file mode 100644 index 0654b57..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/LoginScreen.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.ainotebook.app.ui.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import com.ainotebook.app.ui.AuthViewModel -import com.ainotebook.app.ui.components.BrandLogo -import com.ainotebook.app.ui.components.ErrorBanner -import com.ainotebook.app.ui.components.GlassCard -import com.ainotebook.app.ui.components.SpaceBackground -import com.ainotebook.app.ui.components.rememberHaptics -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.Violet - -@Composable -fun LoginScreen( - vm: AuthViewModel, - onLoggedIn: () -> Unit, - onGoSignup: () -> Unit -) { - val state by vm.state.collectAsState() - var identifier by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var showPw by remember { mutableStateOf(false) } - val haptics = rememberHaptics() - - if (state.success) onLoggedIn() - - SpaceBackground { - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Spacer(Modifier.height(40.dp)) - BrandLogo(size = 96.dp) - Spacer(Modifier.height(12.dp)) - Text( - "AI Notebook", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - Text( - "Your intelligent learning companion", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(28.dp)) - - GlassCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(20.dp)) { - Text( - "Welcome back", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(16.dp)) - - ErrorBanner(state.error) - - OutlinedTextField( - value = identifier, - onValueChange = { identifier = it; vm.clearError() }, - label = { Text("Email or username") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = password, - onValueChange = { password = it; vm.clearError() }, - label = { Text("Password") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - visualTransformation = if (showPw) VisualTransformation.None - else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - trailingIcon = { - IconButton(onClick = { showPw = !showPw }) { - Icon( - if (showPw) Icons.Default.VisibilityOff - else Icons.Default.Visibility, - contentDescription = null - ) - } - } - ) - - Spacer(Modifier.height(20.dp)) - Box( - Modifier - .fillMaxWidth() - .height(50.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(14.dp)) - .background(Brush.linearGradient(listOf(Indigo, Violet))) - ) { - Button( - onClick = { haptics(); vm.login(identifier, password) }, - enabled = !state.loading, - modifier = Modifier.fillMaxWidth().height(50.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - disabledContainerColor = Color.Transparent - ), - elevation = null - ) { - if (state.loading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Text("Log in", fontWeight = FontWeight.SemiBold, color = Color.White) - } - } - } - - Spacer(Modifier.height(8.dp)) - OutlinedButton( - onClick = { haptics(); vm.guest() }, - enabled = !state.loading, - modifier = Modifier.fillMaxWidth().height(50.dp) - ) { - Text("Continue as guest") - } - - Spacer(Modifier.height(8.dp)) - TextButton( - onClick = onGoSignup, - modifier = Modifier.fillMaxWidth() - ) { - Text("Don't have an account? Sign up") - } - } - } - Spacer(Modifier.height(40.dp)) - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ProfileScreen.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ProfileScreen.kt deleted file mode 100644 index e9e5edd..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ProfileScreen.kt +++ /dev/null @@ -1,348 +0,0 @@ -package com.ainotebook.app.ui.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.Brightness6 -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.ColorLens -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Vibration -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.ainotebook.app.data.ThemeMode -import com.ainotebook.app.ui.LocalAppPrefs -import com.ainotebook.app.ui.ProfileViewModel -import com.ainotebook.app.ui.components.ErrorBanner -import com.ainotebook.app.ui.components.GlassCard -import com.ainotebook.app.ui.components.rememberHaptics -import com.ainotebook.app.ui.theme.Cyan -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.MutedText -import com.ainotebook.app.ui.theme.Violet -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfileScreen( - vm: ProfileViewModel, - onLoggedOut: () -> Unit -) { - val state by vm.state.collectAsState() - val prefs = LocalAppPrefs.current - val scope = rememberCoroutineScope() - val haptic = rememberHaptics() - - val themeMode by prefs.themeMode.collectAsState(initial = ThemeMode.DARK) - val dynamicColor by prefs.dynamicColor.collectAsState(initial = false) - val haptics by prefs.hapticsEnabled.collectAsState(initial = true) - - var showThemeSheet by remember { mutableStateOf(false) } - var showNameDialog by remember { mutableStateOf(false) } - var showPasswordDialog by remember { mutableStateOf(false) } - - LaunchedEffect(state.loggedOut) { if (state.loggedOut) onLoggedOut() } - - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .statusBarsPadding() - .padding(16.dp) - ) { - // Header / avatar - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box( - Modifier - .size(88.dp) - .clip(CircleShape) - .background(Brush.linearGradient(listOf(Indigo, Violet))), - contentAlignment = Alignment.Center - ) { - Text( - (state.user?.name?.firstOrNull() ?: 'U').uppercase(), - color = Color.White, - fontSize = 38.sp, - fontWeight = FontWeight.Bold - ) - } - Spacer(Modifier.height(12.dp)) - Text( - state.user?.name ?: "User", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - Text( - state.user?.email ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (state.user?.is_guest == true) { - Spacer(Modifier.height(4.dp)) - Box( - Modifier - .clip(RoundedCornerShape(12.dp)) - .background(Violet.copy(alpha = 0.18f)) - .padding(horizontal = 10.dp, vertical = 4.dp) - ) { - Text("Guest account", color = Violet, style = MaterialTheme.typography.labelMedium) - } - } - } - } - - Spacer(Modifier.height(18.dp)) - ErrorBanner(state.error) - state.message?.let { - Text(it, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(vertical = 6.dp)) - } - - // Account section - SectionLabel("Account") - SettingsGroup { - SettingsRow(Icons.Default.Person, "Display name", state.user?.name ?: "—") { showNameDialog = true } - Divider() - SettingsRow(Icons.Default.Lock, "Change password", "••••••") { showPasswordDialog = true } - } - - Spacer(Modifier.height(16.dp)) - - // Appearance section - SectionLabel("Appearance") - SettingsGroup { - SettingsRow(Icons.Default.Brightness6, "Theme", themeModeLabel(themeMode)) { showThemeSheet = true } - Divider() - SettingsToggleRow( - Icons.Default.ColorLens, - "Dynamic color", - "Use your wallpaper colors (Android 12+)", - dynamicColor - ) { enabled -> scope.launch { prefs.setDynamicColor(enabled) } } - Divider() - SettingsToggleRow( - Icons.Default.Vibration, - "Haptic feedback", - "Subtle vibrations on key actions", - haptics - ) { enabled -> haptic(); scope.launch { prefs.setHaptics(enabled) } } - } - - Spacer(Modifier.height(24.dp)) - OutlinedButton( - onClick = { haptic(); vm.logout() }, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(16.dp) - ) { - Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(8.dp)) - Text("Log out") - } - Spacer(Modifier.height(8.dp)) - Text( - "AI Notebook · v1.1", - style = MaterialTheme.typography.labelSmall, - color = MutedText, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(Modifier.height(24.dp)) - } - - // Theme picker bottom sheet - if (showThemeSheet) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = { showThemeSheet = false }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - Text("Theme", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.height(12.dp)) - ThemeMode.values().forEach { mode -> - val selected = mode == themeMode - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(if (selected) Indigo.copy(alpha = 0.14f) else Color.Transparent) - .clickable { scope.launch { prefs.setThemeMode(mode) }; showThemeSheet = false } - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(themeModeLabel(mode), Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold) - if (selected) Icon(Icons.Default.AutoAwesome, contentDescription = null, tint = Indigo) - } - Spacer(Modifier.height(6.dp)) - } - Spacer(Modifier.height(20.dp)) - } - } - } - - // Edit name dialog - if (showNameDialog) { - var newName by remember { mutableStateOf(state.user?.name ?: "") } - AlertDialog( - onDismissRequest = { showNameDialog = false }, - title = { Text("Display name") }, - text = { - OutlinedTextField(value = newName, onValueChange = { newName = it }, singleLine = true, modifier = Modifier.fillMaxWidth()) - }, - confirmButton = { - TextButton(onClick = { vm.updateName(newName); showNameDialog = false }) { Text("Save") } - }, - dismissButton = { TextButton(onClick = { showNameDialog = false }) { Text("Cancel") } } - ) - } - - // Change password dialog - if (showPasswordDialog) { - var current by remember { mutableStateOf("") } - var new by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showPasswordDialog = false }, - title = { Text("Change password") }, - text = { - Column { - OutlinedTextField( - value = current, onValueChange = { current = it }, - label = { Text("Current password") }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = new, onValueChange = { new = it }, - label = { Text("New password (min 6)") }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - TextButton(onClick = { vm.changePassword(current, new); showPasswordDialog = false }) { Text("Update") } - }, - dismissButton = { TextButton(onClick = { showPasswordDialog = false }) { Text("Cancel") } } - ) - } -} - -private fun themeModeLabel(mode: ThemeMode) = when (mode) { - ThemeMode.SYSTEM -> "System default" - ThemeMode.LIGHT -> "Light" - ThemeMode.DARK -> "Dark" -} - -@Composable -private fun SectionLabel(text: String) { - Text( - text.uppercase(), - style = MaterialTheme.typography.labelMedium, - color = MutedText, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) - ) -} - -@Composable -private fun SettingsGroup(content: @Composable () -> Unit) { - GlassCard(Modifier.fillMaxWidth()) { Column { content() } } -} - -@Composable -private fun Divider() { - Box( - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .height(1.dp) - .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) - ) -} - -@Composable -private fun SettingsRow(icon: ImageVector, title: String, value: String, onClick: () -> Unit) { - Row( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(icon, contentDescription = null, tint = Indigo, modifier = Modifier.size(22.dp)) - Spacer(Modifier.size(14.dp)) - Text(title, Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge) - Text(value, color = MutedText, style = MaterialTheme.typography.bodyMedium, maxLines = 1) - Spacer(Modifier.size(4.dp)) - Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MutedText, modifier = Modifier.size(20.dp)) - } -} - -@Composable -private fun SettingsToggleRow( - icon: ImageVector, - title: String, - subtitle: String, - checked: Boolean, - onToggle: (Boolean) -> Unit -) { - Row( - Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(icon, contentDescription = null, tint = Cyan, modifier = Modifier.size(22.dp)) - Spacer(Modifier.size(14.dp)) - Column(Modifier.weight(1f)) { - Text(title, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge) - Text(subtitle, color = MutedText, style = MaterialTheme.typography.bodySmall) - } - Switch(checked = checked, onCheckedChange = onToggle) - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/SignupScreen.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/SignupScreen.kt deleted file mode 100644 index 0ae0f34..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/SignupScreen.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.ainotebook.app.ui.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -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.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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import com.ainotebook.app.ui.AuthViewModel -import com.ainotebook.app.ui.components.BrandLogo -import com.ainotebook.app.ui.components.ErrorBanner -import com.ainotebook.app.ui.components.GlassCard -import com.ainotebook.app.ui.components.SpaceBackground -import com.ainotebook.app.ui.components.rememberHaptics -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.Violet - -@Composable -fun SignupScreen( - vm: AuthViewModel, - onSignedUp: () -> Unit, - onGoLogin: () -> Unit -) { - val state by vm.state.collectAsState() - var name by remember { mutableStateOf("") } - var username by remember { mutableStateOf("") } - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var confirm by remember { mutableStateOf("") } - val haptics = rememberHaptics() - - if (state.success) onSignedUp() - - SpaceBackground { - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Spacer(Modifier.height(24.dp)) - BrandLogo(size = 72.dp) - Spacer(Modifier.height(10.dp)) - Text( - "Create your account", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(20.dp)) - - GlassCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(20.dp)) { - ErrorBanner(state.error) - - OutlinedTextField( - value = name, onValueChange = { name = it; vm.clearError() }, - label = { Text("Full name") }, singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = username, onValueChange = { username = it; vm.clearError() }, - label = { Text("Username") }, singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = email, onValueChange = { email = it; vm.clearError() }, - label = { Text("Email") }, singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = password, onValueChange = { password = it; vm.clearError() }, - label = { Text("Password (min 6)") }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = confirm, onValueChange = { confirm = it; vm.clearError() }, - label = { Text("Confirm password") }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(20.dp)) - Box( - Modifier - .fillMaxWidth() - .height(50.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(14.dp)) - .background(Brush.linearGradient(listOf(Indigo, Violet))) - ) { - Button( - onClick = { haptics(); vm.signup(name, username, email, password, confirm) }, - enabled = !state.loading, - modifier = Modifier.fillMaxWidth().height(50.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - disabledContainerColor = Color.Transparent - ), - elevation = null - ) { - if (state.loading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = Color.White, strokeWidth = 2.dp - ) - } else { - Text("Create account", fontWeight = FontWeight.SemiBold, color = Color.White) - } - } - } - Spacer(Modifier.height(8.dp)) - TextButton(onClick = onGoLogin, modifier = Modifier.fillMaxWidth()) { - Text("Already have an account? Log in") - } - } - } - Spacer(Modifier.height(24.dp)) - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ToolsScreen.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ToolsScreen.kt deleted file mode 100644 index b40e6eb..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/screens/ToolsScreen.kt +++ /dev/null @@ -1,328 +0,0 @@ -package com.ainotebook.app.ui.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.layout.statusBarsPadding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.AutoStories -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.automirrored.filled.HelpOutline -import androidx.compose.material.icons.filled.Quiz -import androidx.compose.material.icons.filled.Style -import androidx.compose.material.icons.filled.Summarize -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.ainotebook.app.ui.Tool -import com.ainotebook.app.ui.ToolsViewModel -import com.ainotebook.app.ui.components.BrandLogo -import com.ainotebook.app.ui.components.ErrorBanner -import com.ainotebook.app.ui.components.GlassCard -import com.ainotebook.app.ui.components.MarkdownText -import com.ainotebook.app.ui.components.TextResultSkeleton -import com.ainotebook.app.ui.components.rememberHaptics -import com.ainotebook.app.ui.theme.Cyan -import com.ainotebook.app.ui.theme.Indigo -import com.ainotebook.app.ui.theme.Violet - -private data class ToolMeta(val tool: Tool, val title: String, val desc: String, val icon: ImageVector, val accent: Color) - -private val TOOLS = listOf( - ToolMeta(Tool.NOTES, "Notes", "Generate study notes", Icons.Default.AutoStories, Indigo), - ToolMeta(Tool.QUIZ, "Quiz", "Interactive MCQ quiz", Icons.Default.Quiz, Violet), - ToolMeta(Tool.FLASHCARDS, "Flashcards", "Flip-to-reveal cards", Icons.Default.Style, Cyan), - ToolMeta(Tool.PLAN, "Study Plan", "Day-by-day plan", Icons.Default.CalendarMonth, Indigo), - ToolMeta(Tool.SUMMARIZE, "Summarizer", "Summarize any text", Icons.Default.Summarize, Violet), - ToolMeta(Tool.HOMEWORK, "Homework Helper", "Step-by-step answers", Icons.AutoMirrored.Filled.HelpOutline, Cyan), -) - -@Composable -fun ToolsScreen(vm: ToolsViewModel) { - var selected by remember { mutableStateOf(null) } - - val haptics = rememberHaptics() - - if (selected == null) { - Column(Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - BrandLogo(size = 40.dp) - Spacer(Modifier.size(10.dp)) - Text( - "Study Tools", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - } - Spacer(Modifier.height(12.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(TOOLS) { meta -> - GlassCard( - Modifier - .fillMaxWidth() - .height(140.dp) - .clickable { haptics(); vm.reset(); selected = meta.tool } - ) { - Column(Modifier.padding(16.dp)) { - Box( - Modifier - .size(44.dp) - .background(meta.accent.copy(alpha = 0.18f), androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Icon(meta.icon, null, tint = meta.accent, modifier = Modifier.size(24.dp)) - } - Spacer(Modifier.height(10.dp)) - Text(meta.title, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) - Text( - meta.desc, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } else { - ToolDetail(vm, selected!!, onBack = { haptics(); selected = null; vm.reset() }) - } -} - -@Composable -private fun ToolDetail(vm: ToolsViewModel, tool: Tool, onBack: () -> Unit) { - val state by vm.state.collectAsState() - var topic by remember { mutableStateOf("") } - var number by remember { mutableStateOf("5") } - - val meta = TOOLS.first { it.tool == tool } - - val haptics = rememberHaptics() - - Column( - Modifier - .fillMaxSize() - .statusBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(16.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground) - } - Text( - meta.title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - } - Spacer(Modifier.height(8.dp)) - ErrorBanner(state.error) - - val multiline = tool == Tool.SUMMARIZE || tool == Tool.HOMEWORK - val label = when (tool) { - Tool.SUMMARIZE -> "Paste text to summarize" - Tool.HOMEWORK -> "Your homework question" - Tool.PLAN -> "Your study goal" - else -> "Topic" - } - - OutlinedTextField( - value = topic, - onValueChange = { topic = it }, - label = { Text(label) }, - modifier = Modifier.fillMaxWidth().then(if (multiline) Modifier.height(160.dp) else Modifier), - singleLine = !multiline - ) - - if (tool == Tool.QUIZ || tool == Tool.FLASHCARDS || tool == Tool.PLAN) { - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = number, - onValueChange = { number = it.filter { c -> c.isDigit() } }, - label = { Text(if (tool == Tool.PLAN) "Number of days" else "How many?") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - - Spacer(Modifier.height(16.dp)) - val canGenerate = !state.loading && topic.isNotBlank() - Box( - Modifier - .fillMaxWidth() - .height(50.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(14.dp)) - .background( - if (canGenerate) - Brush.linearGradient(listOf(Indigo, Violet)) - else - Brush.linearGradient( - listOf( - MaterialTheme.colorScheme.surfaceContainerHigh, - MaterialTheme.colorScheme.surfaceContainerHigh - ) - ) - ) - ) { - Button( - onClick = { - haptics() - val n = number.toIntOrNull() ?: 5 - when (tool) { - Tool.NOTES -> vm.notes(topic) - Tool.QUIZ -> vm.quiz(topic, n.coerceIn(1, 15)) - Tool.FLASHCARDS -> vm.flashcards(topic, n.coerceIn(1, 20)) - Tool.PLAN -> vm.plan(topic, n.coerceIn(1, 60)) - Tool.SUMMARIZE -> vm.summarize(topic) - Tool.HOMEWORK -> vm.homework(topic) - } - }, - enabled = canGenerate, - modifier = Modifier.fillMaxWidth().height(50.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - disabledContainerColor = Color.Transparent - ), - elevation = null - ) { - if (state.loading) { - CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) - } else { - Text("Generate", fontWeight = FontWeight.SemiBold, color = Color.White) - } - } - } - - Spacer(Modifier.height(16.dp)) - - // Results - if (state.loading && state.textResult == null && state.quiz == null && state.flashcards == null) { - GlassCard(Modifier.fillMaxWidth()) { - TextResultSkeleton(Modifier.padding(16.dp)) - } - } - state.textResult?.let { ResultCard(it) } - state.quiz?.let { QuizResult(it) } - state.flashcards?.let { cards -> - cards.forEach { FlashcardView(it.front, it.back) } - } - } -} - -@Composable -private fun ResultCard(text: String) { - GlassCard(Modifier.fillMaxWidth()) { - MarkdownText( - markdown = text, - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onSurface - ) - } -} - -@Composable -private fun QuizResult(questions: List) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - questions.forEachIndexed { i, q -> - GlassCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp)) { - Text( - "${i + 1}. ${q.question}", - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(8.dp)) - q.options.forEachIndexed { idx, opt -> - val correct = idx == q.answer - Text( - "${('A' + idx)}. $opt", - color = if (correct) Cyan else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = if (correct) FontWeight.Bold else FontWeight.Normal, - modifier = Modifier.padding(vertical = 2.dp) - ) - } - if (q.explanation.isNotBlank()) { - Spacer(Modifier.height(6.dp)) - Text( - "💡 ${q.explanation}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -@Composable -private fun FlashcardView(front: String, back: String) { - var flipped by remember { mutableStateOf(false) } - GlassCard( - Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) - .clickable { flipped = !flipped } - ) { - Column(Modifier.padding(20.dp)) { - Text( - if (flipped) "Answer" else "Question", - style = MaterialTheme.typography.labelSmall, - color = Indigo - ) - Spacer(Modifier.height(6.dp)) - Text( - if (flipped) back else front, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyLarge - ) - Spacer(Modifier.height(6.dp)) - Text( - "Tap to flip", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/theme/Theme.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/theme/Theme.kt deleted file mode 100644 index a78368a..0000000 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/theme/Theme.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.ainotebook.app.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -import androidx.core.view.WindowCompat - -// Brand palette (mirrors the web app's indigo / violet / cyan gradient look). -val Indigo = Color(0xFF6D7BFF) -val Violet = Color(0xFFA855F7) -val Cyan = Color(0xFF22D3EE) - -// Deep, calm space backgrounds — tuned for a premium ChatGPT/Gemini/Perplexity -// assistant feel: near-black canvas with subtly elevated surfaces. -val SpaceBg = Color(0xFF080B16) -val SpaceSurface = Color(0xFF11162A) -val SpaceCard = Color(0xFF161C33) - -// Additional assistant-UI tokens (used by chat bubbles, dividers, hints). -val AssistantBubble = Color(0xFF181E36) -val HairlineOutline = Color(0xFF263052) -val MutedText = Color(0xFF9AA3C7) - -// Light-mode equivalents so chat/dashboard stay legible when the user opts in. -val LightBg = Color(0xFFF6F7FC) -val LightSurface = Color(0xFFFFFFFF) -val LightCard = Color(0xFFEFF1FA) -val LightHairline = Color(0xFFDDE1F0) -val LightMuted = Color(0xFF5B647F) - -private val DarkColors = darkColorScheme( - primary = Indigo, - onPrimary = Color.White, - primaryContainer = Indigo.copy(alpha = 0.22f), - onPrimaryContainer = Color(0xFFD8DCFF), - secondary = Violet, - onSecondary = Color.White, - tertiary = Cyan, - onTertiary = Color(0xFF062A30), - background = SpaceBg, - onBackground = Color(0xFFEDEFFA), - surface = SpaceSurface, - onSurface = Color(0xFFEDEFFA), - surfaceVariant = SpaceCard, - onSurfaceVariant = MutedText, - surfaceContainer = SpaceCard, - surfaceContainerHigh = Color(0xFF1A2138), - outline = HairlineOutline, - outlineVariant = Color(0xFF1C2440), - error = Color(0xFFFB7185), - onError = Color.White -) - -private val LightColors = lightColorScheme( - primary = Indigo, - onPrimary = Color.White, - primaryContainer = Indigo.copy(alpha = 0.14f), - onPrimaryContainer = Color(0xFF2A2F66), - secondary = Violet, - onSecondary = Color.White, - tertiary = Cyan, - onTertiary = Color(0xFF062A30), - background = LightBg, - onBackground = Color(0xFF131726), - surface = LightSurface, - onSurface = Color(0xFF131726), - surfaceVariant = LightCard, - onSurfaceVariant = LightMuted, - surfaceContainer = LightCard, - surfaceContainerHigh = Color(0xFFE7EAF6), - outline = LightHairline, - outlineVariant = Color(0xFFE2E5F2), - error = Color(0xFFDC2626), - onError = Color.White -) - -// Slightly tighter, more confident type scale (assistant-app feel). -private val AppTypography = Typography().run { - copy( - displaySmall = displaySmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.6).sp), - headlineMedium = headlineMedium.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.5).sp), - headlineSmall = headlineSmall.copy(fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp), - titleLarge = titleLarge.copy(fontWeight = FontWeight.SemiBold), - titleMedium = titleMedium.copy(fontWeight = FontWeight.SemiBold), - bodyLarge = bodyLarge.copy(lineHeight = 24.sp), - bodyMedium = bodyMedium.copy(lineHeight = 22.sp), - labelLarge = labelLarge.copy(fontWeight = FontWeight.SemiBold) - ) -} - -@Composable -fun AiNotebookTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = false, - content: @Composable () -> Unit -) { - val context = LocalContext.current - val colors = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColors - else -> LightColors - } - - // Keep the system bars edge-to-edge and pick legible icon tints for the - // chosen scheme (premium native behaviour, never a "website" frame). - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - WindowCompat.setDecorFitsSystemWindows(window, false) - val lightIcons = colors.background.luminance() > 0.5f - val controller = WindowCompat.getInsetsController(window, view) - controller.isAppearanceLightStatusBars = lightIcons - controller.isAppearanceLightNavigationBars = lightIcons - } - } - - MaterialTheme( - colorScheme = colors, - typography = AppTypography, - content = content - ) -} diff --git a/api/Android/app/src/main/res/drawable-nodpi/ss_logo.png b/api/Android/app/src/main/res/drawable-nodpi/ss_logo.png deleted file mode 100644 index 7e25237..0000000 Binary files a/api/Android/app/src/main/res/drawable-nodpi/ss_logo.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/api/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index be43858..0000000 --- a/api/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/api/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/api/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index be43858..0000000 --- a/api/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index a7ad822..0000000 Binary files a/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 4652213..0000000 Binary files a/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index a7ad822..0000000 Binary files a/api/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index dc33b7c..0000000 Binary files a/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 50bae94..0000000 Binary files a/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index dc33b7c..0000000 Binary files a/api/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index e04bcf0..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index d1a9afc..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index e04bcf0..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 9f8bc3a..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 7c2f31a..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 9f8bc3a..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index d7d34ba..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index d795e8a..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index d7d34ba..0000000 Binary files a/api/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/api/Android/app/src/main/res/values/colors.xml b/api/Android/app/src/main/res/values/colors.xml deleted file mode 100644 index 558d406..0000000 --- a/api/Android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - #080B16 - #6D7BFF - #6D7BFF - diff --git a/api/Android/app/src/main/res/values/strings.xml b/api/Android/app/src/main/res/values/strings.xml deleted file mode 100644 index 516ec34..0000000 --- a/api/Android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - AI Notebook - diff --git a/api/Android/app/src/main/res/values/themes.xml b/api/Android/app/src/main/res/values/themes.xml deleted file mode 100644 index dd0959f..0000000 --- a/api/Android/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - diff --git a/api/Android/build.gradle.kts b/api/Android/build.gradle.kts deleted file mode 100644 index 0fe40d0..0000000 --- a/api/Android/build.gradle.kts +++ /dev/null @@ -1,6 +0,0 @@ -// Top-level build file -plugins { - id("com.android.application") version "8.5.2" apply false - id("org.jetbrains.kotlin.android") version "1.9.24" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false -} diff --git a/api/Android/gradle.properties b/api/Android/gradle.properties deleted file mode 100644 index f0a2e55..0000000 --- a/api/Android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -android.useAndroidX=true -kotlin.code.style=official -android.nonTransitiveRClass=true diff --git a/api/Android/gradle/wrapper/gradle-wrapper.jar b/api/Android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e644113..0000000 Binary files a/api/Android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/api/Android/gradle/wrapper/gradle-wrapper.properties b/api/Android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index b82aa23..0000000 --- a/api/Android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/api/Android/gradlew b/api/Android/gradlew deleted file mode 100644 index 97de990..0000000 --- a/api/Android/gradlew +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/api/Android/gradlew.bat b/api/Android/gradlew.bat deleted file mode 100644 index 16e26a1..0000000 --- a/api/Android/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/api/Android/settings.gradle.kts b/api/Android/settings.gradle.kts deleted file mode 100644 index cc64ae7..0000000 --- a/api/Android/settings.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -pluginManagement { - repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - mavenCentral() - gradlePluginPortal() - } -} -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "AINotebook" -include(":app")