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