diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..feff8f3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Unit tests + run: ./gradlew :app:testProdDebugUnitTest --stacktrace + + - name: Assemble debug APK + run: ./gradlew :app:assembleProdDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: hesychia-prod-debug + path: app/build/outputs/apk/prod/debug/*.apk + if-no-files-found: error diff --git a/.gitignore b/.gitignore index a8b0d1d..bd30a55 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ # Gradle files .gradle/ build/ +**/build/ # Local configuration file (sdk path, etc) local.properties +keystore.properties # Log/OS Files *.log @@ -14,6 +16,7 @@ captures/ .externalNativeBuild/ .cxx/ *.apk +*.aab output.json # IntelliJ @@ -33,3 +36,5 @@ google-services.json # Android Profiling *.hprof +# OS +.DS_Store diff --git a/README.md b/README.md index 01b2f0b..2340fc1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,80 @@ -# chat_android +# Hesychia Android (native) +Kotlin + Jetpack Compose client for [Hesychia](https://hesychia.ai), reusing `chat_backend` APIs. + +Epic: [chat_web_app#111](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/111) + +Capacitor WebView shell remains in `chat_web_app/llm-fe` as the interim Play track — see [docs/ADR-002-capacitor-coexistence.md](docs/ADR-002-capacitor-coexistence.md). + +## Stack + +- Kotlin, Jetpack Compose, Material 3 +- Hilt, Navigation Compose, DataStore +- Retrofit + OkHttp (+ WebSocket next) +- RevenueCat Android SDK (IAP Workstream E) +- Min SDK 23 / target SDK 35 +- Application id: `ai.hesychia.chat` + +## Docs + +| Doc | Purpose | +|-----|---------| +| [ADR-001](docs/ADR-001-architecture.md) | Architecture choices | +| [ADR-002](docs/ADR-002-capacitor-coexistence.md) | Play id + Capacitor sunset | +| [SCREEN_INVENTORY](docs/SCREEN_INVENTORY.md) | Routes / flows to mirror | +| [OPEN_QUESTIONS](docs/OPEN_QUESTIONS.md) | Spike leftovers | +| [design-tokens/hesychia.tokens.json](design-tokens/hesychia.tokens.json) | Shared visual tokens | + +## Prerequisites + +- JDK 17+ +- Android SDK Platform 35 + build-tools +- Android Studio (Ladybug+) recommended +- Set `sdk.dir` in `local.properties` (gitignored), or open once in Android Studio + +```properties +sdk.dir=/path/to/Android/Sdk +``` + +## Build + +```bash +./gradlew :app:assembleProdDebug +./gradlew :app:testProdDebugUnitTest +# APK → app/build/outputs/apk/prod/debug/ +``` + +Flavors: + +| Flavor | REST / WS | applicationId | +|--------|-----------|---------------| +| `prod` (default) | production `chatbackend.aimloperations.com` | `ai.hesychia.chat` (+ `.debug` for debug) | +| `beta` | `beta.chatbackend.aimloperations.com` | `ai.hesychia.chat.beta` (+ `.debug`) | + +## Signing (release) + +Never commit keystores. Create `keystore.properties` (gitignored) at repo root: + +```properties +storeFile=/absolute/path/to/aiml-chat-upload.jks +storePassword=... +keyAlias=aiml-chat-upload +keyPassword=... +``` + +```bash +./gradlew :app:bundleProdRelease +``` + +## Monetization + +Native Play uses **RevenueCat** only (no Stripe Checkout). Align with `chat_web_app/llm-fe/MONETIZATION.md`: + +- `appUserID` = Django user pk string +- Provider badge: `revenuecat` + `PLAY_STORE` + +## Related repos + +- `chat_web_app` — product / Capacitor / design source +- `chat_backend` — REST + WS + RevenueCat webhooks +- Future: `chat_ios` (SwiftUI twin epic) diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..bd54882 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..0685c1f --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Hesychia — release minify rules (enable later with R8) +-keepattributes *Annotation*, InnerClasses +-dontwarn okhttp3.** +-dontwarn retrofit2.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d982578 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt b/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt new file mode 100644 index 0000000..3fee61c --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt @@ -0,0 +1,7 @@ +package ai.hesychia.chat + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class HesychiaApp : Application() diff --git a/app/src/main/java/ai/hesychia/chat/MainActivity.kt b/app/src/main/java/ai/hesychia/chat/MainActivity.kt new file mode 100644 index 0000000..11ce9af --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/MainActivity.kt @@ -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) } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt b/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt new file mode 100644 index 0000000..5dc0545 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt @@ -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 +} diff --git a/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt b/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt new file mode 100644 index 0000000..97fa059 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt @@ -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 + + @GET("monetization/payments/") + suspend fun payments(): List + + @GET("monetization/plans/") + suspend fun plans(): List + + @POST("monetization/store/confirm/") + suspend fun confirmStore(@Body body: StoreConfirmRequestDto) + + @GET("company_users") + suspend fun companyUsers(): List + + @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 + + @GET("conversation_details") + suspend fun conversationDetails(@Query("conversation_id") conversationId: Int): List + + @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 + + @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 + + @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 + + @GET("analytics/user_conversations/") + suspend fun userConversations(): List + + @GET("analytics/company_usage/") + suspend fun companyUsage(): List + + @GET("analytics/admin/") + suspend fun adminAnalytics(): List +} diff --git a/app/src/main/java/ai/hesychia/chat/data/api/Models.kt b/app/src/main/java/ai/hesychia/chat/data/api/Models.kt new file mode 100644 index 0000000..02d7c3b --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/api/Models.kt @@ -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 = 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 = 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 = emptyList(), +) + +@Serializable +data class DriveResourceSelectionDto( + val resource_ids: List, + val resource_labels: List, +) + +@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 = emptyList(), + val hours: List = emptyList(), + val matrix: List> = 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, +) diff --git a/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt b/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt new file mode 100644 index 0000000..f9aa76a --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt @@ -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> { 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?) { + 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 + }) + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt b/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt new file mode 100644 index 0000000..1e15f36 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt @@ -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"}""") + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt b/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt new file mode 100644 index 0000000..f0c222a --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt @@ -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) : 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() diff --git a/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt b/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt new file mode 100644 index 0000000..9e3e062 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt @@ -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 + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt b/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt new file mode 100644 index 0000000..170d25c --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt @@ -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 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 = 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 } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt b/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt new file mode 100644 index 0000000..a848bd0 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt @@ -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) + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt new file mode 100644 index 0000000..444ea0c --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt @@ -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") } }, + ) + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt new file mode 100644 index 0000000..c47486b --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt @@ -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 = emptyList(), + val payments: List = emptyList(), + val plans: List = emptyList(), + val prefs: PreferencesDto = PreferencesDto(), + val companyUsers: List = 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 = _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.")) } } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt new file mode 100644 index 0000000..19aefbf --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt @@ -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 = emptyList(), + val conversations: List = emptyList(), + val company: List = emptyList(), + val admin: List = emptyList(), +) + +@HiltViewModel +class AnalyticsViewModel @Inject constructor( + private val api: HesychiaApi, + private val sessionRepository: SessionRepository, +) : ViewModel() { + private val _state = MutableStateFlow(AnalyticsUiState()) + val state: StateFlow = _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, 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), + ) + } + } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt new file mode 100644 index 0000000..ab8590a --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt @@ -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(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(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(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(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(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)) +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt new file mode 100644 index 0000000..b5cb6a1 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt @@ -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 = _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 = 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 = _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 = 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 = 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 = _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 = 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 = runCatching { + api.acknowledgeTos() + sessionRepository.markTosSigned() + } +} + +fun Throwable.userMessage(fallback: String): String = apiMessage(fallback) diff --git a/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..48110a0 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt @@ -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(null) } + var attachmentBytes by remember { mutableStateOf(null) } + var attachmentType by remember { mutableStateOf(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, + 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") + } + } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..87e2665 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt @@ -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 = emptyList(), + val rating: String? = null, +) + +data class ConversationRow(val id: Int, val title: String) + +data class ChatUiState( + val conversations: List = emptyList(), + val selectedId: Int? = null, + val messages: List = 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 = _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, +) diff --git a/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt new file mode 100644 index 0000000..66d3483 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt @@ -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 = emptyList(), + val drives: List = 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 = _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)) +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt new file mode 100644 index 0000000..7937d95 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt @@ -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 = 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 = _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) + } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt new file mode 100644 index 0000000..0787650 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt @@ -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 = sessionRepository.session + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SessionSnapshot()) + + private val _subscription = MutableStateFlow(null) + val subscription: StateFlow = _subscription + + private val _boot = MutableStateFlow(BootState()) + val boot: StateFlow = _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(extraBufferCapacity = 1) + + fun consume(): Uri? = uri.also { uri = null } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt b/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt new file mode 100644 index 0000000..cfee088 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt b/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt new file mode 100644 index 0000000..aac7547 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt @@ -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, +) diff --git a/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt b/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt new file mode 100644 index 0000000..1237a2f --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt @@ -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, + ), +) diff --git a/app/src/main/res/drawable/hesychia_mark.png b/app/src/main/res/drawable/hesychia_mark.png new file mode 100644 index 0000000..b97ffcc Binary files /dev/null and b/app/src/main/res/drawable/hesychia_mark.png differ diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..31f6ffa --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f67db2f --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1A73E8 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bd8111d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Hesychia + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8e52ab0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +