Add the native Hesychia Android client so the repo can build beyond the placeholder README.
CI / build (push) Canceled after 0s
CI / build (push) Canceled after 0s
This commit is contained in:
1 parent
d9d2916a49
commit
e36d97760a
52 files changed
+5050
-1
No files matched your search
@@ -0,0 +1,156 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.hesychia.chat"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ai.hesychia.chat"
|
||||
minSdk = 23
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "REVENUECAT_GOOGLE_API_KEY", "\"\"")
|
||||
buildConfigField("String", "REVENUECAT_OFFERING_ID", "\"\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "env"
|
||||
productFlavors {
|
||||
create("prod") {
|
||||
dimension = "env"
|
||||
isDefault = true
|
||||
buildConfigField(
|
||||
"String",
|
||||
"REST_API_BASE_URL",
|
||||
"\"https://backend.hesychia.ai/api/\"",
|
||||
)
|
||||
buildConfigField(
|
||||
"String",
|
||||
"WS_API_BASE_URL",
|
||||
"\"wss://backend.hesychia.ai/ws/chat_again/\"",
|
||||
)
|
||||
}
|
||||
create("beta") {
|
||||
dimension = "env"
|
||||
applicationIdSuffix = ".beta"
|
||||
versionNameSuffix = "-beta"
|
||||
buildConfigField(
|
||||
"String",
|
||||
"REST_API_BASE_URL",
|
||||
"\"https://beta.backend.hesychia.ai/api/\"",
|
||||
)
|
||||
buildConfigField(
|
||||
"String",
|
||||
"WS_API_BASE_URL",
|
||||
"\"wss://beta.backend.hesychia.ai/ws/chat_again/\"",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
implementation("androidx.browser:browser:1.8.0")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.53.1")
|
||||
ksp("com.google.dagger:hilt-compiler:2.53.1")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
||||
|
||||
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")
|
||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
|
||||
implementation("com.android.billingclient:billing-ktx:7.1.1")
|
||||
// RevenueCat — wire keys via BuildConfig / local.properties later (Workstream E)
|
||||
implementation("com.revenuecat.purchases:purchases:8.12.2")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("com.google.truth:truth:1.4.4")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# Hesychia — release minify rules (enable later with R8)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn retrofit2.**
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".HesychiaApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@drawable/ic_launcher_foreground"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Hesychia">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.Hesychia">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Password-reset / SSO deep links (register IdP redirect URIs separately) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="hesychia.ai"
|
||||
android:pathPrefix="/auth"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="hesychia.ai"
|
||||
android:pathPrefix="/password_reset"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="hesychia.ai"
|
||||
android:pathPrefix="/set_password"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
package ai.hesychia.chat
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class HesychiaApp : Application()
|
||||
@@ -0,0 +1,87 @@
|
||||
package ai.hesychia.chat
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import ai.hesychia.chat.navigation.HesychiaNavHost
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import ai.hesychia.chat.ui.session.LaunchUri
|
||||
import ai.hesychia.chat.ui.session.SessionViewModel
|
||||
import ai.hesychia.chat.ui.theme.HesychiaTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
LaunchUri.uri = intent?.data
|
||||
val launchUri = intent?.data
|
||||
setContent {
|
||||
val sessionVm: SessionViewModel = hiltViewModel()
|
||||
val session by sessionVm.state.collectAsStateWithLifecycle()
|
||||
val boot by sessionVm.boot.collectAsStateWithLifecycle()
|
||||
val subscription by sessionVm.subscription.collectAsStateWithLifecycle()
|
||||
HesychiaTheme(
|
||||
darkTheme = session.darkMode ?: isSystemInDarkTheme(),
|
||||
themeColor = session.themeColor,
|
||||
) {
|
||||
if (!boot.ready) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
val navController = rememberNavController()
|
||||
var driveConnected by remember {
|
||||
mutableStateOf(launchUri?.getQueryParameter("drive_connected") == "1")
|
||||
}
|
||||
LaunchedEffect(navController) {
|
||||
LaunchUri.incoming.collect { uri ->
|
||||
driveConnected = uri.getQueryParameter("drive_connected") == "1"
|
||||
val route = sessionVm.routeFor(uri)
|
||||
navController.navigate(route) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
HesychiaNavHost(
|
||||
startDestination = boot.startRoute,
|
||||
hasRag = subscription?.hasRag == true,
|
||||
driveJustConnected = driveConnected,
|
||||
onSessionChanged = sessionVm::refreshSubscription,
|
||||
onSignOut = {
|
||||
sessionVm.signOut {
|
||||
navController.navigate(Routes.SignIn) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
intent.data?.let { LaunchUri.incoming.tryEmit(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ai.hesychia.chat.data.api
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import retrofit2.HttpException
|
||||
|
||||
fun Throwable.apiMessage(fallback: String, json: Json = ApiJson): String {
|
||||
val http = this as? HttpException ?: return message?.takeIf { it.isNotBlank() } ?: fallback
|
||||
val raw = runCatching { http.response()?.errorBody()?.string() }.getOrNull().orEmpty()
|
||||
if (raw.isBlank()) {
|
||||
return if (http.code() == 403) "Account registration is currently disabled." else fallback
|
||||
}
|
||||
val parsed = runCatching { json.decodeFromString(ErrorEnvelope.serializer(), raw) }.getOrNull()
|
||||
val detail = parsed?.detail?.let { elementText(it) }
|
||||
if (!detail.isNullOrBlank()) return detail
|
||||
val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()
|
||||
val email = (obj?.get("email") as? JsonArray)?.firstOrNull()?.let { elementText(it) }
|
||||
if (!email.isNullOrBlank()) return email
|
||||
return fallback
|
||||
}
|
||||
|
||||
private fun elementText(element: kotlinx.serialization.json.JsonElement): String? = when (element) {
|
||||
is JsonPrimitive -> element.contentOrNull
|
||||
is JsonArray -> element.firstOrNull()?.let { elementText(it) }
|
||||
is JsonObject -> element.entries.firstOrNull()?.value?.let { elementText(it) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
val ApiJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package ai.hesychia.chat.data.api
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface HesychiaApi {
|
||||
@GET("public/settings/")
|
||||
suspend fun publicSettings(): PublicSettingsDto
|
||||
|
||||
@POST("token/obtain/")
|
||||
suspend fun login(@Body body: LoginRequestDto): TokenPairDto
|
||||
|
||||
@POST("token/refresh/")
|
||||
suspend fun refresh(@Body body: RefreshRequestDto): TokenPairDto
|
||||
|
||||
@GET("user/get/")
|
||||
suspend fun currentUser(): UserDto
|
||||
|
||||
@POST("user/create/")
|
||||
suspend fun createUser(@Body body: SignUpRequestDto): SignUpResponseDto
|
||||
|
||||
@POST("user/acknowledge_tos/")
|
||||
suspend fun acknowledgeTos()
|
||||
|
||||
@POST("user/reset_password/")
|
||||
suspend fun resetPassword(@Body body: PasswordResetRequestDto)
|
||||
|
||||
@GET("user/set_password/{slug}/")
|
||||
suspend fun checkSetPassword(@Path("slug") slug: String)
|
||||
|
||||
@POST("user/set_password/{slug}/")
|
||||
suspend fun setPassword(@Path("slug") slug: String, @Body body: SetPasswordRequestDto)
|
||||
|
||||
@POST("blacklist/")
|
||||
suspend fun blacklist(@Body body: RefreshTokenBody)
|
||||
|
||||
@HTTP(method = "DELETE", path = "user/", hasBody = true)
|
||||
suspend fun deleteUser(@Body body: RefreshTokenBody)
|
||||
|
||||
@GET("conversation_preferences")
|
||||
suspend fun preferences(): PreferencesDto
|
||||
|
||||
@POST("conversation_preferences")
|
||||
suspend fun savePreferences(@Body body: PreferencesDto): PreferencesDto
|
||||
|
||||
@GET("monetization/subscription/")
|
||||
suspend fun subscription(): SubscriptionDto
|
||||
|
||||
@GET("monetization/invoices/")
|
||||
suspend fun invoices(): List<InvoiceDto>
|
||||
|
||||
@GET("monetization/payments/")
|
||||
suspend fun payments(): List<PaymentDto>
|
||||
|
||||
@GET("monetization/plans/")
|
||||
suspend fun plans(): List<PlanDto>
|
||||
|
||||
@POST("monetization/store/confirm/")
|
||||
suspend fun confirmStore(@Body body: StoreConfirmRequestDto)
|
||||
|
||||
@GET("company_users")
|
||||
suspend fun companyUsers(): List<UserDto>
|
||||
|
||||
@POST("user/invite/")
|
||||
suspend fun invite(@Body body: EmailBody)
|
||||
|
||||
@POST("company_users")
|
||||
suspend fun updateCompanyUser(@Body body: CompanyUserUpdateDto)
|
||||
|
||||
@HTTP(method = "DELETE", path = "company_users", hasBody = true)
|
||||
suspend fun deleteCompanyUser(@Body body: EmailBody)
|
||||
|
||||
@GET("conversations")
|
||||
suspend fun conversations(): List<ConversationDto>
|
||||
|
||||
@GET("conversation_details")
|
||||
suspend fun conversationDetails(@Query("conversation_id") conversationId: Int): List<PromptDto>
|
||||
|
||||
@HTTP(method = "DELETE", path = "conversation_details", hasBody = true)
|
||||
suspend fun deleteConversation(@Body body: ConversationIdBody)
|
||||
|
||||
@POST("prompt_feedback")
|
||||
suspend fun upsertPromptFeedback(@Body body: PromptFeedbackRequestDto)
|
||||
|
||||
@DELETE("prompt_feedback")
|
||||
suspend fun clearPromptFeedback(@Query("prompt_id") promptId: Int)
|
||||
|
||||
@GET("documents/")
|
||||
suspend fun documents(
|
||||
@Query("workspace") workspace: String,
|
||||
@Query("page") page: Int,
|
||||
@Query("page_size") pageSize: Int,
|
||||
@Query("search") search: String?,
|
||||
@Query("ordering") ordering: String,
|
||||
): DocumentPageDto
|
||||
|
||||
@Multipart
|
||||
@POST("documents/")
|
||||
suspend fun uploadDocument(
|
||||
@Query("workspace") workspace: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
)
|
||||
|
||||
@PATCH("documents_details/{id}")
|
||||
suspend fun setDocumentActive(@Path("id") id: Int, @Body body: ActiveBody)
|
||||
|
||||
@GET("drive/connections/")
|
||||
suspend fun driveConnections(): List<DriveConnectionDto>
|
||||
|
||||
@DELETE("drive/connections/{id}/")
|
||||
suspend fun disconnectDrive(@Path("id") id: Int)
|
||||
|
||||
@POST("drive/connections/{id}/resources/")
|
||||
suspend fun saveDriveResources(
|
||||
@Path("id") id: Int,
|
||||
@Body body: DriveResourceSelectionDto,
|
||||
): DriveConnectionDto
|
||||
|
||||
@POST("drive/connections/{id}/sync/")
|
||||
suspend fun syncDrive(@Path("id") id: Int): DriveSyncResponseDto
|
||||
|
||||
@GET("feedbacks/")
|
||||
suspend fun feedbacks(): List<FeedbackDto>
|
||||
|
||||
@POST("feedbacks/")
|
||||
suspend fun createFeedback(@Body body: FeedbackCreateDto)
|
||||
|
||||
@GET("analytics/user_prompt_heatmap/")
|
||||
suspend fun promptHeatmap(@Query("tz") tz: String): HeatmapDto
|
||||
|
||||
@GET("analytics/user_prompts/")
|
||||
suspend fun userPrompts(): List<MonthSeriesDto>
|
||||
|
||||
@GET("analytics/user_conversations/")
|
||||
suspend fun userConversations(): List<MonthSeriesDto>
|
||||
|
||||
@GET("analytics/company_usage/")
|
||||
suspend fun companyUsage(): List<MonthSeriesDto>
|
||||
|
||||
@GET("analytics/admin/")
|
||||
suspend fun adminAnalytics(): List<MonthSeriesDto>
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package ai.hesychia.chat.data.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class TokenPairDto(
|
||||
val access: String,
|
||||
val refresh: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequestDto(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshRequestDto(
|
||||
val refresh: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshTokenBody(
|
||||
val refresh_token: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OAuthFlagsDto(
|
||||
val google: Boolean = false,
|
||||
val microsoft: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PublicSettingsDto(
|
||||
val enable_account_registration: Boolean = false,
|
||||
val oauth: OAuthFlagsDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CompanyDto(
|
||||
val id: Int? = null,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
val id: Int,
|
||||
val email: String = "",
|
||||
val first_name: String? = null,
|
||||
val last_name: String? = null,
|
||||
val is_company_manager: Boolean = false,
|
||||
val has_signed_tos: Boolean = false,
|
||||
val has_usable_password: Boolean = true,
|
||||
val is_active: Boolean = true,
|
||||
val role: String? = null,
|
||||
val company: CompanyDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SignUpRequestDto(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val first_name: String = "",
|
||||
val last_name: String = "",
|
||||
val company_name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SignUpResponseDto(
|
||||
val access: String,
|
||||
val refresh: String? = null,
|
||||
val needs_checkout: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EmailBody(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PasswordResetRequestDto(
|
||||
val email: String,
|
||||
val recaptchaToken: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SetPasswordRequestDto(
|
||||
val password1: String,
|
||||
val password2: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PreferencesDto(
|
||||
val order: Boolean = true,
|
||||
val use_conversation_context: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlanFeaturesDto(
|
||||
val rag: Boolean = false,
|
||||
val all_future_features: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlanDto(
|
||||
val slug: String = "",
|
||||
val name: String = "",
|
||||
val store_product_id: String? = null,
|
||||
val features: PlanFeaturesDto? = null,
|
||||
val is_selectable: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UsageDto(
|
||||
val prompts_in_window: Int = 0,
|
||||
val prompt_quota: Int? = null,
|
||||
val prompts_remaining: Int? = null,
|
||||
val window_hours: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionDto(
|
||||
val plan: PlanDto? = null,
|
||||
val status: String = "",
|
||||
val source: String = "",
|
||||
val needs_checkout: Boolean = false,
|
||||
val usage: UsageDto? = null,
|
||||
val store_account_token: String? = null,
|
||||
val current_period_end: String? = null,
|
||||
) {
|
||||
val hasRag: Boolean
|
||||
get() = plan?.features?.rag == true || plan?.features?.all_future_features == true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class InvoiceDto(
|
||||
val id: Int? = null,
|
||||
val number: String? = null,
|
||||
val status: String? = null,
|
||||
val total: String? = null,
|
||||
val amount_due: String? = null,
|
||||
val created: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PaymentDto(
|
||||
val id: Int? = null,
|
||||
val status: String? = null,
|
||||
val amount: String? = null,
|
||||
val created: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StoreConfirmRequestDto(
|
||||
val store: String,
|
||||
val product_id: String,
|
||||
val transaction_id: String = "",
|
||||
val purchase_token: String = "",
|
||||
val signed_transaction: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CompanyUserUpdateDto(
|
||||
val email: String,
|
||||
val field: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConversationDto(
|
||||
val id: Int,
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConversationIdBody(
|
||||
val conversation_id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CitationDto(
|
||||
val index: Int = 0,
|
||||
val title: String = "",
|
||||
val url: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FeedbackRatingDto(
|
||||
val rating: String? = null,
|
||||
val reason: String? = null,
|
||||
val comment: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptDto(
|
||||
val id: Int? = null,
|
||||
val message: String = "",
|
||||
val user_created: Boolean = false,
|
||||
val created_timestamp: String? = null,
|
||||
val citations: List<CitationDto> = emptyList(),
|
||||
val feedback: FeedbackRatingDto? = null,
|
||||
val file_type: String? = null,
|
||||
val image: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptFeedbackRequestDto(
|
||||
val prompt_id: Int,
|
||||
val rating: String,
|
||||
val reason: String? = null,
|
||||
val comment: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DocumentDto(
|
||||
val id: Int,
|
||||
val file: String = "",
|
||||
val created: String = "",
|
||||
val active: Boolean = true,
|
||||
val processed: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DocumentPageDto(
|
||||
val count: Int = 0,
|
||||
val results: List<DocumentDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ActiveBody(
|
||||
val active: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DriveConnectionDto(
|
||||
val id: Int,
|
||||
val provider: String = "",
|
||||
val kind: String? = null,
|
||||
val external_account_email: String? = null,
|
||||
val last_sync_status: String? = null,
|
||||
val last_sync_error: String? = null,
|
||||
val selected_resource_labels: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DriveResourceSelectionDto(
|
||||
val resource_ids: List<String>,
|
||||
val resource_labels: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DriveSyncResponseDto(
|
||||
val connection: DriveConnectionDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FeedbackDto(
|
||||
val id: Int? = null,
|
||||
val title: String = "",
|
||||
val text: String = "",
|
||||
val status: String = "",
|
||||
val category: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FeedbackCreateDto(
|
||||
val title: String,
|
||||
val text: String,
|
||||
val category: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MonthSeriesDto(
|
||||
val month: String = "",
|
||||
val you: Int = 0,
|
||||
val others: Int = 0,
|
||||
val all: Int = 0,
|
||||
val used: Int = 0,
|
||||
val not_used: Int = 0,
|
||||
val avg: Double? = null,
|
||||
val median: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeatmapPeakDto(
|
||||
val day: String = "",
|
||||
val hour: Int = 0,
|
||||
val count: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HeatmapDto(
|
||||
val tz: String = "",
|
||||
val total: Int = 0,
|
||||
val max: Int = 0,
|
||||
val days: List<String> = emptyList(),
|
||||
val hours: List<Int> = emptyList(),
|
||||
val matrix: List<List<Int>> = emptyList(),
|
||||
val most_active_day: String? = null,
|
||||
val most_active_hour: Int? = null,
|
||||
val peak_cell: HeatmapPeakDto? = null,
|
||||
)
|
||||
|
||||
/** Loose error body. Backend mixes `detail` strings and field arrays. */
|
||||
@Serializable
|
||||
data class ErrorEnvelope(
|
||||
val detail: JsonElement? = null,
|
||||
)
|
||||
@@ -0,0 +1,283 @@
|
||||
package ai.hesychia.chat.data.billing
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import ai.hesychia.chat.BuildConfig
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.StoreConfirmRequestDto
|
||||
import ai.hesychia.chat.data.api.apiMessage
|
||||
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||
import com.android.billingclient.api.BillingClient
|
||||
import com.android.billingclient.api.BillingClientStateListener
|
||||
import com.android.billingclient.api.BillingFlowParams
|
||||
import com.android.billingclient.api.BillingResult
|
||||
import com.android.billingclient.api.PendingPurchasesParams
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||
import com.android.billingclient.api.QueryProductDetailsParams
|
||||
import com.android.billingclient.api.QueryPurchasesParams
|
||||
import com.revenuecat.purchases.CustomerInfo
|
||||
import com.revenuecat.purchases.Purchases
|
||||
import com.revenuecat.purchases.PurchasesConfiguration
|
||||
import com.revenuecat.purchases.PurchasesError
|
||||
import com.revenuecat.purchases.interfaces.LogInCallback
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
/**
|
||||
* Play purchase sheet uses BillingClient so the backend gets a real
|
||||
* purchase token and the user's store account id. RevenueCat still
|
||||
* identifies the signed-in user when an API key is configured.
|
||||
* Entitlement is granted only after `POST /monetization/store/confirm/`.
|
||||
*/
|
||||
@Singleton
|
||||
class StoreBilling @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val api: HesychiaApi,
|
||||
) : PurchasesUpdatedListener {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var billingClient: BillingClient? = null
|
||||
private var pendingProductId: String = ""
|
||||
private var pendingError: ((String) -> Unit)? = null
|
||||
private var pendingDone: (() -> Unit)? = null
|
||||
|
||||
fun configure() {
|
||||
val key = BuildConfig.REVENUECAT_GOOGLE_API_KEY
|
||||
if (key.isBlank() || Purchases.isConfigured) return
|
||||
Purchases.configure(PurchasesConfiguration.Builder(context, key).build())
|
||||
}
|
||||
|
||||
suspend fun logIn(userId: String) {
|
||||
configure()
|
||||
if (!Purchases.isConfigured) return
|
||||
suspendCancellableCoroutine { cont ->
|
||||
Purchases.sharedInstance.logIn(userId, object : LogInCallback {
|
||||
override fun onReceived(customerInfo: CustomerInfo, created: Boolean) {
|
||||
if (cont.isActive) cont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun onError(error: PurchasesError) {
|
||||
if (cont.isActive) cont.resume(Unit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logOut() {
|
||||
if (!Purchases.isConfigured) return
|
||||
runCatching { Purchases.sharedInstance.logOut() }
|
||||
}
|
||||
|
||||
fun purchase(
|
||||
activity: Activity,
|
||||
productId: String,
|
||||
accountToken: String,
|
||||
onError: (String) -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
if (productId.isBlank()) {
|
||||
onError("No Play product is configured for this plan.")
|
||||
return
|
||||
}
|
||||
connect(onError) {
|
||||
queryAndLaunch(activity, productId, accountToken, onError, onDone)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restore() {
|
||||
val owned = suspendCancellableCoroutine<List<Purchase>> { cont ->
|
||||
connect(
|
||||
onError = { message ->
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException(message))
|
||||
},
|
||||
) {
|
||||
val client = billingClient
|
||||
if (client == null || !client.isReady) {
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException("Play Billing is not ready."))
|
||||
return@connect
|
||||
}
|
||||
val params = QueryPurchasesParams.newBuilder()
|
||||
.setProductType(BillingClient.ProductType.SUBS)
|
||||
.build()
|
||||
client.queryPurchasesAsync(params) { result, purchases ->
|
||||
if (!cont.isActive) return@queryPurchasesAsync
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
cont.resumeWithException(
|
||||
IllegalStateException("Could not restore Play purchases: ${result.debugMessage}"),
|
||||
)
|
||||
return@queryPurchasesAsync
|
||||
}
|
||||
cont.resume(
|
||||
purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (owned.isEmpty()) throw IllegalStateException("No active store purchases found.")
|
||||
for (purchase in owned) {
|
||||
acknowledge(purchase)
|
||||
confirm(purchase)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
|
||||
val onError = pendingError
|
||||
val onDone = pendingDone
|
||||
val productId = pendingProductId
|
||||
pendingError = null
|
||||
pendingDone = null
|
||||
pendingProductId = ""
|
||||
if (onError == null || onDone == null) return
|
||||
when (billingResult.responseCode) {
|
||||
BillingClient.BillingResponseCode.USER_CANCELED -> onError("Purchase cancelled")
|
||||
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> queryOwned(productId, onError, onDone)
|
||||
BillingClient.BillingResponseCode.OK -> {
|
||||
val match = purchases?.firstOrNull { purchase ->
|
||||
purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
|
||||
(productId.isBlank() || purchase.products.contains(productId))
|
||||
}
|
||||
if (match == null) onError("Play purchase did not complete")
|
||||
else finish(match, onError, onDone)
|
||||
}
|
||||
else -> onError("Play purchase failed: ${billingResult.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryAndLaunch(
|
||||
activity: Activity,
|
||||
productId: String,
|
||||
accountToken: String,
|
||||
onError: (String) -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val client = billingClient ?: return onError("Play Billing is not ready.")
|
||||
val params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(
|
||||
listOf(
|
||||
QueryProductDetailsParams.Product.newBuilder()
|
||||
.setProductId(productId)
|
||||
.setProductType(BillingClient.ProductType.SUBS)
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
client.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
|
||||
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
onError("Could not load Play product: ${billingResult.debugMessage}")
|
||||
return@queryProductDetailsAsync
|
||||
}
|
||||
val details = productDetailsList.firstOrNull()
|
||||
if (details == null) {
|
||||
onError("Play product was not found: $productId")
|
||||
return@queryProductDetailsAsync
|
||||
}
|
||||
val offerToken = details.subscriptionOfferDetails?.firstOrNull()?.offerToken
|
||||
if (offerToken.isNullOrBlank()) {
|
||||
onError("Play product has no subscription offer")
|
||||
return@queryProductDetailsAsync
|
||||
}
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||
.setProductDetails(details)
|
||||
.setOfferToken(offerToken)
|
||||
.build()
|
||||
val flow = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(listOf(productParams))
|
||||
if (accountToken.isNotBlank()) flow.setObfuscatedAccountId(accountToken)
|
||||
activity.runOnUiThread {
|
||||
pendingProductId = productId
|
||||
pendingError = onError
|
||||
pendingDone = onDone
|
||||
val launched = client.launchBillingFlow(activity, flow.build())
|
||||
if (launched.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
pendingError = null
|
||||
pendingDone = null
|
||||
pendingProductId = ""
|
||||
onError("Could not open Play purchase sheet: ${launched.debugMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryOwned(productId: String, onError: (String) -> Unit, onDone: () -> Unit) {
|
||||
val client = billingClient ?: return onError("Play Billing is not ready.")
|
||||
val params = QueryPurchasesParams.newBuilder()
|
||||
.setProductType(BillingClient.ProductType.SUBS)
|
||||
.build()
|
||||
client.queryPurchasesAsync(params) { billingResult, purchases ->
|
||||
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
onError("Could not restore Play purchases: ${billingResult.debugMessage}")
|
||||
return@queryPurchasesAsync
|
||||
}
|
||||
val match = purchases.firstOrNull { purchase ->
|
||||
purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
|
||||
(productId.isBlank() || purchase.products.contains(productId))
|
||||
}
|
||||
if (match == null) onError("No active Play purchase for $productId")
|
||||
else finish(match, onError, onDone)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish(purchase: Purchase, onError: (String) -> Unit, onDone: () -> Unit) {
|
||||
acknowledge(purchase)
|
||||
scope.launch {
|
||||
runCatching { confirm(purchase) }
|
||||
.onSuccess { onDone() }
|
||||
.onFailure { error -> onError(error.apiMessage("Could not confirm purchase")) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun confirm(purchase: Purchase) {
|
||||
val productId = purchase.products.firstOrNull().orEmpty()
|
||||
if (productId.isBlank()) error("Play purchase did not include a product.")
|
||||
api.confirmStore(
|
||||
StoreConfirmRequestDto(
|
||||
store = "play_store",
|
||||
product_id = productId,
|
||||
transaction_id = purchase.orderId ?: purchase.purchaseToken,
|
||||
purchase_token = purchase.purchaseToken,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun acknowledge(purchase: Purchase) {
|
||||
val client = billingClient ?: return
|
||||
if (purchase.isAcknowledged) return
|
||||
val params = AcknowledgePurchaseParams.newBuilder()
|
||||
.setPurchaseToken(purchase.purchaseToken)
|
||||
.build()
|
||||
client.acknowledgePurchase(params) { }
|
||||
}
|
||||
|
||||
private fun connect(onError: (String) -> Unit, whenReady: () -> Unit) {
|
||||
val client = billingClient ?: BillingClient.newBuilder(context)
|
||||
.setListener(this)
|
||||
.enablePendingPurchases(
|
||||
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
|
||||
)
|
||||
.build()
|
||||
.also { billingClient = it }
|
||||
if (client.isReady) {
|
||||
whenReady()
|
||||
return
|
||||
}
|
||||
client.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(billingResult: BillingResult) {
|
||||
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
whenReady()
|
||||
} else {
|
||||
onError("Play Billing setup failed: ${billingResult.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBillingServiceDisconnected() = Unit
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ai.hesychia.chat.data.chat
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
||||
enum class SocketStatus { Connecting, Connected, Disconnected }
|
||||
|
||||
@Singleton
|
||||
class ChatSocket @Inject constructor(
|
||||
private val client: OkHttpClient,
|
||||
) {
|
||||
private var socket: WebSocket? = null
|
||||
|
||||
var onStatus: ((SocketStatus) -> Unit)? = null
|
||||
var onText: ((String) -> Unit)? = null
|
||||
|
||||
@Synchronized
|
||||
fun connect(url: String) {
|
||||
close()
|
||||
onStatus?.invoke(SocketStatus.Connecting)
|
||||
val request = Request.Builder().url(url).build()
|
||||
socket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
onStatus?.invoke(SocketStatus.Connected)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
onText?.invoke(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
onStatus?.invoke(SocketStatus.Disconnected)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
onStatus?.invoke(SocketStatus.Disconnected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun send(payload: String): Boolean = socket?.send(payload) == true
|
||||
|
||||
@Synchronized
|
||||
fun close() {
|
||||
socket?.close(1000, "bye")
|
||||
socket = null
|
||||
}
|
||||
|
||||
fun ping(email: String?) {
|
||||
val safe = email?.replace("\"", "") ?: ""
|
||||
socket?.send("""{"type":"ping","email":"$safe"}""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ai.hesychia.chat.data.chat
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
sealed interface IncomingFrame {
|
||||
data object ConversationIdMarker : IncomingFrame
|
||||
data object StreamStart : IncomingFrame
|
||||
data object StreamEnd : IncomingFrame
|
||||
data object Ignore : IncomingFrame
|
||||
data class Text(val value: String) : IncomingFrame
|
||||
data class Status(val stage: String, val label: String, val detail: String?) : IncomingFrame
|
||||
data class Citations(val titles: List<String>) : IncomingFrame
|
||||
data class Error(val content: String) : IncomingFrame
|
||||
}
|
||||
|
||||
private val frameJson = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
fun classifyIncoming(raw: String): IncomingFrame {
|
||||
val text = raw.trim()
|
||||
return when (text) {
|
||||
"CONVERSATION_ID" -> IncomingFrame.ConversationIdMarker
|
||||
"START_OF_THE_STREAM_ENDER_GAME_42" -> IncomingFrame.StreamStart
|
||||
"END_OF_THE_STREAM_ENDER_GAME_42" -> IncomingFrame.StreamEnd
|
||||
else -> classifyJsonOrText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyJsonOrText(text: String): IncomingFrame {
|
||||
if (!text.startsWith("{")) return IncomingFrame.Text(text)
|
||||
val obj = runCatching { frameJson.parseToJsonElement(text) as? JsonObject }.getOrNull()
|
||||
?: return IncomingFrame.Text(text)
|
||||
val type = obj["type"]?.jsonPrimitive?.contentOrNull
|
||||
if (obj["v"] != null && type != null) {
|
||||
return when (type) {
|
||||
"status" -> {
|
||||
val data = obj["data"] as? JsonObject
|
||||
val stage = data?.get("stage")?.jsonPrimitive?.contentOrNull
|
||||
val label = data?.get("label")?.jsonPrimitive?.contentOrNull
|
||||
if (stage.isNullOrBlank() || label == null) IncomingFrame.Ignore
|
||||
else IncomingFrame.Status(
|
||||
stage = stage,
|
||||
label = label,
|
||||
detail = data["detail"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
"citations" -> {
|
||||
val titles = (obj["data"] as? JsonArray).orEmpty().mapNotNull { item ->
|
||||
(item as? JsonObject)?.get("title")?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
IncomingFrame.Citations(titles)
|
||||
}
|
||||
"error" -> IncomingFrame.Error(
|
||||
obj["content"]?.jsonPrimitive?.contentOrNull ?: "Chat request failed",
|
||||
)
|
||||
else -> IncomingFrame.Ignore
|
||||
}
|
||||
}
|
||||
if (type == "error") {
|
||||
return IncomingFrame.Error(obj["content"]?.jsonPrimitive?.contentOrNull ?: "Chat request failed")
|
||||
}
|
||||
if (type == "ping") return IncomingFrame.Ignore
|
||||
obj["content"]?.jsonPrimitive?.contentOrNull?.let { content ->
|
||||
if (type == null) return IncomingFrame.Error(content)
|
||||
}
|
||||
return IncomingFrame.Text(text)
|
||||
}
|
||||
|
||||
fun conversationIdOrNull(text: String): Int? = text.trim().toIntOrNull()
|
||||
?: text.trim().toDoubleOrNull()?.toInt()
|
||||
@@ -0,0 +1,162 @@
|
||||
package ai.hesychia.chat.data.network
|
||||
|
||||
import ai.hesychia.chat.BuildConfig
|
||||
import ai.hesychia.chat.data.api.ApiJson
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.RefreshRequestDto
|
||||
import ai.hesychia.chat.data.session.SessionRepository
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = ApiJson
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named("bare")
|
||||
fun provideBareClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthInterceptor(sessionRepository: SessionRepository): Interceptor =
|
||||
Interceptor { chain ->
|
||||
val token = sessionRepository.cachedAccessToken
|
||||
?: runBlocking { sessionRepository.session.first().accessToken }
|
||||
android.util.Log.d("NetworkAuth", "Outgoing request: ${chain.request().url} | HasToken: ${!token.isNullOrBlank()}")
|
||||
val request = if (token.isNullOrBlank()) {
|
||||
chain.request()
|
||||
} else {
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "JWT $token")
|
||||
.build()
|
||||
}
|
||||
val response = chain.proceed(request)
|
||||
android.util.Log.d("NetworkAuth", "Response for ${request.url}: status=${response.code}")
|
||||
response
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticator(
|
||||
sessionRepository: SessionRepository,
|
||||
@Named("bare") bare: OkHttpClient,
|
||||
json: Json,
|
||||
): Authenticator = TokenAuthenticator(sessionRepository, bare, json)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttp(
|
||||
authInterceptor: Interceptor,
|
||||
authenticator: Authenticator,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(authenticator)
|
||||
.addInterceptor(logging)
|
||||
.pingInterval(25, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit =
|
||||
Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.REST_API_BASE_URL)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApi(retrofit: Retrofit): HesychiaApi = retrofit.create(HesychiaApi::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* On 401, exchange the refresh token once and retry. Skips the auth endpoints
|
||||
* so a bad password does not recurse.
|
||||
*/
|
||||
class TokenAuthenticator(
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val bare: OkHttpClient,
|
||||
private val json: Json,
|
||||
) : Authenticator {
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
val path = response.request.url.encodedPath
|
||||
if (path.contains("token/obtain") || path.contains("token/refresh") || path.contains("user/create")) {
|
||||
return null
|
||||
}
|
||||
val refresh = sessionRepository.cachedRefreshToken
|
||||
?: runBlocking { sessionRepository.session.first().refreshToken }
|
||||
if (refresh.isNullOrBlank()) return null
|
||||
val refreshed = runCatching { refreshBlocking(refresh) }.getOrNull() ?: return null
|
||||
runBlocking {
|
||||
sessionRepository.saveTokens(
|
||||
access = refreshed.access,
|
||||
refresh = refreshed.refresh ?: refresh,
|
||||
)
|
||||
}
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "JWT ${refreshed.access}")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun refreshBlocking(refresh: String): ai.hesychia.chat.data.api.TokenPairDto {
|
||||
val body = json.encodeToString(RefreshRequestDto.serializer(), RefreshRequestDto(refresh))
|
||||
val request = Request.Builder()
|
||||
.url(BuildConfig.REST_API_BASE_URL + "token/refresh/")
|
||||
.post(body.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
bare.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("refresh failed ${response.code}")
|
||||
val raw = response.body?.string().orEmpty()
|
||||
return json.decodeFromString(ai.hesychia.chat.data.api.TokenPairDto.serializer(), raw)
|
||||
}
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count += 1
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ai.hesychia.chat.data.session
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import ai.hesychia.chat.data.api.UserDto
|
||||
import ai.hesychia.chat.ui.theme.ThemeColor
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "hesychia_session",
|
||||
)
|
||||
|
||||
data class SessionSnapshot(
|
||||
/** False until DataStore has emitted. Avoids flashing Sign in over a saved session. */
|
||||
val hydrated: Boolean = false,
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val userId: String? = null,
|
||||
val email: String? = null,
|
||||
val firstName: String? = null,
|
||||
val lastName: String? = null,
|
||||
val isCompanyManager: Boolean = false,
|
||||
val hasSignedTos: Boolean = false,
|
||||
val hasCompany: Boolean = false,
|
||||
val role: String? = null,
|
||||
val darkMode: Boolean? = null,
|
||||
val themeColor: ThemeColor = ThemeColor.Blue,
|
||||
) {
|
||||
val isAuthenticated: Boolean get() = !accessToken.isNullOrBlank()
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SessionRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val accessKey = stringPreferencesKey("access_token")
|
||||
private val refreshKey = stringPreferencesKey("refresh_token")
|
||||
private val userIdKey = stringPreferencesKey("user_id")
|
||||
private val emailKey = stringPreferencesKey("email")
|
||||
private val firstNameKey = stringPreferencesKey("first_name")
|
||||
private val lastNameKey = stringPreferencesKey("last_name")
|
||||
private val managerKey = booleanPreferencesKey("is_company_manager")
|
||||
private val tosKey = booleanPreferencesKey("has_signed_tos")
|
||||
private val companyKey = booleanPreferencesKey("has_company")
|
||||
private val roleKey = stringPreferencesKey("role")
|
||||
private val darkModeKey = booleanPreferencesKey("dark_mode")
|
||||
private val themeColorKey = stringPreferencesKey("theme_color")
|
||||
|
||||
@Volatile
|
||||
var cachedAccessToken: String? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var cachedRefreshToken: String? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
runCatching {
|
||||
val prefs = runBlocking { context.sessionDataStore.data.first() }
|
||||
cachedAccessToken = prefs[accessKey]
|
||||
cachedRefreshToken = prefs[refreshKey]
|
||||
}
|
||||
}
|
||||
|
||||
val session: Flow<SessionSnapshot> = context.sessionDataStore.data.map { prefs ->
|
||||
SessionSnapshot(
|
||||
hydrated = true,
|
||||
accessToken = prefs[accessKey],
|
||||
refreshToken = prefs[refreshKey],
|
||||
userId = prefs[userIdKey],
|
||||
email = prefs[emailKey],
|
||||
firstName = prefs[firstNameKey],
|
||||
lastName = prefs[lastNameKey],
|
||||
isCompanyManager = prefs[managerKey] ?: false,
|
||||
hasSignedTos = prefs[tosKey] ?: false,
|
||||
hasCompany = prefs[companyKey] ?: false,
|
||||
role = prefs[roleKey],
|
||||
darkMode = prefs[darkModeKey],
|
||||
themeColor = prefs[themeColorKey]?.let { runCatching { ThemeColor.valueOf(it) }.getOrNull() }
|
||||
?: ThemeColor.Blue,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun saveTokens(access: String, refresh: String?) {
|
||||
cachedAccessToken = access
|
||||
if (refresh != null) cachedRefreshToken = refresh
|
||||
context.sessionDataStore.edit { prefs ->
|
||||
prefs[accessKey] = access
|
||||
if (refresh != null) prefs[refreshKey] = refresh
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveUser(user: UserDto) {
|
||||
context.sessionDataStore.edit { prefs ->
|
||||
prefs[userIdKey] = user.id.toString()
|
||||
prefs[emailKey] = user.email
|
||||
if (user.first_name != null) prefs[firstNameKey] = user.first_name else prefs.remove(firstNameKey)
|
||||
if (user.last_name != null) prefs[lastNameKey] = user.last_name else prefs.remove(lastNameKey)
|
||||
prefs[managerKey] = user.is_company_manager
|
||||
prefs[tosKey] = user.has_signed_tos
|
||||
prefs[companyKey] = user.company?.id != null
|
||||
if (user.role != null) prefs[roleKey] = user.role else prefs.remove(roleKey)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markTosSigned() {
|
||||
context.sessionDataStore.edit { it[tosKey] = true }
|
||||
}
|
||||
|
||||
/** Drops the session. Theme preferences stay. */
|
||||
suspend fun clearAuth() {
|
||||
cachedAccessToken = null
|
||||
cachedRefreshToken = null
|
||||
context.sessionDataStore.edit { prefs ->
|
||||
prefs.remove(accessKey)
|
||||
prefs.remove(refreshKey)
|
||||
prefs.remove(userIdKey)
|
||||
prefs.remove(emailKey)
|
||||
prefs.remove(firstNameKey)
|
||||
prefs.remove(lastNameKey)
|
||||
prefs.remove(managerKey)
|
||||
prefs.remove(tosKey)
|
||||
prefs.remove(companyKey)
|
||||
prefs.remove(roleKey)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDarkMode(enabled: Boolean) {
|
||||
context.sessionDataStore.edit { it[darkModeKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setThemeColor(color: ThemeColor) {
|
||||
context.sessionDataStore.edit { it[themeColorKey] = color.name }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package ai.hesychia.chat.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import ai.hesychia.chat.ui.account.AccountScreen
|
||||
import ai.hesychia.chat.ui.analytics.AnalyticsScreen
|
||||
import ai.hesychia.chat.ui.auth.PasswordResetConfirmationScreen
|
||||
import ai.hesychia.chat.ui.auth.PasswordResetScreen
|
||||
import ai.hesychia.chat.ui.auth.SetPasswordScreen
|
||||
import ai.hesychia.chat.ui.auth.SignInScreen
|
||||
import ai.hesychia.chat.ui.auth.SignUpScreen
|
||||
import ai.hesychia.chat.ui.auth.TermsScreen
|
||||
import ai.hesychia.chat.ui.chat.ChatScreen
|
||||
import ai.hesychia.chat.ui.documents.DocumentsScreen
|
||||
import ai.hesychia.chat.ui.feedback.FeedbackScreen
|
||||
|
||||
object Routes {
|
||||
const val SignIn = "signin"
|
||||
const val SignUp = "signup"
|
||||
const val PasswordReset = "password_reset"
|
||||
const val PasswordResetConfirmation = "password_reset_confirmation"
|
||||
const val SetPassword = "set_password"
|
||||
const val Terms = "terms"
|
||||
const val Chat = "chat"
|
||||
const val Account = "account"
|
||||
const val Documents = "documents"
|
||||
const val Analytics = "analytics"
|
||||
const val Feedback = "feedback"
|
||||
}
|
||||
|
||||
private val authRoutes = setOf(
|
||||
Routes.SignIn,
|
||||
Routes.SignUp,
|
||||
Routes.PasswordReset,
|
||||
Routes.PasswordResetConfirmation,
|
||||
Routes.SetPassword,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HesychiaNavHost(
|
||||
startDestination: String,
|
||||
hasRag: Boolean,
|
||||
driveJustConnected: Boolean,
|
||||
onSignOut: () -> Unit,
|
||||
onSessionChanged: () -> Unit = {},
|
||||
navController: NavHostController,
|
||||
) {
|
||||
fun go(route: String) {
|
||||
navController.navigate(route) {
|
||||
if (route == Routes.SignIn || route == Routes.Chat || route == Routes.Terms || route == Routes.Account) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = startDestination) {
|
||||
composable(Routes.SignIn) {
|
||||
SignInScreen(
|
||||
onDone = { route ->
|
||||
onSessionChanged()
|
||||
go(route)
|
||||
},
|
||||
onNavigateSignUp = { navController.navigate(Routes.SignUp) },
|
||||
onForgotPassword = { navController.navigate(Routes.PasswordReset) },
|
||||
)
|
||||
}
|
||||
composable(Routes.SignUp) {
|
||||
SignUpScreen(
|
||||
onDone = { route ->
|
||||
onSessionChanged()
|
||||
go(route)
|
||||
},
|
||||
onNavigateSignIn = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.PasswordReset) {
|
||||
PasswordResetScreen(
|
||||
onSent = { navController.navigate(Routes.PasswordResetConfirmation) },
|
||||
onBack = { go(Routes.SignIn) },
|
||||
)
|
||||
}
|
||||
composable(Routes.PasswordResetConfirmation) {
|
||||
PasswordResetConfirmationScreen(onBack = { go(Routes.SignIn) })
|
||||
}
|
||||
composable(
|
||||
route = "${Routes.SetPassword}?slug={slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType; defaultValue = "" }),
|
||||
) { entry ->
|
||||
SetPasswordScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
onDone = { go(Routes.SignIn) },
|
||||
)
|
||||
}
|
||||
composable(Routes.Terms) {
|
||||
TermsScreen(onAccepted = {
|
||||
onSessionChanged()
|
||||
go(Routes.Chat)
|
||||
})
|
||||
}
|
||||
composable(Routes.Chat) {
|
||||
ChatScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut)
|
||||
}
|
||||
composable(Routes.Account) {
|
||||
AccountScreen(hasRag = hasRag, onNavigate = ::go, onSignedOut = { go(Routes.SignIn) })
|
||||
}
|
||||
composable(Routes.Documents) {
|
||||
DocumentsScreen(
|
||||
hasRag = hasRag,
|
||||
driveJustConnected = driveJustConnected,
|
||||
onNavigate = ::go,
|
||||
onSignOut = onSignOut,
|
||||
)
|
||||
}
|
||||
composable(Routes.Analytics) {
|
||||
AnalyticsScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut)
|
||||
}
|
||||
composable(Routes.Feedback) {
|
||||
FeedbackScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package ai.hesychia.chat.ui.account
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import ai.hesychia.chat.ui.shell.AppScaffold
|
||||
import ai.hesychia.chat.ui.theme.ThemeColor
|
||||
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
hasRag: Boolean,
|
||||
onNavigate: (String) -> Unit,
|
||||
onSignedOut: () -> Unit,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var invite by remember { mutableStateOf("") }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
AppScaffold("Account", Routes.Account, hasRag, onNavigate, onSignedOut) { padding ->
|
||||
if (state.loading) {
|
||||
Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@AppScaffold
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Theme", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Dark mode", modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = state.session.darkMode ?: androidx.compose.foundation.isSystemInDarkTheme(),
|
||||
onCheckedChange = viewModel::setDark,
|
||||
)
|
||||
}
|
||||
Text("Palette")
|
||||
ThemeColor.entries.forEach { color ->
|
||||
TextButton(onClick = { viewModel.setColor(color) }) {
|
||||
Text(if (state.session.themeColor == color) "• ${color.name}" else color.name)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Use conversation context", modifier = Modifier.weight(1f))
|
||||
Switch(checked = state.prefs.use_conversation_context, onCheckedChange = viewModel::setContext)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Newest conversations first", modifier = Modifier.weight(1f))
|
||||
Switch(checked = state.prefs.order, onCheckedChange = viewModel::setOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Usage", style = MaterialTheme.typography.titleMedium)
|
||||
val usage = state.subscription?.usage
|
||||
val quota = usage?.prompt_quota
|
||||
Text(
|
||||
if (quota == null) "Prompt quota: —"
|
||||
else "Prompts ${usage.prompts_in_window} / $quota this window",
|
||||
)
|
||||
Text(state.session.email ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Billing", style = MaterialTheme.typography.titleMedium)
|
||||
val sub = state.subscription
|
||||
Text("Plan: ${sub?.plan?.name ?: "None"}")
|
||||
Text("Status: ${sub?.status ?: "—"} · source ${sub?.source ?: "—"}")
|
||||
Text("Subscribe in the Play Store. Stripe checkout stays on the web.")
|
||||
val activity = LocalContext.current as? Activity
|
||||
val accountToken = sub?.store_account_token.orEmpty()
|
||||
state.plans.filter { it.is_selectable && !it.store_product_id.isNullOrBlank() }.forEach { plan ->
|
||||
Button(
|
||||
onClick = {
|
||||
val host = activity ?: return@Button
|
||||
viewModel.purchase(host, plan.store_product_id.orEmpty(), accountToken)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Subscribe to ${plan.name}")
|
||||
}
|
||||
}
|
||||
Text("Invoices: ${state.invoices.size} · Payments: ${state.payments.size}")
|
||||
OutlinedButton(onClick = viewModel::restore, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Restore purchases")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.session.isCompanyManager) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Company accounts", style = MaterialTheme.typography.titleMedium)
|
||||
state.companyUsers.forEach { user ->
|
||||
Text(user.email)
|
||||
Row {
|
||||
TextButton(onClick = {
|
||||
viewModel.updateUser(user.email, "is_active", (!user.is_active).toString())
|
||||
}) { Text(if (user.is_active) "Deactivate" else "Activate") }
|
||||
if (user.email != state.session.email) {
|
||||
TextButton(onClick = {
|
||||
viewModel.updateUser(user.email, "company_manager", (!user.is_company_manager).toString())
|
||||
}) { Text(if (user.is_company_manager) "Remove manager" else "Make manager") }
|
||||
}
|
||||
TextButton(onClick = { viewModel.updateUser(user.email, "delete", "") }) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
OutlinedTextField(invite, { invite = it }, label = { Text("Invite email") }, modifier = Modifier.fillMaxWidth())
|
||||
Button(onClick = { viewModel.invite(invite.trim()); invite = "" }, enabled = invite.contains("@")) {
|
||||
Text("Invite")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Account information", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Account and prompt information will be available soon")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { confirmDelete = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Delete account")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Delete account") },
|
||||
text = { Text("Permanently deactivate your account. Conversations are hidden and you will be signed out. This cannot be undone from the app.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
viewModel.deleteAccount(onSignedOut)
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package ai.hesychia.chat.ui.account
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.hesychia.chat.data.api.CompanyUserUpdateDto
|
||||
import ai.hesychia.chat.data.api.EmailBody
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.InvoiceDto
|
||||
import ai.hesychia.chat.data.api.PaymentDto
|
||||
import ai.hesychia.chat.data.api.PlanDto
|
||||
import ai.hesychia.chat.data.api.PreferencesDto
|
||||
import ai.hesychia.chat.data.api.RefreshTokenBody
|
||||
import ai.hesychia.chat.data.api.SubscriptionDto
|
||||
import ai.hesychia.chat.data.api.UserDto
|
||||
import ai.hesychia.chat.data.api.apiMessage
|
||||
import ai.hesychia.chat.data.billing.StoreBilling
|
||||
import ai.hesychia.chat.data.session.SessionRepository
|
||||
import ai.hesychia.chat.data.session.SessionSnapshot
|
||||
import ai.hesychia.chat.ui.theme.ThemeColor
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AccountUiState(
|
||||
val loading: Boolean = true,
|
||||
val session: SessionSnapshot = SessionSnapshot(hydrated = true),
|
||||
val subscription: SubscriptionDto? = null,
|
||||
val invoices: List<InvoiceDto> = emptyList(),
|
||||
val payments: List<PaymentDto> = emptyList(),
|
||||
val plans: List<PlanDto> = emptyList(),
|
||||
val prefs: PreferencesDto = PreferencesDto(),
|
||||
val companyUsers: List<UserDto> = emptyList(),
|
||||
val notice: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val storeBilling: StoreBilling,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AccountUiState())
|
||||
val state: StateFlow<AccountUiState> = _state
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
val session = sessionRepository.session.first { it.hydrated }
|
||||
val sub = runCatching { api.subscription() }.getOrNull()
|
||||
val invoices = runCatching { api.invoices() }.getOrDefault(emptyList())
|
||||
val payments = runCatching { api.payments() }.getOrDefault(emptyList())
|
||||
val plans = runCatching { api.plans() }.getOrDefault(emptyList())
|
||||
val prefs = runCatching { api.preferences() }.getOrDefault(PreferencesDto())
|
||||
val users = if (session.isCompanyManager) {
|
||||
runCatching { api.companyUsers() }.getOrDefault(emptyList())
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
_state.value = AccountUiState(
|
||||
loading = false,
|
||||
session = session,
|
||||
subscription = sub,
|
||||
invoices = invoices,
|
||||
payments = payments,
|
||||
plans = plans,
|
||||
prefs = prefs,
|
||||
companyUsers = users,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDark(enabled: Boolean) {
|
||||
viewModelScope.launch { sessionRepository.setDarkMode(enabled) }
|
||||
_state.update { it.copy(session = it.session.copy(darkMode = enabled)) }
|
||||
}
|
||||
|
||||
fun setColor(color: ThemeColor) {
|
||||
viewModelScope.launch { sessionRepository.setThemeColor(color) }
|
||||
_state.update { it.copy(session = it.session.copy(themeColor = color)) }
|
||||
}
|
||||
|
||||
fun setContext(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching { api.savePreferences(PreferencesDto(use_conversation_context = enabled)) }
|
||||
.onSuccess { saved -> _state.update { it.copy(prefs = saved) } }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save preferences")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun setOrder(newestFirst: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching { api.savePreferences(PreferencesDto(order = newestFirst)) }
|
||||
.onSuccess { saved -> _state.update { it.copy(prefs = saved) } }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save preferences")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun invite(email: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { api.invite(EmailBody(email)) }
|
||||
.onSuccess {
|
||||
_state.update { it.copy(notice = "Invite sent") }
|
||||
refresh()
|
||||
}
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not invite that user")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUser(email: String, field: String, value: String) {
|
||||
viewModelScope.launch {
|
||||
val call = if (field == "delete") {
|
||||
runCatching { api.deleteCompanyUser(EmailBody(email)) }
|
||||
} else {
|
||||
runCatching { api.updateCompanyUser(CompanyUserUpdateDto(email, field, value)) }
|
||||
}
|
||||
call.onSuccess { refresh() }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not update that user")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun purchase(activity: Activity, productId: String, accountToken: String) {
|
||||
storeBilling.purchase(
|
||||
activity = activity,
|
||||
productId = productId,
|
||||
accountToken = accountToken,
|
||||
onError = { message -> _state.update { it.copy(error = message, notice = null) } },
|
||||
onDone = {
|
||||
_state.update { it.copy(notice = "Purchase confirmed", error = null) }
|
||||
refresh()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
viewModelScope.launch {
|
||||
runCatching { storeBilling.restore() }
|
||||
.onSuccess {
|
||||
_state.update { it.copy(notice = "Purchases restored") }
|
||||
refresh()
|
||||
}
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not restore purchases")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAccount(onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val refresh = sessionRepository.session.first().refreshToken
|
||||
runCatching {
|
||||
api.deleteUser(RefreshTokenBody(refresh.orEmpty()))
|
||||
if (!refresh.isNullOrBlank()) runCatching { api.blacklist(RefreshTokenBody(refresh)) }
|
||||
storeBilling.logOut()
|
||||
sessionRepository.clearAuth()
|
||||
}.onSuccess { onDone() }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not delete account. Try again.")) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package ai.hesychia.chat.ui.analytics
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.hesychia.chat.data.api.HeatmapDto
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.MonthSeriesDto
|
||||
import ai.hesychia.chat.data.session.SessionRepository
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import ai.hesychia.chat.ui.shell.AppScaffold
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AnalyticsUiState(
|
||||
val loading: Boolean = true,
|
||||
val showCompany: Boolean = false,
|
||||
val isManager: Boolean = false,
|
||||
val showOps: Boolean = false,
|
||||
val heatmap: HeatmapDto? = null,
|
||||
val prompts: List<MonthSeriesDto> = emptyList(),
|
||||
val conversations: List<MonthSeriesDto> = emptyList(),
|
||||
val company: List<MonthSeriesDto> = emptyList(),
|
||||
val admin: List<MonthSeriesDto> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AnalyticsViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
private val sessionRepository: SessionRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AnalyticsUiState())
|
||||
val state: StateFlow<AnalyticsUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val session = sessionRepository.session.first { it.hydrated }
|
||||
val showOps = isOpsAdmin(session.email, session.role)
|
||||
val showCompany = session.isCompanyManager || session.hasCompany
|
||||
val tz = TimeZone.getDefault().id
|
||||
_state.value = AnalyticsUiState(
|
||||
loading = false,
|
||||
showCompany = showCompany,
|
||||
isManager = session.isCompanyManager,
|
||||
showOps = showOps,
|
||||
heatmap = runCatching { api.promptHeatmap(tz) }.getOrNull(),
|
||||
prompts = runCatching { api.userPrompts() }.getOrDefault(emptyList()),
|
||||
conversations = runCatching { api.userConversations() }.getOrDefault(emptyList()),
|
||||
company = if (session.isCompanyManager) runCatching { api.companyUsage() }.getOrDefault(emptyList()) else emptyList(),
|
||||
admin = if (showOps) runCatching { api.adminAnalytics() }.getOrDefault(emptyList()) else emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isOpsAdmin(email: String?, role: String?): Boolean {
|
||||
if (role != null && role.lowercase() in setOf("admin", "ops", "staff")) return true
|
||||
return email == "ryan+admin@aimloperations.com"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnalyticsScreen(
|
||||
hasRag: Boolean,
|
||||
onNavigate: (String) -> Unit,
|
||||
onSignOut: () -> Unit,
|
||||
viewModel: AnalyticsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
AppScaffold("Analytics", Routes.Analytics, hasRag, onNavigate, onSignOut) { padding ->
|
||||
if (state.loading) {
|
||||
Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@AppScaffold
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Your activity", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Personal prompt timing and volume. Other users' private messages are never shown here.")
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Prompt timing", style = MaterialTheme.typography.titleMedium)
|
||||
val heat = state.heatmap
|
||||
if (heat == null) {
|
||||
Text("No timing data.")
|
||||
} else {
|
||||
Text("${heat.total} prompts · busiest ${heat.most_active_day ?: "—"} ${heat.most_active_hour ?: ""}:00")
|
||||
heat.peak_cell?.let { Text("Peak ${it.day} ${it.hour}:00 · ${it.count}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
SeriesCard("Conversation volume", state.conversations) { "${it.month}: ${it.all}" }
|
||||
SeriesCard("Prompt volume", state.prompts) { "${it.month}: you ${it.you} · others ${it.others} · all ${it.all}" }
|
||||
if (state.showCompany) {
|
||||
Text("Company", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Aggregated seat and usage trends for your workspace. No message content.")
|
||||
if (state.isManager) {
|
||||
SeriesCard("Team seat activity", state.company) { "${it.month}: used ${it.used} · not used ${it.not_used}" }
|
||||
} else {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Detailed team seat charts are available to company managers.",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.showOps) {
|
||||
Text("Operations", style = MaterialTheme.typography.titleLarge)
|
||||
SeriesCard("Response times", state.admin) {
|
||||
"${it.month}: avg ${it.avg ?: "—"} · median ${it.median ?: "—"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesCard(title: String, rows: List<MonthSeriesDto>, line: (MonthSeriesDto) -> String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
if (rows.isEmpty()) {
|
||||
Text("No data.")
|
||||
} else {
|
||||
val max = rows.maxOf { maxOf(it.you, it.others, it.all, it.used, it.not_used, (it.avg ?: 0.0).toInt()) }.coerceAtLeast(1)
|
||||
rows.forEach { row ->
|
||||
Text(line(row), style = MaterialTheme.typography.bodySmall)
|
||||
LinearProgressIndicator(
|
||||
progress = { row.all.coerceAtLeast(row.used).toFloat() / max },
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package ai.hesychia.chat.ui.auth
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.LaunchedEffect
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ai.hesychia.chat.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SignInScreen(
|
||||
onDone: (String) -> Unit,
|
||||
onNavigateSignUp: () -> Unit,
|
||||
onForgotPassword: () -> Unit,
|
||||
viewModel: SignInViewModel = hiltViewModel(),
|
||||
) {
|
||||
val public by viewModel.publicState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
AuthColumn {
|
||||
Text("Hesychia.ai", style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Sign In", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email Address") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
viewModel.signIn(email.trim(), password)
|
||||
.onSuccess(onDone)
|
||||
.onFailure { error = it.userMessage("Error retrieving account. Try again") }
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
enabled = !loading && email.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (loading) CircularProgressIndicator(Modifier.height(20.dp)) else Text("Sign In")
|
||||
}
|
||||
TextButton(onClick = onForgotPassword) { Text("Forgot Password?") }
|
||||
if (public.registrationEnabled) {
|
||||
TextButton(onClick = onNavigateSignUp) { Text("Don't have an account? Sign up") }
|
||||
if (public.google) {
|
||||
OutlinedButton(
|
||||
onClick = { openTab(context, viewModel.oauthUrl("google", "login")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Continue with Google") }
|
||||
}
|
||||
if (public.microsoft) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { openTab(context, viewModel.oauthUrl("microsoft", "login")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Continue with Microsoft") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignUpScreen(
|
||||
onDone: (String) -> Unit,
|
||||
onNavigateSignIn: () -> Unit,
|
||||
viewModel: SignUpViewModel = hiltViewModel(),
|
||||
) {
|
||||
val public by viewModel.publicState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var email by remember { mutableStateOf("") }
|
||||
var first by remember { mutableStateOf("") }
|
||||
var last by remember { mutableStateOf("") }
|
||||
var company by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirm by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (public.loadingSettings) {
|
||||
AuthColumn { CircularProgressIndicator() }
|
||||
return
|
||||
}
|
||||
if (!public.registrationEnabled) {
|
||||
AuthColumn {
|
||||
Text("Sign up", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Self-serve registration is not available right now.")
|
||||
TextButton(onClick = onNavigateSignIn) { Text("Already have an account? Sign in") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
AuthColumn {
|
||||
Text("Sign up", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"Create an account, then subscribe with Play billing.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Email Address") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(first, { first = it }, label = { Text("First name (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(last, { last = it }, label = { Text("Last name (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(company, { company = it }, label = { Text("Company / workspace (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(password, { password = it }, label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(confirm, { confirm = it }, label = { Text("Confirm password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (password.length < 8) {
|
||||
error = "Password must be at least 8 characters"
|
||||
return@Button
|
||||
}
|
||||
if (password != confirm) {
|
||||
error = "Passwords must match"
|
||||
return@Button
|
||||
}
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
viewModel.signUp(email.trim(), password, first.trim(), last.trim(), company.trim())
|
||||
.onSuccess(onDone)
|
||||
.onFailure { error = it.userMessage("Could not create account. Try again.") }
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
enabled = !loading && email.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (loading) CircularProgressIndicator(Modifier.height(20.dp)) else Text("Sign up")
|
||||
}
|
||||
if (public.google) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { openTab(context, viewModel.oauthUrl("google")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Sign up with Google") }
|
||||
}
|
||||
if (public.microsoft) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { openTab(context, viewModel.oauthUrl("microsoft")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Sign up with Microsoft") }
|
||||
}
|
||||
TextButton(onClick = onNavigateSignIn) { Text("Already have an account? Sign in") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PasswordResetScreen(
|
||||
onSent: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: PasswordResetViewModel = hiltViewModel(),
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AuthColumn {
|
||||
Text("Reset password", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"Enter your email and we will send a link to set a new password.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
email,
|
||||
{ email = it },
|
||||
label = { Text("Email Address") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
viewModel.requestReset(email.trim())
|
||||
.onSuccess { onSent() }
|
||||
.onFailure { error = it.userMessage("Unable to reset password. Please try again.") }
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
enabled = !loading && email.contains("@"),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (loading) "Sending…" else "Reset password") }
|
||||
TextButton(onClick = onBack) { Text("Back to sign in") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PasswordResetConfirmationScreen(onBack: () -> Unit) {
|
||||
AuthColumn {
|
||||
Text("Check your email", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("If an account exists for that address, we sent a link to set a new password.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton(onClick = onBack) { Text("Back to sign in") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SetPasswordScreen(
|
||||
slug: String,
|
||||
onDone: () -> Unit,
|
||||
viewModel: SetPasswordViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirm by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(slug) { viewModel.check(slug) }
|
||||
if (state.checking) {
|
||||
AuthColumn { CircularProgressIndicator() }
|
||||
return
|
||||
}
|
||||
if (!state.linkValid) {
|
||||
AuthColumn {
|
||||
Text("This password link is invalid or expired.")
|
||||
TextButton(onClick = onDone) { Text("Back to sign in") }
|
||||
}
|
||||
return
|
||||
}
|
||||
AuthColumn {
|
||||
Text("Set a new password", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(password, { password = it }, label = { Text("New password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(confirm, { confirm = it }, label = { Text("Confirm password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val problem = setPasswordProblem(password, confirm)
|
||||
if (problem != null) {
|
||||
error = problem
|
||||
return@Button
|
||||
}
|
||||
scope.launch {
|
||||
loading = true
|
||||
viewModel.submit(slug, password)
|
||||
.onSuccess { onDone() }
|
||||
.onFailure { error = it.userMessage("Could not set password.") }
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
enabled = !loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Save password") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TermsScreen(
|
||||
onAccepted: () -> Unit,
|
||||
viewModel: TermsViewModel = hiltViewModel(),
|
||||
) {
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AuthColumn {
|
||||
Text("Terms of Service", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Acknowledge the Hesychia terms to open the dashboard. The public marketing site is not part of this app.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
loading = true
|
||||
viewModel.acknowledge()
|
||||
.onSuccess { onAccepted() }
|
||||
.onFailure { error = it.userMessage("Could not save acknowledgement.") }
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
enabled = !loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (loading) "Saving…" else "I agree") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPasswordProblem(password: String, confirm: String): String? = when {
|
||||
password.length < 8 -> "Must be at least 8 characters"
|
||||
password.none { it.isDigit() } -> "Must include a number"
|
||||
password.none { !it.isLetterOrDigit() } -> "Must include a special character"
|
||||
password != confirm -> "Passwords must match"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthColumn(content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 480.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.hesychia_mark),
|
||||
contentDescription = "Hesychia Logo",
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openTab(context: android.content.Context, url: String) {
|
||||
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package ai.hesychia.chat.ui.auth
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.hesychia.chat.BuildConfig
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.LoginRequestDto
|
||||
import ai.hesychia.chat.data.api.PasswordResetRequestDto
|
||||
import ai.hesychia.chat.data.api.PublicSettingsDto
|
||||
import ai.hesychia.chat.data.api.SetPasswordRequestDto
|
||||
import ai.hesychia.chat.data.api.SignUpRequestDto
|
||||
import ai.hesychia.chat.data.api.apiMessage
|
||||
import ai.hesychia.chat.data.billing.StoreBilling
|
||||
import ai.hesychia.chat.data.session.SessionRepository
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PublicAuthState(
|
||||
val loadingSettings: Boolean = true,
|
||||
val registrationEnabled: Boolean = false,
|
||||
val google: Boolean = false,
|
||||
val microsoft: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SignInViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val storeBilling: StoreBilling,
|
||||
) : ViewModel() {
|
||||
private val _public = MutableStateFlow(PublicAuthState())
|
||||
val publicState: StateFlow<PublicAuthState> = _public
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loadSettings() }
|
||||
}
|
||||
|
||||
private suspend fun loadSettings() {
|
||||
val settings = runCatching { api.publicSettings() }.getOrElse { PublicSettingsDto() }
|
||||
_public.value = PublicAuthState(
|
||||
loadingSettings = false,
|
||||
registrationEnabled = settings.enable_account_registration,
|
||||
google = settings.oauth?.google == true,
|
||||
microsoft = settings.oauth?.microsoft == true,
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns the route to open after a successful password sign-in. */
|
||||
suspend fun signIn(email: String, password: String): Result<String> = runCatching {
|
||||
Log.d("AuthViewModel", "Attempting login for $email")
|
||||
val tokens = api.login(LoginRequestDto(username = email, password = password))
|
||||
Log.d("AuthViewModel", "Login tokens received: access token len=${tokens.access.length}")
|
||||
sessionRepository.saveTokens(tokens.access, tokens.refresh)
|
||||
val user = api.currentUser()
|
||||
Log.d("AuthViewModel", "Current user fetched: id=${user.id}, email=${user.email}")
|
||||
sessionRepository.saveUser(user)
|
||||
runCatching { storeBilling.logIn(user.id.toString()) }
|
||||
if (user.has_signed_tos) Routes.Chat else Routes.Terms
|
||||
}.onFailure { e ->
|
||||
Log.e("AuthViewModel", "SignIn failed: ${e.message}", e)
|
||||
}
|
||||
|
||||
fun oauthUrl(provider: String, intent: String): String {
|
||||
val base = BuildConfig.REST_API_BASE_URL.trimEnd('/')
|
||||
return "$base/auth/oauth/$provider/start/?intent=$intent"
|
||||
}
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class SignUpViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val storeBilling: StoreBilling,
|
||||
) : ViewModel() {
|
||||
private val _public = MutableStateFlow(PublicAuthState())
|
||||
val publicState: StateFlow<PublicAuthState> = _public
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val settings = runCatching { api.publicSettings() }.getOrElse { PublicSettingsDto() }
|
||||
_public.value = PublicAuthState(
|
||||
loadingSettings = false,
|
||||
registrationEnabled = settings.enable_account_registration,
|
||||
google = settings.oauth?.google == true,
|
||||
microsoft = settings.oauth?.microsoft == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun signUp(
|
||||
email: String,
|
||||
password: String,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
company: String,
|
||||
): Result<String> = runCatching {
|
||||
Log.d("AuthViewModel", "Attempting signup for $email")
|
||||
val created = api.createUser(
|
||||
SignUpRequestDto(
|
||||
email = email,
|
||||
password = password,
|
||||
first_name = firstName,
|
||||
last_name = lastName,
|
||||
company_name = company,
|
||||
),
|
||||
)
|
||||
Log.d("AuthViewModel", "Signup user created: access token len=${created.access.length}")
|
||||
sessionRepository.saveTokens(created.access, created.refresh)
|
||||
val user = api.currentUser()
|
||||
Log.d("AuthViewModel", "Current user fetched after signup: id=${user.id}, email=${user.email}")
|
||||
sessionRepository.saveUser(user)
|
||||
runCatching { storeBilling.logIn(user.id.toString()) }
|
||||
if (created.needs_checkout) Routes.Account else if (user.has_signed_tos) Routes.Chat else Routes.Terms
|
||||
}.onFailure { e ->
|
||||
Log.e("AuthViewModel", "SignUp failed: ${e.message}", e)
|
||||
}
|
||||
|
||||
fun oauthUrl(provider: String): String {
|
||||
val base = BuildConfig.REST_API_BASE_URL.trimEnd('/')
|
||||
return "$base/auth/oauth/$provider/start/?intent=signup"
|
||||
}
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class PasswordResetViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
) : ViewModel() {
|
||||
suspend fun requestReset(email: String): Result<Unit> = runCatching {
|
||||
api.resetPassword(PasswordResetRequestDto(email = email, recaptchaToken = ""))
|
||||
}
|
||||
}
|
||||
|
||||
data class SetPasswordState(
|
||||
val checking: Boolean = true,
|
||||
val linkValid: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SetPasswordViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SetPasswordState())
|
||||
val state: StateFlow<SetPasswordState> = _state
|
||||
|
||||
fun check(slug: String) {
|
||||
viewModelScope.launch {
|
||||
if (slug.isBlank()) {
|
||||
_state.value = SetPasswordState(checking = false, linkValid = false)
|
||||
return@launch
|
||||
}
|
||||
val ok = runCatching { api.checkSetPassword(slug) }.isSuccess
|
||||
_state.value = SetPasswordState(checking = false, linkValid = ok)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun submit(slug: String, password: String): Result<Unit> = runCatching {
|
||||
api.setPassword(slug, SetPasswordRequestDto(password1 = password, password2 = password))
|
||||
}
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class TermsViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
private val sessionRepository: SessionRepository,
|
||||
) : ViewModel() {
|
||||
suspend fun acknowledge(): Result<Unit> = runCatching {
|
||||
api.acknowledgeTos()
|
||||
sessionRepository.markTosSigned()
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.userMessage(fallback: String): String = apiMessage(fallback)
|
||||
@@ -0,0 +1,226 @@
|
||||
package ai.hesychia.chat.ui.chat
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.ThumbDown
|
||||
import androidx.compose.material.icons.filled.ThumbUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import android.util.Base64
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import ai.hesychia.chat.ui.shell.AppScaffold
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
hasRag: Boolean,
|
||||
onNavigate: (String) -> Unit,
|
||||
onSignOut: () -> Unit,
|
||||
viewModel: ChatViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var draft by remember { mutableStateOf("") }
|
||||
var attachmentName by remember { mutableStateOf<String?>(null) }
|
||||
var attachmentBytes by remember { mutableStateOf<ByteArray?>(null) }
|
||||
var attachmentType by remember { mutableStateOf<String?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
attachmentBytes = bytes
|
||||
attachmentType = context.contentResolver.getType(uri)
|
||||
attachmentName = uri.lastPathSegment
|
||||
}
|
||||
|
||||
LaunchedEffect(state.messages.size, state.messages.lastOrNull()?.text) {
|
||||
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
title = "Dashboard",
|
||||
currentRoute = Routes.Chat,
|
||||
hasRag = hasRag,
|
||||
onNavigate = onNavigate,
|
||||
onSignOut = onSignOut,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
ConversationStrip(
|
||||
rows = state.conversations,
|
||||
selectedId = state.selectedId,
|
||||
onSelect = viewModel::select,
|
||||
onNew = viewModel::newChat,
|
||||
onDelete = viewModel::deleteSelected,
|
||||
)
|
||||
if (state.connectionLabel.isNotBlank()) {
|
||||
Text(state.connectionLabel, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
if (state.interrupted) {
|
||||
Text(
|
||||
"Last response was interrupted. You can re-send your message.",
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
state.activityLabel?.let { Text(it, style = MaterialTheme.typography.labelMedium) }
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (state.messages.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Select a conversation or start a new one.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(state.messages, key = { "${it.id}-${it.userCreated}-${it.text.take(12)}-${it.streaming}" }) { msg ->
|
||||
MessageBubble(msg, onRate = { viewModel.rate(msg.id, it) })
|
||||
}
|
||||
}
|
||||
attachmentName?.let { Text("Attached: $it", style = MaterialTheme.typography.labelSmall) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = { picker.launch("*/*") }) {
|
||||
Icon(Icons.Default.AttachFile, contentDescription = "Attach")
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val body = state.messages.joinToString("\n\n") { msg ->
|
||||
val who = if (msg.userCreated) "You" else "Hesychia"
|
||||
"$who: ${msg.text}"
|
||||
}
|
||||
val share = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, body)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(share, "Export conversation"))
|
||||
}) {
|
||||
Icon(Icons.Default.Share, contentDescription = "Export")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Type a message…") },
|
||||
maxLines = 4,
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val file = attachmentBytes?.let { Base64.encodeToString(it, Base64.NO_WRAP) }
|
||||
viewModel.send(draft, file, attachmentType)
|
||||
draft = ""
|
||||
attachmentBytes = null
|
||||
attachmentName = null
|
||||
attachmentType = null
|
||||
},
|
||||
enabled = draft.isNotBlank() && state.connected,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationStrip(
|
||||
rows: List<ConversationRow>,
|
||||
selectedId: Int?,
|
||||
onSelect: (Int) -> Unit,
|
||||
onNew: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
androidx.compose.material3.TextButton(onClick = onNew) { Text("+ New chat") }
|
||||
if (selectedId != null) {
|
||||
IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Delete conversation") }
|
||||
}
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
Text("No conversations yet. Start with + New chat.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(rows, key = { it.id }) { row ->
|
||||
Surface(
|
||||
onClick = { onSelect(row.id) },
|
||||
color = if (row.id == selectedId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(row.title, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(msg: ChatMessage, onRate: (String?) -> Unit) {
|
||||
val align = if (msg.userCreated) Alignment.End else Alignment.Start
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) {
|
||||
Surface(
|
||||
color = if (msg.userCreated) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = if (msg.streaming && msg.text.isEmpty()) "…" else msg.text,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
msg.citations.forEach { cite ->
|
||||
Text(cite, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (!msg.userCreated && msg.id != null && !msg.streaming) {
|
||||
Row {
|
||||
IconButton(onClick = { onRate(if (msg.rating == "up") null else "up") }) {
|
||||
Icon(Icons.Default.ThumbUp, contentDescription = "Up")
|
||||
}
|
||||
IconButton(onClick = { onRate(if (msg.rating == "down") null else "down") }) {
|
||||
Icon(Icons.Default.ThumbDown, contentDescription = "Down")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package ai.hesychia.chat.ui.chat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.hesychia.chat.BuildConfig
|
||||
import ai.hesychia.chat.data.api.ConversationIdBody
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.PromptDto
|
||||
import ai.hesychia.chat.data.api.PromptFeedbackRequestDto
|
||||
import ai.hesychia.chat.data.api.apiMessage
|
||||
import ai.hesychia.chat.data.chat.ChatSocket
|
||||
import ai.hesychia.chat.data.chat.IncomingFrame
|
||||
import ai.hesychia.chat.data.chat.SocketStatus
|
||||
import ai.hesychia.chat.data.chat.classifyIncoming
|
||||
import ai.hesychia.chat.data.chat.conversationIdOrNull
|
||||
import ai.hesychia.chat.data.session.SessionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
data class ChatMessage(
|
||||
val id: Int? = null,
|
||||
val text: String,
|
||||
val userCreated: Boolean,
|
||||
val streaming: Boolean = false,
|
||||
val citations: List<String> = emptyList(),
|
||||
val rating: String? = null,
|
||||
)
|
||||
|
||||
data class ConversationRow(val id: Int, val title: String)
|
||||
|
||||
data class ChatUiState(
|
||||
val conversations: List<ConversationRow> = emptyList(),
|
||||
val selectedId: Int? = null,
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val connected: Boolean = false,
|
||||
val connectionLabel: String = "Connecting to chat…",
|
||||
val activityLabel: String? = null,
|
||||
val interrupted: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
private val socket: ChatSocket,
|
||||
private val sessionRepository: SessionRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(ChatUiState())
|
||||
val state: StateFlow<ChatUiState> = _state
|
||||
|
||||
private var phase = 0
|
||||
private var heartbeat: Job? = null
|
||||
private var refetch: Job? = null
|
||||
|
||||
init {
|
||||
socket.onStatus = { status ->
|
||||
viewModelScope.launch {
|
||||
when (status) {
|
||||
SocketStatus.Connecting -> _state.update {
|
||||
it.copy(connected = false, connectionLabel = "Connecting to chat…")
|
||||
}
|
||||
SocketStatus.Connected -> _state.update {
|
||||
it.copy(connected = true, connectionLabel = "")
|
||||
}
|
||||
SocketStatus.Disconnected -> {
|
||||
val wasStreaming = phase == 2
|
||||
phase = 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
connected = false,
|
||||
connectionLabel = "Connection to the LLM is not active",
|
||||
interrupted = it.interrupted || wasStreaming,
|
||||
activityLabel = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
socket.onText = { raw -> viewModelScope.launch { onFrame(raw) } }
|
||||
socket.connect(BuildConfig.WS_API_BASE_URL)
|
||||
heartbeat = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
delay(25_000)
|
||||
val email = sessionRepository.session.first().email
|
||||
socket.ping(email)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch { reloadConversations() }
|
||||
}
|
||||
|
||||
fun newChat() {
|
||||
phase = 0
|
||||
_state.update { it.copy(selectedId = null, messages = emptyList(), interrupted = false, error = null) }
|
||||
}
|
||||
|
||||
fun select(id: Int) {
|
||||
phase = 0
|
||||
_state.update { it.copy(selectedId = id, interrupted = false, error = null) }
|
||||
viewModelScope.launch { loadDetails(id) }
|
||||
}
|
||||
|
||||
fun deleteSelected() {
|
||||
val id = _state.value.selectedId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { api.deleteConversation(ConversationIdBody(id)) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
conversations = it.conversations.filterNot { row -> row.id == id },
|
||||
selectedId = null,
|
||||
messages = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun send(text: String, fileBase64: String? = null, fileType: String? = null) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty() || !_state.value.connected) return
|
||||
val selected = _state.value.selectedId
|
||||
viewModelScope.launch {
|
||||
val session = sessionRepository.session.first()
|
||||
val payload = buildJsonObject {
|
||||
put("message", trimmed)
|
||||
if (selected == null) put("conversation_id", JsonNull) else put("conversation_id", selected)
|
||||
put("email", session.email ?: "")
|
||||
put("token", session.accessToken ?: "")
|
||||
if (fileBase64 == null) put("file", JsonNull) else put("file", fileBase64)
|
||||
if (fileType == null) put("fileType", JsonNull) else put("fileType", fileType)
|
||||
put("modelName", "THINKING")
|
||||
}.toString()
|
||||
val ok = socket.send(payload)
|
||||
if (!ok) {
|
||||
_state.update { it.copy(error = "Connection to the LLM is not active") }
|
||||
return@launch
|
||||
}
|
||||
phase = 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
interrupted = false,
|
||||
error = null,
|
||||
activityLabel = null,
|
||||
messages = it.messages + ChatMessage(text = trimmed, userCreated = true) +
|
||||
ChatMessage(text = "", userCreated = false, streaming = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rate(promptId: Int?, rating: String?) {
|
||||
if (promptId == null) return
|
||||
viewModelScope.launch {
|
||||
val result = if (rating == null) {
|
||||
runCatching { api.clearPromptFeedback(promptId) }
|
||||
} else {
|
||||
runCatching { api.upsertPromptFeedback(PromptFeedbackRequestDto(promptId, rating)) }
|
||||
}
|
||||
result.onSuccess {
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
messages = state.messages.map { msg ->
|
||||
if (msg.id == promptId) msg.copy(rating = rating) else msg
|
||||
},
|
||||
)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
_state.update { it.copy(error = err.apiMessage("Could not save rating")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onFrame(raw: String) {
|
||||
when (val frame = classifyIncoming(raw)) {
|
||||
IncomingFrame.ConversationIdMarker -> phase = 1
|
||||
IncomingFrame.StreamStart -> phase = 2
|
||||
IncomingFrame.StreamEnd -> {
|
||||
phase = 0
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
activityLabel = null,
|
||||
interrupted = false,
|
||||
messages = state.messages.map { msg ->
|
||||
if (msg.streaming) msg.copy(streaming = false) else msg
|
||||
},
|
||||
)
|
||||
}
|
||||
scheduleRefetch()
|
||||
}
|
||||
is IncomingFrame.Text -> when (phase) {
|
||||
1 -> {
|
||||
val id = conversationIdOrNull(frame.value)
|
||||
if (id != null && _state.value.selectedId == null) {
|
||||
_state.update { it.copy(selectedId = id) }
|
||||
reloadConversations()
|
||||
}
|
||||
}
|
||||
2 -> appendToken(frame.value)
|
||||
else -> {
|
||||
if (frame.value.startsWith("{")) return
|
||||
}
|
||||
}
|
||||
is IncomingFrame.Status -> {
|
||||
val label = if (frame.stage == "writing") null else frame.label
|
||||
_state.update { it.copy(activityLabel = label ?: frame.detail) }
|
||||
}
|
||||
is IncomingFrame.Citations -> {
|
||||
_state.update { state ->
|
||||
val msgs = state.messages.toMutableList()
|
||||
val index = msgs.indexOfLast { !it.userCreated }
|
||||
if (index >= 0) msgs[index] = msgs[index].copy(citations = frame.titles)
|
||||
state.copy(messages = msgs)
|
||||
}
|
||||
}
|
||||
is IncomingFrame.Error -> {
|
||||
phase = 0
|
||||
_state.update { state ->
|
||||
val msgs = state.messages.toMutableList()
|
||||
val index = msgs.indexOfLast { !it.userCreated }
|
||||
if (index >= 0) {
|
||||
msgs[index] = msgs[index].copy(text = frame.content, streaming = false)
|
||||
}
|
||||
state.copy(messages = msgs, error = frame.content, activityLabel = null)
|
||||
}
|
||||
}
|
||||
IncomingFrame.Ignore -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendToken(token: String) {
|
||||
_state.update { state ->
|
||||
val msgs = state.messages.toMutableList()
|
||||
val index = msgs.indexOfLast { it.streaming || !it.userCreated }
|
||||
if (index >= 0) {
|
||||
msgs[index] = msgs[index].copy(text = msgs[index].text + token, streaming = true)
|
||||
}
|
||||
state.copy(messages = msgs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleRefetch() {
|
||||
val id = _state.value.selectedId ?: return
|
||||
refetch?.cancel()
|
||||
refetch = viewModelScope.launch {
|
||||
delay(800)
|
||||
loadDetails(id)
|
||||
reloadConversations()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadConversations() {
|
||||
val rows = runCatching { api.conversations() }.getOrDefault(emptyList())
|
||||
.map { ConversationRow(it.id, it.title?.ifBlank { null } ?: "New conversation") }
|
||||
_state.update { it.copy(conversations = rows) }
|
||||
}
|
||||
|
||||
private suspend fun loadDetails(id: Int) {
|
||||
val prompts = runCatching { api.conversationDetails(id) }.getOrElse { err ->
|
||||
_state.update { it.copy(error = err.apiMessage("Could not load conversation")) }
|
||||
return
|
||||
}
|
||||
val messages = prompts.map { it.toMessage() }.toMutableList()
|
||||
if (messages.size == 1) {
|
||||
messages += ChatMessage(text = "", userCreated = false, streaming = true)
|
||||
}
|
||||
_state.update { it.copy(messages = messages, selectedId = id) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
heartbeat?.cancel()
|
||||
socket.onText = null
|
||||
socket.onStatus = null
|
||||
socket.close()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PromptDto.toMessage() = ChatMessage(
|
||||
id = id,
|
||||
text = message,
|
||||
userCreated = user_created,
|
||||
citations = citations.map { cite -> cite.title.ifBlank { cite.url } },
|
||||
rating = feedback?.rating,
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
package ai.hesychia.chat.ui.documents
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.hesychia.chat.BuildConfig
|
||||
import ai.hesychia.chat.data.api.DocumentDto
|
||||
import ai.hesychia.chat.data.api.DriveConnectionDto
|
||||
import ai.hesychia.chat.data.api.DriveResourceSelectionDto
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.apiMessage
|
||||
import ai.hesychia.chat.data.session.SessionRepository
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import ai.hesychia.chat.ui.shell.AppScaffold
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
data class DocumentsUiState(
|
||||
val loading: Boolean = true,
|
||||
val hasRag: Boolean = false,
|
||||
val hasCompany: Boolean = false,
|
||||
val isManager: Boolean = false,
|
||||
val scope: String = "personal",
|
||||
val search: String = "",
|
||||
val ordering: String = "-created",
|
||||
val page: Int = 1,
|
||||
val total: Int = 0,
|
||||
val documents: List<DocumentDto> = emptyList(),
|
||||
val drives: List<DriveConnectionDto> = emptyList(),
|
||||
val driveBanner: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DocumentsViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
private val sessionRepository: SessionRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(DocumentsUiState())
|
||||
val state: StateFlow<DocumentsUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val session = sessionRepository.session.first { it.hydrated }
|
||||
val sub = runCatching { api.subscription() }.getOrNull()
|
||||
val scope = if (session.hasCompany) "company" else "personal"
|
||||
_state.update {
|
||||
it.copy(
|
||||
hasRag = sub?.hasRag == true,
|
||||
hasCompany = session.hasCompany,
|
||||
isManager = session.isCompanyManager,
|
||||
scope = scope,
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
if (sub?.hasRag == true) reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun showDriveBanner() {
|
||||
_state.update { it.copy(driveBanner = true) }
|
||||
}
|
||||
|
||||
fun setScope(scope: String) {
|
||||
_state.update { it.copy(scope = scope, page = 1) }
|
||||
viewModelScope.launch { reload() }
|
||||
}
|
||||
|
||||
fun setSearch(value: String) {
|
||||
_state.update { it.copy(search = value, page = 1) }
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(300)
|
||||
if (_state.value.search == value) reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSort() {
|
||||
val next = if (_state.value.ordering == "-created") "created" else "-created"
|
||||
_state.update { it.copy(ordering = next, page = 1) }
|
||||
viewModelScope.launch { reload() }
|
||||
}
|
||||
|
||||
fun setPage(page: Int) {
|
||||
_state.update { it.copy(page = page) }
|
||||
viewModelScope.launch { reload() }
|
||||
}
|
||||
|
||||
fun toggleActive(doc: DocumentDto, active: Boolean) {
|
||||
_state.update { state ->
|
||||
state.copy(documents = state.documents.map { if (it.id == doc.id) it.copy(active = active) else it })
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { api.setDocumentActive(doc.id, ai.hesychia.chat.data.api.ActiveBody(active)) }
|
||||
.onFailure {
|
||||
_state.update { state ->
|
||||
state.copy(documents = state.documents.map { item ->
|
||||
if (item.id == doc.id) item.copy(active = !active) else item
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(name: String, bytes: ByteArray, mime: String?) {
|
||||
viewModelScope.launch {
|
||||
val body = bytes.toRequestBody((mime ?: "application/octet-stream").toMediaTypeOrNull())
|
||||
val part = MultipartBody.Part.createFormData("file", name, body)
|
||||
runCatching { api.uploadDocument(_state.value.scope, part) }
|
||||
.onSuccess { reload() }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Upload failed")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect(id: Int) {
|
||||
viewModelScope.launch {
|
||||
runCatching { api.disconnectDrive(id) }
|
||||
.onSuccess { reload() }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not disconnect this drive")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(id: Int) {
|
||||
viewModelScope.launch {
|
||||
runCatching { api.syncDrive(id) }
|
||||
.onSuccess { reload() }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Drive sync failed")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun saveResources(id: Int, raw: String) {
|
||||
val ids = raw.split(',', ' ', '\n').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
if (ids.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { api.saveDriveResources(id, DriveResourceSelectionDto(ids, ids)) }
|
||||
.onSuccess { reload() }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save folder selection")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun oauthUrl(provider: String, company: Boolean): String {
|
||||
val intent = if (company) "link_company_drive" else "link_drive"
|
||||
return BuildConfig.REST_API_BASE_URL.trimEnd('/') + "/auth/oauth/$provider/start/?intent=$intent"
|
||||
}
|
||||
|
||||
private suspend fun reload() {
|
||||
val state = _state.value
|
||||
val page = runCatching {
|
||||
api.documents(state.scope, state.page, 20, state.search.ifBlank { null }, state.ordering)
|
||||
}.getOrElse { err ->
|
||||
_state.update { it.copy(error = err.apiMessage("Could not load documents"), documents = emptyList()) }
|
||||
return
|
||||
}
|
||||
val drives = runCatching { api.driveConnections() }.getOrDefault(emptyList())
|
||||
_state.update { it.copy(documents = page.results, total = page.count, drives = drives, loading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DocumentsScreen(
|
||||
hasRag: Boolean,
|
||||
driveJustConnected: Boolean,
|
||||
onNavigate: (String) -> Unit,
|
||||
onSignOut: () -> Unit,
|
||||
viewModel: DocumentsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var resourceDraft by remember { mutableStateOf("") }
|
||||
LaunchedEffect(driveJustConnected) {
|
||||
if (driveJustConnected) viewModel.showDriveBanner()
|
||||
}
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val name = uri.lastPathSegment ?: "upload"
|
||||
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
|
||||
viewModel.upload(name, bytes, context.contentResolver.getType(uri))
|
||||
}
|
||||
AppScaffold("Documents", Routes.Documents, hasRag, onNavigate, onSignOut) { padding ->
|
||||
if (state.loading) {
|
||||
Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@AppScaffold
|
||||
}
|
||||
if (!state.hasRag) {
|
||||
Column(Modifier.fillMaxSize().padding(padding).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Unlock document storage", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Document uploads and cloud drive connections are available on plans that include RAG document search.")
|
||||
Button(onClick = { onNavigate(Routes.Account) }) { Text("Upgrade in Billing") }
|
||||
}
|
||||
return@AppScaffold
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (state.driveBanner) {
|
||||
item { Text("Drive connected. Choose folders below to include them in your knowledge base.") }
|
||||
}
|
||||
state.error?.let { err -> item { Text(err, color = MaterialTheme.colorScheme.error) } }
|
||||
if (state.hasCompany) {
|
||||
item {
|
||||
Row {
|
||||
TextButton(onClick = { viewModel.setScope("company") }) { Text(if (state.scope == "company") "• Company" else "Company") }
|
||||
TextButton(onClick = { viewModel.setScope("personal") }) { Text(if (state.scope == "personal") "• Personal" else "Personal") }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
state.search,
|
||||
viewModel::setSearch,
|
||||
label = { Text("Search") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${state.total} documents", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = viewModel::toggleSort) { Text(if (state.ordering == "-created") "Newest" else "Oldest") }
|
||||
}
|
||||
}
|
||||
items(state.documents, key = { it.id }) { doc ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(doc.file.substringAfterLast('/'))
|
||||
Text(doc.created.take(10), style = MaterialTheme.typography.labelSmall)
|
||||
Text(if (doc.processed) "Processed" else "Processing", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
Switch(checked = doc.active, onCheckedChange = { viewModel.toggleActive(doc, it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row {
|
||||
TextButton(onClick = { if (state.page > 1) viewModel.setPage(state.page - 1) }, enabled = state.page > 1) { Text("Previous") }
|
||||
TextButton(onClick = { viewModel.setPage(state.page + 1) }, enabled = state.page * 20 < state.total) { Text("Next") }
|
||||
}
|
||||
}
|
||||
item { Button(onClick = { picker.launch("*/*") }) { Text("Upload a document") } }
|
||||
item { Text("Cloud drives", style = MaterialTheme.typography.titleMedium) }
|
||||
item {
|
||||
Row {
|
||||
TextButton(onClick = { openTab(context, viewModel.oauthUrl("google", false)) }) { Text("Google Drive") }
|
||||
TextButton(onClick = { openTab(context, viewModel.oauthUrl("microsoft", false)) }) { Text("OneDrive") }
|
||||
}
|
||||
}
|
||||
items(state.drives.filter { (it.kind ?: "personal") == "personal" }, key = { "p${it.id}" }) { drive ->
|
||||
DriveCard(drive, resourceDraft, { resourceDraft = it }, viewModel)
|
||||
}
|
||||
if (state.isManager) {
|
||||
item { Text("Company knowledge sources", style = MaterialTheme.typography.titleMedium) }
|
||||
item {
|
||||
Row {
|
||||
TextButton(onClick = { openTab(context, viewModel.oauthUrl("google", true)) }) { Text("Shared Drive") }
|
||||
TextButton(onClick = { openTab(context, viewModel.oauthUrl("microsoft", true)) }) { Text("Microsoft 365") }
|
||||
}
|
||||
}
|
||||
items(state.drives.filter { it.kind == "company" }, key = { "c${it.id}" }) { drive ->
|
||||
DriveCard(drive, resourceDraft, { resourceDraft = it }, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DriveCard(
|
||||
drive: DriveConnectionDto,
|
||||
draft: String,
|
||||
onDraft: (String) -> Unit,
|
||||
viewModel: DocumentsViewModel,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text("${drive.provider} · ${drive.external_account_email ?: "connected"}")
|
||||
Text("Sync: ${drive.last_sync_status ?: "never"}")
|
||||
OutlinedTextField(draft, onDraft, label = { Text("Folder ids, comma separated") }, modifier = Modifier.fillMaxWidth())
|
||||
Row {
|
||||
TextButton(onClick = { viewModel.saveResources(drive.id, draft) }) { Text("Save folders") }
|
||||
TextButton(onClick = { viewModel.sync(drive.id) }) { Text("Sync") }
|
||||
TextButton(onClick = { viewModel.disconnect(drive.id) }) { Text("Disconnect") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openTab(context: android.content.Context, url: String) {
|
||||
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package ai.hesychia.chat.ui.feedback
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.hesychia.chat.data.api.FeedbackCreateDto
|
||||
import ai.hesychia.chat.data.api.FeedbackDto
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.apiMessage
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import ai.hesychia.chat.ui.shell.AppScaffold
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val categories = listOf(
|
||||
"Bug" to "BUG",
|
||||
"Enhancement" to "ENCHANCEMENT",
|
||||
"Other" to "OTHER",
|
||||
)
|
||||
|
||||
data class FeedbackUiState(
|
||||
val items: List<FeedbackDto> = emptyList(),
|
||||
val error: String? = null,
|
||||
val notice: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FeedbackViewModel @Inject constructor(
|
||||
private val api: HesychiaApi,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(FeedbackUiState())
|
||||
val state: StateFlow<FeedbackUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch { reload() }
|
||||
}
|
||||
|
||||
fun submit(title: String, text: String, category: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { api.createFeedback(FeedbackCreateDto(title, text, category)) }
|
||||
.onSuccess {
|
||||
_state.update { it.copy(notice = "Feedback sent", error = null) }
|
||||
reload()
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.update { it.copy(error = err.apiMessage("Could not send feedback")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reload() {
|
||||
runCatching { api.feedbacks() }
|
||||
.onSuccess { rows -> _state.update { it.copy(items = rows) } }
|
||||
.onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not load feedback")) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedbackScreen(
|
||||
hasRag: Boolean,
|
||||
onNavigate: (String) -> Unit,
|
||||
onSignOut: () -> Unit,
|
||||
viewModel: FeedbackViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var title by remember { mutableStateOf("") }
|
||||
var body by remember { mutableStateOf("") }
|
||||
var category by remember { mutableStateOf("BUG") }
|
||||
AppScaffold("Feedback", Routes.Feedback, hasRag, onNavigate, onSignOut) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Submit feedback", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(title, { title = it }, label = { Text("Title") }, modifier = Modifier.fillMaxWidth())
|
||||
RowOfCategories(category) { category = it }
|
||||
OutlinedTextField(body, { body = it }, label = { Text("Text") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.submit(title.trim(), body.trim(), category)
|
||||
title = ""
|
||||
body = ""
|
||||
},
|
||||
enabled = title.isNotBlank() && body.isNotBlank(),
|
||||
) { Text("Send") }
|
||||
}
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Feedback history", style = MaterialTheme.typography.titleMedium)
|
||||
if (state.items.isEmpty()) Text("No feedback yet.")
|
||||
state.items.forEach { item ->
|
||||
Text(item.title, style = MaterialTheme.typography.titleSmall)
|
||||
Text("${item.category} · ${item.status}", style = MaterialTheme.typography.labelSmall)
|
||||
Text(item.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowOfCategories(selected: String, onSelect: (String) -> Unit) {
|
||||
Column {
|
||||
categories.forEach { (label, value) ->
|
||||
TextButton(onClick = { onSelect(value) }) {
|
||||
Text(if (selected == value) "• $label" else label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ai.hesychia.chat.ui.session
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.hesychia.chat.data.api.HesychiaApi
|
||||
import ai.hesychia.chat.data.api.RefreshTokenBody
|
||||
import ai.hesychia.chat.data.api.SubscriptionDto
|
||||
import ai.hesychia.chat.data.billing.StoreBilling
|
||||
import ai.hesychia.chat.data.session.SessionRepository
|
||||
import ai.hesychia.chat.data.session.SessionSnapshot
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class BootState(
|
||||
val ready: Boolean = false,
|
||||
val startRoute: String = Routes.SignIn,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SessionViewModel @Inject constructor(
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val api: HesychiaApi,
|
||||
private val storeBilling: StoreBilling,
|
||||
) : ViewModel() {
|
||||
val state: StateFlow<SessionSnapshot> = sessionRepository.session
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SessionSnapshot())
|
||||
|
||||
private val _subscription = MutableStateFlow<SubscriptionDto?>(null)
|
||||
val subscription: StateFlow<SubscriptionDto?> = _subscription
|
||||
|
||||
private val _boot = MutableStateFlow(BootState())
|
||||
val boot: StateFlow<BootState> = _boot
|
||||
|
||||
val hasRag: Boolean get() = _subscription.value?.hasRag == true
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val snap = sessionRepository.session.first { it.hydrated }
|
||||
if (snap.isAuthenticated) {
|
||||
refreshUser()
|
||||
val updatedSnap = sessionRepository.session.first { it.hydrated }
|
||||
if (updatedSnap.isAuthenticated) {
|
||||
loadSubscription()
|
||||
updatedSnap.userId?.let { runCatching { storeBilling.logIn(it) } }
|
||||
}
|
||||
}
|
||||
val uri = LaunchUri.consume()
|
||||
val route = if (uri != null) {
|
||||
routeFor(uri)
|
||||
} else {
|
||||
defaultRoute(sessionRepository.session.first { it.hydrated })
|
||||
}
|
||||
_boot.value = BootState(ready = true, startRoute = route)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSubscription() {
|
||||
viewModelScope.launch { loadSubscription() }
|
||||
}
|
||||
|
||||
private suspend fun loadSubscription() {
|
||||
_subscription.value = runCatching { api.subscription() }.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun refreshUser() {
|
||||
val user = runCatching { api.currentUser() }.getOrNull()
|
||||
if (user != null) {
|
||||
sessionRepository.saveUser(user)
|
||||
} else {
|
||||
sessionRepository.clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
fun signOut(onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
val refresh = state.value.refreshToken
|
||||
if (!refresh.isNullOrBlank()) {
|
||||
runCatching { api.blacklist(RefreshTokenBody(refresh)) }
|
||||
}
|
||||
runCatching { storeBilling.logOut() }
|
||||
sessionRepository.clearAuth()
|
||||
_subscription.value = null
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep link from https://hesychia.ai/... Returns the route to open. */
|
||||
suspend fun routeFor(uri: Uri): String {
|
||||
val path = uri.path.orEmpty()
|
||||
if (path.startsWith("/set_password")) {
|
||||
val slug = uri.getQueryParameter("slug").orEmpty()
|
||||
return "${Routes.SetPassword}?slug=$slug"
|
||||
}
|
||||
if (path.startsWith("/password_reset_confirmation")) return Routes.PasswordResetConfirmation
|
||||
if (path.startsWith("/password_reset")) return Routes.PasswordReset
|
||||
if (path.startsWith("/auth")) {
|
||||
if (uri.getQueryParameter("drive_connected") == "1") return Routes.Documents
|
||||
val access = uri.getQueryParameter("access")
|
||||
val refresh = uri.getQueryParameter("refresh")
|
||||
val error = uri.getQueryParameter("error")
|
||||
if (!error.isNullOrBlank()) return Routes.SignIn
|
||||
if (!access.isNullOrBlank() && !refresh.isNullOrBlank()) {
|
||||
sessionRepository.saveTokens(access, refresh)
|
||||
refreshUser()
|
||||
loadSubscription()
|
||||
val user = sessionRepository.session.first { it.hydrated }
|
||||
user.userId?.let { runCatching { storeBilling.logIn(it) } }
|
||||
if (uri.getQueryParameter("needs_checkout") == "1") return Routes.Account
|
||||
if (!user.hasSignedTos) return Routes.Terms
|
||||
return Routes.Chat
|
||||
}
|
||||
}
|
||||
val latest = sessionRepository.session.first { it.hydrated }
|
||||
return defaultRoute(latest)
|
||||
}
|
||||
|
||||
private fun defaultRoute(snap: SessionSnapshot): String = when {
|
||||
!snap.isAuthenticated -> Routes.SignIn
|
||||
!snap.hasSignedTos -> Routes.Terms
|
||||
else -> Routes.Chat
|
||||
}
|
||||
|
||||
fun noteBootRoute(route: String) {
|
||||
_boot.update { it.copy(ready = true, startRoute = route) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Set from [ai.hesychia.chat.MainActivity] before Compose reads the session. */
|
||||
object LaunchUri {
|
||||
@Volatile
|
||||
var uri: Uri? = null
|
||||
|
||||
val incoming = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
|
||||
|
||||
fun consume(): Uri? = uri.also { uri = null }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package ai.hesychia.chat.ui.shell
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.hesychia.chat.navigation.Routes
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DrawerDest(val route: String, val label: String)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppScaffold(
|
||||
title: String,
|
||||
currentRoute: String,
|
||||
hasRag: Boolean,
|
||||
onNavigate: (String) -> Unit,
|
||||
onSignOut: () -> Unit,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val dests = buildList {
|
||||
add(DrawerDest(Routes.Chat, "Dashboard"))
|
||||
add(DrawerDest(Routes.Account, "Account"))
|
||||
if (hasRag) add(DrawerDest(Routes.Documents, "Documents"))
|
||||
add(DrawerDest(Routes.Analytics, "Analytics"))
|
||||
add(DrawerDest(Routes.Feedback, "Feedback"))
|
||||
}
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
Text("Hesychia", modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp))
|
||||
dests.forEach { dest ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(dest.label) },
|
||||
selected = currentRoute == dest.route,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
if (currentRoute != dest.route) onNavigate(dest.route)
|
||||
},
|
||||
)
|
||||
}
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Sign out") },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
onSignOut()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package ai.hesychia.chat.ui.theme
|
||||
|
||||
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.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Theme color variants mirrored from llm-fe `ui-kit/assets/theme/base/palettes.js`.
|
||||
* Default product accent on web often follows `info` / blue (#1A73E8).
|
||||
*/
|
||||
enum class ThemeColor {
|
||||
Blue,
|
||||
Green,
|
||||
Purple,
|
||||
Orange,
|
||||
Red,
|
||||
}
|
||||
|
||||
data class BrandPalette(
|
||||
val main: Color,
|
||||
val focus: Color,
|
||||
)
|
||||
|
||||
object HesychiaTokens {
|
||||
val blue = BrandPalette(Color(0xFF1A73E8), Color(0xFF1662C4))
|
||||
val green = BrandPalette(Color(0xFF4CAF50), Color(0xFF67BB6A))
|
||||
val purple = BrandPalette(Color(0xFF9C27B0), Color(0xFFBA68C8))
|
||||
val orange = BrandPalette(Color(0xFFFB8C00), Color(0xFFFC9D26))
|
||||
val red = BrandPalette(Color(0xFFF44335), Color(0xFFF65F53))
|
||||
|
||||
val backgroundLight = Color(0xFFF0F2F5)
|
||||
val backgroundDark = Color(0xFF1A2035)
|
||||
val surfaceLight = Color(0xFFFFFFFF)
|
||||
val surfaceDark = Color(0xFF202940)
|
||||
val textMuted = Color(0xFF7B809A)
|
||||
val dark = Color(0xFF344767)
|
||||
val grey300 = Color(0xFFDEE2E6)
|
||||
val error = Color(0xFFF44335)
|
||||
val success = Color(0xFF4CAF50)
|
||||
val warning = Color(0xFFFB8C00)
|
||||
|
||||
/** Corner radii from web borders (approx rem→dp). */
|
||||
val radiusSm = 4
|
||||
val radiusMd = 8
|
||||
val radiusLg = 12
|
||||
val radiusXl = 16
|
||||
|
||||
fun palette(themeColor: ThemeColor): BrandPalette = when (themeColor) {
|
||||
ThemeColor.Blue -> blue
|
||||
ThemeColor.Green -> green
|
||||
ThemeColor.Purple -> purple
|
||||
ThemeColor.Orange -> orange
|
||||
ThemeColor.Red -> red
|
||||
}
|
||||
}
|
||||
|
||||
private fun lightScheme(accent: BrandPalette) = lightColorScheme(
|
||||
primary = accent.main,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = accent.focus,
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = HesychiaTokens.textMuted,
|
||||
onSecondary = Color.White,
|
||||
background = HesychiaTokens.backgroundLight,
|
||||
onBackground = HesychiaTokens.dark,
|
||||
surface = HesychiaTokens.surfaceLight,
|
||||
onSurface = HesychiaTokens.dark,
|
||||
surfaceVariant = HesychiaTokens.grey300,
|
||||
onSurfaceVariant = HesychiaTokens.textMuted,
|
||||
error = HesychiaTokens.error,
|
||||
onError = Color.White,
|
||||
outline = HesychiaTokens.grey300,
|
||||
outlineVariant = HesychiaTokens.grey300,
|
||||
)
|
||||
|
||||
private fun darkScheme(accent: BrandPalette) = darkColorScheme(
|
||||
primary = accent.main,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = accent.focus,
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = HesychiaTokens.textMuted,
|
||||
onSecondary = Color.White,
|
||||
background = HesychiaTokens.backgroundDark,
|
||||
onBackground = Color.White,
|
||||
surface = HesychiaTokens.surfaceDark,
|
||||
onSurface = Color.White,
|
||||
surfaceVariant = Color(0xFF2C3C58),
|
||||
onSurfaceVariant = Color(0xFFADB5BD),
|
||||
error = HesychiaTokens.error,
|
||||
onError = Color.White,
|
||||
outline = Color(0xFF495057),
|
||||
outlineVariant = Color(0xFF495057),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HesychiaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
themeColor: ThemeColor = ThemeColor.Blue,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val palette = HesychiaTokens.palette(themeColor)
|
||||
val colors = if (darkTheme) darkScheme(palette) else lightScheme(palette)
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
typography = HesychiaTypography,
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AnimatedConstellationBackground()
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedConstellationBackground(modifier: Modifier = Modifier) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "constellation")
|
||||
val progress by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 60000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "progress"
|
||||
)
|
||||
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val dotColor = primaryColor.copy(alpha = 0.35f)
|
||||
val lineColor = primaryColor.copy(alpha = 0.15f)
|
||||
|
||||
val particles = remember {
|
||||
List(28) { i ->
|
||||
Particle(
|
||||
x = (i * 37.5f) % 1f,
|
||||
y = (i * 53.2f) % 1f,
|
||||
vx = ((i % 5) - 2f) * 0.00008f,
|
||||
vy = (((i * 7) % 5) - 2f) * 0.00008f,
|
||||
radius = 2f + (i % 3) * 1.5f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val currentParticles = particles.map { p ->
|
||||
var nx = (p.x + p.vx * progress) % 1f
|
||||
if (nx < 0f) nx += 1f
|
||||
var ny = (p.y + p.vy * progress) % 1f
|
||||
if (ny < 0f) ny += 1f
|
||||
Offset(nx * width, ny * height)
|
||||
}
|
||||
|
||||
for (i in currentParticles.indices) {
|
||||
for (j in i + 1 until currentParticles.size) {
|
||||
val p1 = currentParticles[i]
|
||||
val p2 = currentParticles[j]
|
||||
val dx = p1.x - p2.x
|
||||
val dy = p1.y - p2.y
|
||||
val distSq = dx * dx + dy * dy
|
||||
if (distSq < 18000f) {
|
||||
val alphaFactor = (1f - (distSq / 18000f)).coerceIn(0f, 1f)
|
||||
drawLine(
|
||||
color = lineColor.copy(alpha = lineColor.alpha * alphaFactor),
|
||||
start = p1,
|
||||
end = p2,
|
||||
strokeWidth = 1.2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (pt in currentParticles) {
|
||||
drawCircle(
|
||||
color = dotColor,
|
||||
radius = 3f,
|
||||
center = pt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Particle(
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val vx: Float,
|
||||
val vy: Float,
|
||||
val radius: Float,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
package ai.hesychia.chat.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Typography scale from llm-fe `ui-kit/assets/theme/base/typography.js`.
|
||||
* Web uses Roboto; Compose defaults to platform sans until branded fonts packaged.
|
||||
*/
|
||||
val HesychiaTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 48.sp,
|
||||
lineHeight = 60.sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 46.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 39.sp,
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 31.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 25.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,30c-13.2,0 -24,10.8 -24,24s10.8,24 24,24 24,-10.8 24,-24 -10.8,-24 -24,-24zM54,70c-8.8,0 -16,-7.2 -16,-16s7.2,-16 16,-16 16,7.2 16,16 -7.2,16 -16,16z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A73E8</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hesychia</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Hesychia" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,26 @@
|
||||
package ai.hesychia.chat.data.chat
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class WsFramesTest {
|
||||
@Test
|
||||
fun sentinels() {
|
||||
assertThat(classifyIncoming("CONVERSATION_ID")).isEqualTo(IncomingFrame.ConversationIdMarker)
|
||||
assertThat(classifyIncoming("START_OF_THE_STREAM_ENDER_GAME_42")).isEqualTo(IncomingFrame.StreamStart)
|
||||
assertThat(classifyIncoming("END_OF_THE_STREAM_ENDER_GAME_42")).isEqualTo(IncomingFrame.StreamEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusAndErrorFrames() {
|
||||
val status = classifyIncoming("""{"v":1,"type":"status","data":{"stage":"search","label":"Searching","detail":"docs"}}""")
|
||||
assertThat(status).isEqualTo(IncomingFrame.Status("search", "Searching", "docs"))
|
||||
val error = classifyIncoming("""{"type":"error","content":"Quota exceeded"}""")
|
||||
assertThat(error).isEqualTo(IncomingFrame.Error("Quota exceeded"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainTokensStayText() {
|
||||
assertThat(classifyIncoming("Hello")).isEqualTo(IncomingFrame.Text("Hello"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ai.hesychia.chat.ui.theme
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class HesychiaTokensTest {
|
||||
@Test
|
||||
fun bluePaletteMatchesWeb() {
|
||||
assertThat(HesychiaTokens.blue.main.red).isWithin(0.01f).of(0x1A / 255f)
|
||||
assertThat(HesychiaTokens.blue.main.green).isWithin(0.01f).of(0x73 / 255f)
|
||||
assertThat(HesychiaTokens.blue.main.blue).isWithin(0.01f).of(0xE8 / 255f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allThemeColorsResolve() {
|
||||
ThemeColor.entries.forEach { color ->
|
||||
assertThat(HesychiaTokens.palette(color).main).isNotNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user