diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5cabdcf..570b5a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,17 +1,16 @@ plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.compose.compiler) } android { namespace = "com.runanywhere.kotlin_starter_example" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.runanywhere.kotlin_starter_example" minSdk = 26 - targetSdk = 35 + targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -37,10 +36,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { compose = true } @@ -65,11 +60,13 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.navigation.compose) debugImplementation(libs.androidx.compose.ui.tooling) + // Markdown + implementation(libs.markdown.renderer) + // Coroutines implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 91a4a84..57b6d89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,8 @@ diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/MainActivity.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/MainActivity.kt index dbe3a82..c0bfee9 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/MainActivity.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/MainActivity.kt @@ -13,8 +13,10 @@ import com.runanywhere.kotlin_starter_example.services.ModelService import com.runanywhere.kotlin_starter_example.ui.screens.ChatScreen import com.runanywhere.kotlin_starter_example.ui.screens.HomeScreen import com.runanywhere.kotlin_starter_example.ui.screens.SpeechToTextScreen +import com.runanywhere.kotlin_starter_example.ui.screens.SplashScreen import com.runanywhere.kotlin_starter_example.ui.screens.TextToSpeechScreen import com.runanywhere.kotlin_starter_example.ui.screens.ToolCallingScreen +import com.runanywhere.kotlin_starter_example.ui.screens.LoraScreen import com.runanywhere.kotlin_starter_example.ui.screens.VisionScreen import com.runanywhere.kotlin_starter_example.ui.screens.VoicePipelineScreen import com.runanywhere.kotlin_starter_example.ui.theme.KotlinStarterTheme @@ -71,8 +73,18 @@ fun RunAnywhereApp() { NavHost( navController = navController, - startDestination = "home" + startDestination = "splash" ) { + composable("splash") { + SplashScreen( + onSplashComplete = { + navController.navigate("home") { + popUpTo("splash") { inclusive = true } + } + } + ) + } + composable("home") { HomeScreen( onNavigateToChat = { navController.navigate("chat") }, @@ -80,7 +92,8 @@ fun RunAnywhereApp() { onNavigateToTTS = { navController.navigate("tts") }, onNavigateToVoicePipeline = { navController.navigate("voice_pipeline") }, onNavigateToToolCalling = { navController.navigate("tool_calling") }, - onNavigateToVision = { navController.navigate("vision") } + onNavigateToVision = { navController.navigate("vision") }, + onNavigateToLora = { navController.navigate("lora") } ) } @@ -125,5 +138,12 @@ fun RunAnywhereApp() { modelService = modelService ) } + + composable("lora") { + LoraScreen( + onNavigateBack = { navController.popBackStack() }, + modelService = modelService + ) + } } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/services/ModelService.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/services/ModelService.kt index 397c6f2..7a87e8c 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/services/ModelService.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/services/ModelService.kt @@ -74,33 +74,104 @@ class ModelService : ViewModel() { private set var isVLMLoaded by mutableStateOf(false) private set - + + // LoRA base model state + var isLoraBaseDownloading by mutableStateOf(false) + private set + var loraBaseDownloadProgress by mutableStateOf(0f) + private set + var isLoraBaseLoading by mutableStateOf(false) + private set + var isLoraBaseLoaded by mutableStateOf(false) + private set + var isVoiceAgentReady by mutableStateOf(false) private set - + var errorMessage by mutableStateOf(null) private set companion object { // Model IDs - using officially supported models - const val LLM_MODEL_ID = "smollm2-360m-instruct-q8_0" + const val LLM_MODEL_ID = "qwen2.5-0.5b-instruct-q6_k" const val STT_MODEL_ID = "sherpa-onnx-whisper-tiny.en" const val TTS_MODEL_ID = "vits-piper-en_US-lessac-medium" const val VLM_MODEL_ID = "smolvlm-256m-instruct" - + + // LoRA-compatible base model (Qwen 2.5 supports LoRA adapters) + const val LORA_BASE_MODEL_ID = "qwen2.5-0.5b-instruct-q6_k" + + // LoRA adapter definitions + data class LoraAdapterDef( + val id: String, + val name: String, + val description: String, + val url: String, + val filename: String, + val examplePrompts: List + ) + + val LORA_ADAPTERS = listOf( + LoraAdapterDef( + id = "code-assistant-lora", + name = "Code Assistant", + description = "Enhances code generation and programming assistance", + url = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/code-assistant-Q8_0.gguf", + filename = "code-assistant-Q8_0.gguf", + examplePrompts = listOf( + "Write a Python function to reverse a linked list", + "Explain the difference between a stack and a queue with code examples", + ) + ), + LoraAdapterDef( + id = "reasoning-logic-lora", + name = "Reasoning Logic", + description = "Improves logical reasoning and step-by-step problem solving", + url = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/reasoning-logic-Q8_0.gguf", + filename = "reasoning-logic-Q8_0.gguf", + examplePrompts = listOf( + "If all roses are flowers and some flowers fade quickly, can we conclude some roses fade quickly?", + "A farmer has 17 sheep. All but 9 die. How many are left?", + ) + ), + LoraAdapterDef( + id = "medical-qa-lora", + name = "Medical QA", + description = "Enhances medical question answering and health-related responses", + url = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/medical-qa-Q8_0.gguf", + filename = "medical-qa-Q8_0.gguf", + examplePrompts = listOf( + "What are the common symptoms of vitamin D deficiency?", + "Explain the difference between Type 1 and Type 2 diabetes", + ) + ), + LoraAdapterDef( + id = "creative-writing-lora", + name = "Creative Writing", + description = "Improves creative writing, storytelling, and literary style", + url = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/creative-writing-Q8_0.gguf", + filename = "creative-writing-Q8_0.gguf", + examplePrompts = listOf( + "Write a short story about a robot discovering emotions for the first time", + "Describe a sunset over the ocean using vivid sensory language", + ) + ), + ) + /** * Register default models with the SDK. - * Includes LLM, STT, TTS, and VLM (multi-file model with mmproj). + * Includes LLM, STT, TTS, VLM. Qwen 2.5 serves as both default LLM and LoRA base. */ fun registerDefaultModels() { - // LLM Model - SmolLM2 360M (small, fast, good for demos) + // LLM Model - Qwen 2.5 0.5B Instruct (supports LoRA adapters) RunAnywhere.registerModel( id = LLM_MODEL_ID, - name = "SmolLM2 360M Instruct Q8_0", - url = "https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct-GGUF/resolve/main/smollm2-360m-instruct-q8_0.gguf", + name = "Qwen 2.5 0.5B Instruct Q6_K", + url = "https://huggingface.co/Triangle104/Qwen2.5-0.5B-Instruct-Q6_K-GGUF/resolve/main/qwen2.5-0.5b-instruct-q6_k.gguf", framework = InferenceFramework.LLAMA_CPP, modality = ModelCategory.LANGUAGE, - memoryRequirement = 400_000_000 + memoryRequirement = 600_000_000, + supportsLora = true ) // STT Model - Whisper Tiny English (fast transcription) @@ -333,6 +404,55 @@ class ModelService : ViewModel() { } } + /** + * Download and load LoRA-compatible base model (Qwen 2.5 0.5B) + * This unloads any existing LLM first, then loads the LoRA-compatible base. + */ + fun downloadAndLoadLoraBase() { + if (isLoraBaseDownloading || isLoraBaseLoading) return + + viewModelScope.launch { + try { + errorMessage = null + + // Unload existing LLM if loaded (can only have one at a time) + if (isLLMLoaded) { + RunAnywhere.unloadLLMModel() + isLLMLoaded = false + } + + // Check if already downloaded + if (!isModelDownloaded(LORA_BASE_MODEL_ID)) { + isLoraBaseDownloading = true + loraBaseDownloadProgress = 0f + + RunAnywhere.downloadModel(LORA_BASE_MODEL_ID) + .catch { e -> + errorMessage = "Qwen download failed: ${e.message}" + } + .collect { progress -> + loraBaseDownloadProgress = progress.progress + } + + isLoraBaseDownloading = false + } + + // Load the model + isLoraBaseLoading = true + RunAnywhere.loadLLMModel(LORA_BASE_MODEL_ID) + isLoraBaseLoaded = true + isLLMLoaded = true + isLoraBaseLoading = false + + refreshModelState() + } catch (e: Exception) { + errorMessage = "Qwen load failed: ${e.message}" + isLoraBaseDownloading = false + isLoraBaseLoading = false + } + } + } + /** * Download and load all models for voice agent */ diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppButton.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppButton.kt new file mode 100644 index 0000000..360a352 --- /dev/null +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppButton.kt @@ -0,0 +1,106 @@ +package com.runanywhere.kotlin_starter_example.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme + +@Composable +fun AppButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + color: Color = AppTheme.colors.accent, + content: @Composable RowScope.() -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "press" + ) + + Button( + onClick = onClick, + modifier = modifier.height(44.dp).scale(scale), + enabled = enabled, + shape = RoundedCornerShape(11.dp), + colors = ButtonDefaults.buttonColors( + containerColor = color, + contentColor = Color.White, + disabledContainerColor = color.copy(alpha = 0.3f), + disabledContentColor = Color.White.copy(alpha = 0.4f) + ), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 0.dp), + interactionSource = interactionSource, + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } +} + +@Composable +fun AppOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit +) { + val colors = AppTheme.colors + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "press" + ) + + OutlinedButton( + onClick = onClick, + modifier = modifier.height(44.dp).scale(scale), + enabled = enabled, + shape = RoundedCornerShape(11.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = colors.surfaceContainer, + contentColor = colors.textPrimary, + disabledContentColor = colors.textSecondary + ), + border = ButtonDefaults.outlinedButtonBorder(enabled = enabled).copy( + brush = SolidColor(colors.border) + ), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 0.dp), + interactionSource = interactionSource, + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } +} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppCard.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppCard.kt new file mode 100644 index 0000000..a5d792a --- /dev/null +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppCard.kt @@ -0,0 +1,37 @@ +package com.runanywhere.kotlin_starter_example.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme + +@Composable +fun AppCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val colors = AppTheme.colors + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = colors.surfaceContainer), + border = BorderStroke(0.5.dp, colors.border), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + content = content + ) + } +} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppScaffold.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppScaffold.kt new file mode 100644 index 0000000..46b58b4 --- /dev/null +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/AppScaffold.kt @@ -0,0 +1,87 @@ +package com.runanywhere.kotlin_starter_example.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppScaffold( + title: String, + subtitle: String? = null, + onBack: (() -> Unit)? = null, + bottomBar: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + val colors = AppTheme.colors + + Scaffold( + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = colors.textPrimary + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colors.textSecondary + ) + } + } + }, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + TablerIcons.ArrowLeft, + contentDescription = "Back", + tint = colors.textPrimary, + modifier = Modifier.size(22.dp) + ) + } + } else { + Spacer(modifier = Modifier.width(8.dp)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = colors.background, + scrolledContainerColor = colors.background + ) + ) + }, + bottomBar = bottomBar, + containerColor = colors.background + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(innerPadding) + ) { + content() + } + } +} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/ChatComponents.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/ChatComponents.kt new file mode 100644 index 0000000..a1c1b29 --- /dev/null +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/ChatComponents.kt @@ -0,0 +1,224 @@ +package com.runanywhere.kotlin_starter_example.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme + +@Composable +fun ChatInputBar( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + isGenerating: Boolean, + placeholder: String = "Type a message...", + accentColor: Color = AppTheme.colors.accent, + modifier: Modifier = Modifier +) { + val colors = AppTheme.colors + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val sendScale by animateFloatAsState( + targetValue = if (isPressed) 0.88f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "send" + ) + + Row( + modifier = modifier + .fillMaxWidth() + .background(colors.surface) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + placeholder = { + Text(placeholder, color = colors.textTertiary, style = MaterialTheme.typography.bodyMedium) + }, + readOnly = isGenerating, + colors = TextFieldDefaults.colors( + focusedContainerColor = colors.surfaceContainer, + unfocusedContainerColor = colors.surfaceContainer, + disabledContainerColor = colors.surfaceContainer, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = accentColor + ), + shape = RoundedCornerShape(12.dp), + maxLines = 4, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = colors.textPrimary) + ) + + IconButton( + onClick = { if (value.isNotBlank() && !isGenerating) onSend() }, + modifier = Modifier + .size(44.dp) + .scale(sendScale), + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (isGenerating) accentColor.copy(alpha = 0.7f) + else if (value.isBlank()) colors.surfaceContainer + else accentColor, + contentColor = Color.White, + disabledContainerColor = colors.surfaceContainer, + disabledContentColor = colors.textTertiary + ), + interactionSource = interactionSource + ) { + if (isGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + TablerIcons.Send, + contentDescription = "Send", + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +@Composable +fun MessageBubble( + text: String, + isUser: Boolean, + isStreaming: Boolean = false, + accentColor: Color = AppTheme.colors.accent, + modifier: Modifier = Modifier +) { + val colors = AppTheme.colors + + if (isUser) { + // User message: right-aligned bubble + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Column( + modifier = Modifier + .widthIn(max = 300.dp) + .background( + accentColor, + RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp) + ) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) + } + } + } else { + // AI message: full-width, ChatGPT style - no bubble, just text + Column( + modifier = modifier + .fillMaxWidth() + .padding(end = 32.dp) + ) { + if (isStreaming || text.isEmpty()) { + // During streaming, show plain text for performance + Text( + text = text.ifEmpty { "..." }, + style = MaterialTheme.typography.bodyMedium, + color = colors.textPrimary, + lineHeight = MaterialTheme.typography.bodyMedium.lineHeight + ) + } else { + // After streaming completes, render as markdown + Markdown( + content = text, + colors = markdownColor( + text = colors.textPrimary, + codeText = colors.tintCyan, + codeBackground = colors.surfaceContainer, + dividerColor = colors.border, + linkText = colors.accent, + ), + typography = markdownTypography( + h1 = MaterialTheme.typography.headlineSmall.copy(color = colors.textPrimary), + h2 = MaterialTheme.typography.titleLarge.copy(color = colors.textPrimary), + h3 = MaterialTheme.typography.titleMedium.copy(color = colors.textPrimary), + text = MaterialTheme.typography.bodyMedium.copy(color = colors.textPrimary), + code = MaterialTheme.typography.bodySmall.copy(color = colors.tintCyan), + ), + ) + } + } + } +} + +@Composable +fun EmptyChat( + icon: @Composable () -> Unit, + title: String, + subtitle: String +) { + val colors = AppTheme.colors + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + icon() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = colors.textPrimary + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = colors.textSecondary + ) + } +} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/FeatureCard.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/FeatureCard.kt index d4260ba..fc60669 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/FeatureCard.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/FeatureCard.kt @@ -1,8 +1,21 @@ package com.runanywhere.kotlin_starter_example.ui.components +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +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.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -10,13 +23,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme @Composable fun FeatureCard( @@ -27,56 +42,63 @@ fun FeatureCard( onClick: () -> Unit, modifier: Modifier = Modifier ) { + val colors = AppTheme.colors + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.96f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "scale" + ) + val tintColor = gradientColors.first() + Card( + onClick = onClick, modifier = modifier .fillMaxWidth() - .aspectRatio(0.85f) - .clickable(onClick = onClick), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f) - ), - elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp, - pressedElevation = 8.dp - ) + .scale(scale), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = colors.surfaceContainer), + border = BorderStroke(0.5.dp, colors.border), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + interactionSource = interactionSource ) { Column( modifier = Modifier .fillMaxSize() - .padding(20.dp), + .padding(16.dp), verticalArrangement = Arrangement.SpaceBetween ) { - // Icon with gradient background Box( modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(14.dp)) - .background( - brush = Brush.linearGradient(gradientColors) - ), - contentAlignment = Alignment.Center + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(tintColor.copy(alpha = 0.12f)), + contentAlignment = androidx.compose.ui.Alignment.Center ) { Icon( imageVector = icon, contentDescription = title, - tint = Color.White, - modifier = Modifier.size(28.dp) + tint = tintColor, + modifier = Modifier.size(20.dp) ) } - - // Text content + + Spacer(modifier = Modifier.height(12.dp)) + Column { Text( text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary ) - Spacer(modifier = Modifier.height(4.dp)) Text( text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + style = MaterialTheme.typography.bodySmall, + color = colors.textSecondary ) } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/ModelLoaderWidget.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/ModelLoaderWidget.kt index 1fdfe52..05c3435 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/ModelLoaderWidget.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/components/ModelLoaderWidget.kt @@ -1,13 +1,35 @@ package com.runanywhere.kotlin_starter_example.ui.components import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.* +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme @Composable fun ModelLoaderWidget( @@ -19,86 +41,132 @@ fun ModelLoaderWidget( onLoadClick: () -> Unit, modifier: Modifier = Modifier ) { - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) + val colors = AppTheme.colors + val animatedProgress by animateFloatAsState( + targetValue = downloadProgress, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "progress" + ) + + AppCard(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = modelName, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = when { - isLoaded -> "Ready" - isLoading -> "Loading..." - isDownloading -> "Downloading ${(downloadProgress * 100).toInt()}%" - else -> "Not loaded" - }, - style = MaterialTheme.typography.bodySmall, - color = when { - isLoaded -> MaterialTheme.colorScheme.primary - isDownloading || isLoading -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + Column(modifier = Modifier.weight(1f)) { + Text( + text = modelName, + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary + ) + Spacer(modifier = Modifier.height(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + when { + isLoaded -> { + Icon( + TablerIcons.CircleCheck, + contentDescription = null, + tint = colors.success, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Ready", + style = MaterialTheme.typography.bodySmall, + color = colors.success + ) + } + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = colors.accent + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Loading model...", + style = MaterialTheme.typography.bodySmall, + color = colors.accent + ) + } + isDownloading -> { + Text( + text = "Downloading ${(downloadProgress * 100).toInt()}%", + style = MaterialTheme.typography.bodySmall, + color = colors.accent + ) + } + else -> { + Text( + text = "Not loaded", + style = MaterialTheme.typography.bodySmall, + color = colors.textSecondary + ) } - ) + } } - - if (!isLoaded) { - Button( - onClick = onLoadClick, - enabled = !isDownloading && !isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary + } + + if (!isLoaded) { + AppButton( + onClick = onLoadClick, + enabled = !isDownloading && !isLoading, + ) { + if (isDownloading || isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = androidx.compose.ui.graphics.Color.White + ) + } else { + Icon( + TablerIcons.Download, + contentDescription = null, + modifier = Modifier.size(16.dp) ) - ) { - Text(if (isDownloading || isLoading) "Loading..." else "Load") + Text("Load", style = MaterialTheme.typography.labelLarge) } } } - - // Progress bar for downloading - AnimatedVisibility(visible = isDownloading) { - Column { - Spacer(modifier = Modifier.height(12.dp)) - LinearProgressIndicator( - progress = { downloadProgress }, - modifier = Modifier - .fillMaxWidth() - .height(4.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - } + } + + // Progress bar + AnimatedVisibility( + visible = isDownloading, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 } + ) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(2.dp)), + color = colors.accent, + trackColor = colors.border, + ) } - - // Loading indicator - AnimatedVisibility(visible = isLoading) { - Column { - Spacer(modifier = Modifier.height(12.dp)) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(4.dp), - color = MaterialTheme.colorScheme.secondary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - } + } + + // Loading indeterminate + AnimatedVisibility( + visible = isLoading && !isDownloading, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 } + ) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(2.dp)), + color = colors.accent, + trackColor = colors.border, + ) } } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/icons/TablerIcons.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/icons/TablerIcons.kt new file mode 100644 index 0000000..aa6f92b --- /dev/null +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/icons/TablerIcons.kt @@ -0,0 +1,55 @@ +package com.runanywhere.kotlin_starter_example.ui.icons + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import com.runanywhere.kotlin_starter_example.R + +object TablerIcons { + // Navigation + val ArrowLeft: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_arrow_left) + + // Chat & Communication + val Message: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_message) + val Send: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_send) + val Robot: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_robot) + val User: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_user) + + // Audio & Voice + val Microphone: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_microphone) + val Volume: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_volume) + val Ear: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_ear) + val Speakerphone: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_speakerphone) + val PlayerStop: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_player_stop) + + // AI & Magic + val Sparkles: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_sparkles) + + // Tools & Actions + val Tool: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_tool) + val Calculator: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_calculator) + val Adjustments: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_adjustments) + val Tune: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_tune) + val Download: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_download) + val Plus: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_plus) + val X: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_x) + val TrashX: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_trash_x) + + // Status & Info + val ShieldCheck: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_shield_check) + val CircleCheck: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_circle_check) + val CircleX: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_circle_x) + val AlertTriangle: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_alert_triangle) + val Clock: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_clock) + + // Vision & Media + val Eye: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_eye) + val Photo: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_photo) + val PhotoPlus: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_photo_plus) + + // System + val Cpu: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_cpu) + val Cloud: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_cloud) + val Typography: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_typography) + val Language: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_language) +} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ChatScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ChatScreen.kt index 8439ce6..d90a1da 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ChatScreen.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ChatScreen.kt @@ -3,60 +3,36 @@ package com.runanywhere.kotlin_starter_example.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.Send -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.SmartToy -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.runanywhere.kotlin_starter_example.services.ModelService +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold +import com.runanywhere.kotlin_starter_example.ui.components.ChatInputBar +import com.runanywhere.kotlin_starter_example.ui.components.EmptyChat +import com.runanywhere.kotlin_starter_example.ui.components.MessageBubble import com.runanywhere.kotlin_starter_example.ui.components.ModelLoaderWidget -import com.runanywhere.kotlin_starter_example.ui.theme.AccentCyan -import com.runanywhere.kotlin_starter_example.ui.theme.AccentViolet -import com.runanywhere.kotlin_starter_example.ui.theme.PrimaryDark -import com.runanywhere.kotlin_starter_example.ui.theme.PrimaryMid -import com.runanywhere.kotlin_starter_example.ui.theme.SurfaceCard -import com.runanywhere.kotlin_starter_example.ui.theme.TextMuted -import com.runanywhere.kotlin_starter_example.ui.theme.TextPrimary -import com.runanywhere.sdk.public.extensions.chat +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.generateStream import kotlinx.coroutines.launch data class ChatMessage( @@ -65,243 +41,130 @@ data class ChatMessage( val timestamp: Long = System.currentTimeMillis() ) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( onNavigateBack: () -> Unit, modelService: ModelService = viewModel(), modifier: Modifier = Modifier ) { + val colors = AppTheme.colors var messages by remember { mutableStateOf(listOf()) } var inputText by remember { mutableStateOf("") } var isGenerating by remember { mutableStateOf(false) } - + val scope = rememberCoroutineScope() val listState = rememberLazyListState() - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Chat - LLM") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = PrimaryDark - ) - ) - }, - containerColor = PrimaryDark - ) { padding -> - Column( - modifier = modifier - .fillMaxSize() - .padding(padding) - ) { - // Model loader section - if (!modelService.isLLMLoaded) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - ModelLoaderWidget( - modelName = "SmolLM2 360M", - isDownloading = modelService.isLLMDownloading, - isLoading = modelService.isLLMLoading, - isLoaded = modelService.isLLMLoaded, - downloadProgress = modelService.llmDownloadProgress, - onLoadClick = { modelService.downloadAndLoadLLM() } - ) - - modelService.errorMessage?.let { error -> - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 4.dp) - ) - } - } - } - - // Chat messages - LazyColumn( - state = listState, - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(vertical = 16.dp) - ) { - if (messages.isEmpty() && modelService.isLLMLoaded) { - item { - EmptyStateMessage() - } - } - - items(messages) { message -> - ChatMessageBubble(message) - } - } - - // Input section + + AppScaffold( + title = "Chat", + subtitle = "LLM Text Generation", + onBack = onNavigateBack, + bottomBar = { if (modelService.isLLMLoaded) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = SurfaceCard.copy(alpha = 0.8f), - shadowElevation = 8.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.Bottom - ) { - TextField( - value = inputText, - onValueChange = { inputText = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Type a message...") }, - readOnly = isGenerating, - colors = TextFieldDefaults.colors( - focusedContainerColor = PrimaryMid, - unfocusedContainerColor = PrimaryMid, - disabledContainerColor = PrimaryMid, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - shape = RoundedCornerShape(12.dp), - maxLines = 4 - ) - - Spacer(modifier = Modifier.width(8.dp)) - - FloatingActionButton( - onClick = { - if (inputText.isNotBlank() && !isGenerating) { - val userMessage = inputText - messages = messages + ChatMessage(userMessage, isUser = true) - inputText = "" - - scope.launch { - isGenerating = true - listState.animateScrollToItem(messages.size) - - try { - val response = com.runanywhere.sdk.public.RunAnywhere.chat(userMessage) - messages = messages + ChatMessage(response, isUser = false) - listState.animateScrollToItem(messages.size) - } catch (e: Exception) { - messages = messages + ChatMessage( - "Error: ${e.message}", - isUser = false - ) - } finally { - isGenerating = false + ChatInputBar( + value = inputText, + onValueChange = { inputText = it }, + onSend = { + if (inputText.isNotBlank() && !isGenerating) { + val userMessage = inputText + messages = messages + ChatMessage(userMessage, isUser = true) + inputText = "" + + scope.launch { + isGenerating = true + messages = messages + ChatMessage("", isUser = false) + listState.animateScrollToItem(messages.size) + + try { + RunAnywhere.generateStream(userMessage) + .collect { token -> + val lastIndex = messages.lastIndex + val current = messages[lastIndex] + messages = messages.toMutableList().apply { + set(lastIndex, current.copy(text = current.text + token)) + } } + listState.animateScrollToItem(messages.size) + } catch (e: Exception) { + val lastIndex = messages.lastIndex + messages = messages.toMutableList().apply { + set(lastIndex, ChatMessage("Error: ${e.message}", isUser = false)) } + } finally { + isGenerating = false } - }, - containerColor = if (isGenerating) AccentViolet else if (inputText.isBlank()) TextMuted else AccentCyan - ) { - if (isGenerating) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White - ) - } else { - Icon(Icons.AutoMirrored.Rounded.Send, "Send") } } - } - } + }, + isGenerating = isGenerating + ) } } - } -} - -@Composable -private fun EmptyStateMessage() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - imageVector = Icons.Rounded.SmartToy, - contentDescription = null, - tint = AccentCyan, - modifier = Modifier.size(64.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Start a conversation", - style = MaterialTheme.typography.titleLarge, - color = TextPrimary - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Type a message below to chat with the AI", - style = MaterialTheme.typography.bodyMedium, - color = TextMuted - ) - } -} - -@Composable -private fun ChatMessageBubble(message: ChatMessage) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start - ) { - if (!message.isUser) { - Icon( - imageVector = Icons.Rounded.SmartToy, - contentDescription = null, - tint = AccentCyan, + // Model loader + if (!modelService.isLLMLoaded) { + Column( modifier = Modifier - .size(32.dp) - .padding(top = 4.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) + .fillMaxWidth() + .padding(16.dp) + ) { + ModelLoaderWidget( + modelName = "SmolLM2 360M", + isDownloading = modelService.isLLMDownloading, + isLoading = modelService.isLLMLoading, + isLoaded = modelService.isLLMLoaded, + downloadProgress = modelService.llmDownloadProgress, + onLoadClick = { modelService.downloadAndLoadLLM() } + ) + + modelService.errorMessage?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = colors.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } } - - Card( - modifier = Modifier.widthIn(max = 280.dp), - shape = RoundedCornerShape( - topStart = if (message.isUser) 16.dp else 4.dp, - topEnd = if (message.isUser) 4.dp else 16.dp, - bottomStart = 16.dp, - bottomEnd = 16.dp - ), - colors = CardDefaults.cardColors( - containerColor = if (message.isUser) AccentCyan else SurfaceCard - ) + + // Chat messages + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) ) { - Text( - text = message.text, - modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodyMedium, - color = if (message.isUser) Color.White else TextPrimary - ) - } - - if (message.isUser) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Rounded.Person, - contentDescription = null, - tint = AccentViolet, - modifier = Modifier - .size(32.dp) - .padding(top = 4.dp) - ) + if (messages.isEmpty() && modelService.isLLMLoaded) { + item { + EmptyChat( + icon = { + Icon( + TablerIcons.Robot, + contentDescription = null, + tint = colors.accent, + modifier = Modifier.size(48.dp) + ) + }, + title = "Start a conversation", + subtitle = "Type a message below to chat with the AI" + ) + } + } + + items(messages.size) { index -> + val message = messages[index] + val isLastAiMessage = !message.isUser && index == messages.lastIndex + MessageBubble( + text = message.text, + isUser = message.isUser, + isStreaming = isLastAiMessage && isGenerating + ) + } } } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/HomeScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/HomeScreen.kt index e86782a..648ad4a 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/HomeScreen.kt @@ -1,25 +1,28 @@ package com.runanywhere.kotlin_starter_example.ui.screens -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +import com.runanywhere.kotlin_starter_example.ui.components.AppCard +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold import com.runanywhere.kotlin_starter_example.ui.components.FeatureCard -import com.runanywhere.kotlin_starter_example.ui.theme.* +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme @Composable fun HomeScreen( @@ -29,195 +32,155 @@ fun HomeScreen( onNavigateToVoicePipeline: () -> Unit, onNavigateToToolCalling: () -> Unit, onNavigateToVision: () -> Unit, + onNavigateToLora: () -> Unit, modifier: Modifier = Modifier ) { - Box( - modifier = modifier - .fillMaxSize() - .background( - brush = Brush.linearGradient( - colors = listOf( - PrimaryDark, - Color(0xFF0F1629), - PrimaryMid - ) - ) - ) + val colors = AppTheme.colors + + AppScaffold( + title = "RunAnywhere", + subtitle = "Kotlin SDK Starter" ) { - Column( + androidx.compose.foundation.layout.Column( modifier = Modifier - .fillMaxSize() + .weight(1f) + .fillMaxWidth() .verticalScroll(rememberScrollState()) - .padding(24.dp) + .padding(horizontal = 20.dp) ) { - Spacer(modifier = Modifier.height(20.dp)) - - // Header - Header() - - Spacer(modifier = Modifier.height(40.dp)) - + Spacer(modifier = Modifier.height(8.dp)) + // Privacy info PrivacyInfoCard() - - Spacer(modifier = Modifier.height(32.dp)) - + + Spacer(modifier = Modifier.height(24.dp)) + // Feature grid - LazyVerticalGrid( - columns = GridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.height(560.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - FeatureCard( - title = "Chat", - subtitle = "LLM Text Generation", - icon = Icons.Rounded.Chat, - gradientColors = listOf(AccentCyan, Color(0xFF0EA5E9)), - onClick = onNavigateToChat - ) - } - - item { - FeatureCard( - title = "Speech", - subtitle = "Speech to Text", - icon = Icons.Rounded.Mic, - gradientColors = listOf(AccentViolet, Color(0xFF7C3AED)), - onClick = onNavigateToSTT - ) - } - - item { - FeatureCard( - title = "Voice", - subtitle = "Text to Speech", - icon = Icons.Rounded.VolumeUp, - gradientColors = listOf(AccentPink, Color(0xFFDB2777)), - onClick = onNavigateToTTS - ) - } - - item { - FeatureCard( - title = "Pipeline", - subtitle = "Voice Agent", - icon = Icons.Rounded.AutoAwesome, - gradientColors = listOf(AccentGreen, Color(0xFF059669)), - onClick = onNavigateToVoicePipeline - ) - } - - item { - FeatureCard( - title = "Tools", - subtitle = "Function Calling", - icon = Icons.Rounded.Build, - gradientColors = listOf(AccentOrange, Color(0xFFEA580C)), - onClick = onNavigateToToolCalling - ) - } - - item { - FeatureCard( - title = "Vision", - subtitle = "Image Understanding", - icon = Icons.Rounded.RemoveRedEye, - gradientColors = listOf(AccentPink, Color(0xFFDB2777)), - onClick = onNavigateToVision - ) - } + FeatureCard( + title = "Chat", + subtitle = "LLM Text Generation", + icon = TablerIcons.Message, + gradientColors = listOf(colors.tintBlue), + onClick = onNavigateToChat, + modifier = Modifier.weight(1f) + ) + FeatureCard( + title = "Speech", + subtitle = "Speech to Text", + icon = TablerIcons.Microphone, + gradientColors = listOf(colors.tintPurple), + onClick = onNavigateToSTT, + modifier = Modifier.weight(1f) + ) } - - Spacer(modifier = Modifier.height(32.dp)) - + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + FeatureCard( + title = "Voice", + subtitle = "Text to Speech", + icon = TablerIcons.Volume, + gradientColors = listOf(colors.tintPink), + onClick = onNavigateToTTS, + modifier = Modifier.weight(1f) + ) + FeatureCard( + title = "Pipeline", + subtitle = "Voice Agent", + icon = TablerIcons.Sparkles, + gradientColors = listOf(colors.tintGreen), + onClick = onNavigateToVoicePipeline, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + FeatureCard( + title = "Tools", + subtitle = "Function Calling", + icon = TablerIcons.Tool, + gradientColors = listOf(colors.tintOrange), + onClick = onNavigateToToolCalling, + modifier = Modifier.weight(1f) + ) + FeatureCard( + title = "Vision", + subtitle = "Image Understanding", + icon = TablerIcons.Eye, + gradientColors = listOf(colors.tintPink), + onClick = onNavigateToVision, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + FeatureCard( + title = "LoRA", + subtitle = "Fine-Tune Adapters", + icon = TablerIcons.Tune, + gradientColors = listOf(colors.tintCyan), + onClick = onNavigateToLora, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.height(24.dp)) + // Model info ModelInfoSection() - - Spacer(modifier = Modifier.height(24.dp)) - } - } -} -@Composable -private fun Header() { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - // Icon - Box( - modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(16.dp)) - .background( - brush = Brush.linearGradient( - colors = listOf(AccentCyan, AccentViolet) - ) - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Bolt, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(32.dp) - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - // Title - Column { - Text( - text = "RunAnywhere", - style = MaterialTheme.typography.headlineLarge, - color = TextPrimary - ) - Text( - text = "Kotlin SDK Starter", - style = MaterialTheme.typography.bodyMedium, - color = AccentCyan - ) + Spacer(modifier = Modifier.height(28.dp)) } } } @Composable private fun PrivacyInfoCard() { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = SurfaceCard.copy(alpha = 0.6f) - ) - ) { + val colors = AppTheme.colors + + AppCard { Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Rounded.PrivacyTip, + imageVector = TablerIcons.ShieldCheck, contentDescription = null, - tint = AccentCyan.copy(alpha = 0.8f), - modifier = Modifier.size(28.dp) + tint = colors.accent, + modifier = Modifier.size(22.dp) ) - - Spacer(modifier = Modifier.width(16.dp)) - - Column { + + Spacer(modifier = Modifier.width(14.dp)) + + androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f)) { Text( text = "Privacy-First On-Device AI", style = MaterialTheme.typography.titleMedium, - color = TextPrimary + color = colors.textPrimary ) - Spacer(modifier = Modifier.height(4.dp)) Text( - text = "All AI processing happens locally on your device. No data ever leaves your phone.", + text = "All processing happens locally. No data leaves your device.", style = MaterialTheme.typography.bodySmall, - color = TextMuted + color = colors.textSecondary ) } } @@ -226,51 +189,23 @@ private fun PrivacyInfoCard() { @Composable private fun ModelInfoSection() { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = SurfaceCard.copy(alpha = 0.5f) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - ModelInfoRow( - icon = Icons.Rounded.Memory, - title = "LLM", - value = "SmolLM2 360M" - ) - Spacer(modifier = Modifier.height(12.dp)) - ModelInfoRow( - icon = Icons.Rounded.RemoveRedEye, - title = "VLM", - value = "SmolVLM 256M" - ) - Spacer(modifier = Modifier.height(12.dp)) - ModelInfoRow( - icon = Icons.Rounded.Hearing, - title = "STT", - value = "Whisper Tiny" - ) - Spacer(modifier = Modifier.height(12.dp)) - ModelInfoRow( - icon = Icons.Rounded.RecordVoiceOver, - title = "TTS", - value = "Piper Lessac" - ) - } + val colors = AppTheme.colors + + AppCard { + ModelInfoRow(icon = TablerIcons.Cpu, title = "LLM", value = "SmolLM2 360M") + Spacer(modifier = Modifier.height(10.dp)) + ModelInfoRow(icon = TablerIcons.Eye, title = "VLM", value = "SmolVLM 256M") + Spacer(modifier = Modifier.height(10.dp)) + ModelInfoRow(icon = TablerIcons.Ear, title = "STT", value = "Whisper Tiny") + Spacer(modifier = Modifier.height(10.dp)) + ModelInfoRow(icon = TablerIcons.Speakerphone, title = "TTS", value = "Piper Lessac") } } @Composable -private fun ModelInfoRow( - icon: androidx.compose.ui.graphics.vector.ImageVector, - title: String, - value: String -) { +private fun ModelInfoRow(icon: ImageVector, title: String, value: String) { + val colors = AppTheme.colors + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -280,21 +215,12 @@ private fun ModelInfoRow( Icon( imageVector = icon, contentDescription = null, - tint = TextMuted, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = TextPrimary + tint = colors.textTertiary, + modifier = Modifier.size(18.dp) ) + Spacer(modifier = Modifier.width(10.dp)) + Text(text = title, style = MaterialTheme.typography.bodyMedium, color = colors.textPrimary) } - - Text( - text = value, - style = MaterialTheme.typography.bodySmall, - color = AccentCyan - ) + Text(text = value, style = MaterialTheme.typography.bodySmall, color = colors.accent) } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/LoraScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/LoraScreen.kt new file mode 100644 index 0000000..dc3e179 --- /dev/null +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/LoraScreen.kt @@ -0,0 +1,692 @@ +package com.runanywhere.kotlin_starter_example.ui.screens + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.runanywhere.kotlin_starter_example.services.ModelService +import com.runanywhere.kotlin_starter_example.ui.components.AppButton +import com.runanywhere.kotlin_starter_example.ui.components.AppCard +import com.runanywhere.kotlin_starter_example.ui.components.AppOutlinedButton +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold +import com.runanywhere.kotlin_starter_example.ui.components.ChatInputBar +import com.runanywhere.kotlin_starter_example.ui.components.EmptyChat +import com.runanywhere.kotlin_starter_example.ui.components.MessageBubble +import com.runanywhere.kotlin_starter_example.ui.components.ModelLoaderWidget +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterConfig +import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterInfo +import com.runanywhere.sdk.public.extensions.generateStream +import com.runanywhere.sdk.public.extensions.clearLoraAdapters +import com.runanywhere.sdk.public.extensions.getLoadedLoraAdapters +import com.runanywhere.sdk.public.extensions.loadLoraAdapter +import com.runanywhere.sdk.public.extensions.removeLoraAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LoraScreen( + onNavigateBack: () -> Unit, + modelService: ModelService = viewModel(), + modifier: Modifier = Modifier +) { + val colors = AppTheme.colors + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // LoRA state + var adapterScale by remember { mutableFloatStateOf(1.0f) } + var loadedAdapters by remember { mutableStateOf(listOf()) } + var isLoadingAdapter by remember { mutableStateOf(false) } + var loraError by remember { mutableStateOf(null) } + var isAdaptersPanelExpanded by remember { mutableStateOf(true) } + + // Per-adapter download state + val downloadingAdapters = remember { mutableStateMapOf() } + val downloadProgress = remember { mutableStateMapOf() } + val downloadedAdapters = remember { + mutableStateMapOf().apply { + val loraDir = File(context.filesDir, "lora_adapters") + ModelService.LORA_ADAPTERS.forEach { adapter -> + put(adapter.id, File(loraDir, adapter.filename).exists()) + } + } + } + + // Chat state + var messages by remember { mutableStateOf(listOf()) } + var inputText by remember { mutableStateOf("") } + var isGenerating by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + // Current example prompts based on loaded adapters + val currentExamplePrompts = remember(loadedAdapters) { + loadedAdapters.flatMap { adapter -> + val filename = adapter.path.substringAfterLast("/") + ModelService.LORA_ADAPTERS + .find { it.filename == filename } + ?.examplePrompts ?: emptyList() + } + } + + val activeCount = remember(loadedAdapters) { + ModelService.LORA_ADAPTERS.count { def -> + loadedAdapters.any { it.path.substringAfterLast("/") == def.filename } + } + } + + suspend fun refreshAdapters() { + try { + loadedAdapters = RunAnywhere.getLoadedLoraAdapters() + } catch (_: Exception) { + loadedAdapters = emptyList() + } + } + + fun downloadAndLoadAdapter(adapterDef: ModelService.Companion.LoraAdapterDef) { + scope.launch { + val loraDir = File(context.filesDir, "lora_adapters") + val adapterFile = File(loraDir, adapterDef.filename) + + loraError = null + + if (!adapterFile.exists()) { + downloadingAdapters[adapterDef.id] = true + downloadProgress[adapterDef.id] = 0f + + val success = withContext(Dispatchers.IO) { + try { + adapterFile.parentFile?.mkdirs() + val connection = URL(adapterDef.url).openConnection() as HttpURLConnection + connection.connectTimeout = 30_000 + connection.readTimeout = 30_000 + connection.connect() + + val totalBytes = connection.contentLengthLong + var downloadedBytes = 0L + + connection.inputStream.use { input -> + adapterFile.outputStream().use { output -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + downloadedBytes += bytesRead + if (totalBytes > 0) { + downloadProgress[adapterDef.id] = downloadedBytes.toFloat() / totalBytes.toFloat() + } + } + } + } + true + } catch (e: Exception) { + adapterFile.delete() + loraError = "Download failed: ${e.message}" + false + } + } + + downloadingAdapters[adapterDef.id] = false + + if (!success) return@launch + downloadedAdapters[adapterDef.id] = true + } + + isLoadingAdapter = true + try { + RunAnywhere.loadLoraAdapter( + LoRAAdapterConfig(path = adapterFile.absolutePath, scale = adapterScale) + ) + refreshAdapters() + } catch (e: Exception) { + loraError = "Failed to load ${adapterDef.name} adapter: ${e.message}" + } finally { + isLoadingAdapter = false + } + } + } + + fun copyUriToLocal(uri: Uri): String? { + return try { + val inputStream = context.contentResolver.openInputStream(uri) ?: return null + val loraDir = File(context.filesDir, "lora_adapters").apply { mkdirs() } + val fileName = uri.lastPathSegment?.substringAfterLast("/") + ?.takeIf { it.endsWith(".gguf") } + ?: "adapter_${System.currentTimeMillis()}.gguf" + val destFile = File(loraDir, fileName) + inputStream.use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + destFile.absolutePath + } catch (e: Exception) { + loraError = "Failed to copy adapter file: ${e.message}" + null + } + } + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) { + scope.launch { + isLoadingAdapter = true + loraError = null + try { + val localPath = withContext(Dispatchers.IO) { copyUriToLocal(uri) } + if (localPath != null) { + RunAnywhere.loadLoraAdapter( + LoRAAdapterConfig(path = localPath, scale = adapterScale) + ) + refreshAdapters() + } + } catch (e: Exception) { + loraError = "Failed to load adapter: ${e.message}" + } finally { + isLoadingAdapter = false + } + } + } + } + + fun sendMessage(text: String) { + if (text.isBlank() || isGenerating) return + val userMessage = text + messages = messages + ChatMessage(userMessage, isUser = true) + inputText = "" + // Collapse the panel once user starts chatting + isAdaptersPanelExpanded = false + + scope.launch { + isGenerating = true + messages = messages + ChatMessage("", isUser = false) + listState.animateScrollToItem(messages.size) + + try { + RunAnywhere.generateStream(userMessage) + .collect { token -> + val lastIndex = messages.lastIndex + val current = messages[lastIndex] + messages = messages.toMutableList().apply { + set(lastIndex, current.copy(text = current.text + token)) + } + } + listState.animateScrollToItem(messages.size) + } catch (e: Exception) { + val lastIndex = messages.lastIndex + messages = messages.toMutableList().apply { + set(lastIndex, ChatMessage("Error: ${e.message}", isUser = false)) + } + } finally { + isGenerating = false + } + } + } + + @Composable + fun adapterIcon(adapterId: String): ImageVector = when (adapterId) { + "code-assistant-lora" -> TablerIcons.Tool + "reasoning-logic-lora" -> TablerIcons.Calculator + "medical-qa-lora" -> TablerIcons.ShieldCheck + "creative-writing-lora" -> TablerIcons.Typography + else -> TablerIcons.Tune + } + + fun adapterColor(adapterId: String): Color = when (adapterId) { + "code-assistant-lora" -> colors.tintBlue + "reasoning-logic-lora" -> colors.tintOrange + "medical-qa-lora" -> colors.tintGreen + "creative-writing-lora" -> colors.tintPurple + else -> colors.tintCyan + } + + AppScaffold( + title = "LoRA Adapters", + subtitle = "Fine-Tune with Adapters", + onBack = onNavigateBack, + bottomBar = { + if (modelService.isLoraBaseLoaded) { + ChatInputBar( + value = inputText, + onValueChange = { inputText = it }, + onSend = { sendMessage(inputText) }, + isGenerating = isGenerating, + placeholder = "Test with LoRA adapters...", + accentColor = colors.tintCyan + ) + } + } + ) { + // Step 1: Load LoRA-compatible base model + if (!modelService.isLoraBaseLoaded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + ModelLoaderWidget( + modelName = "Qwen 2.5 0.5B (LoRA Base)", + isDownloading = modelService.isLoraBaseDownloading, + isLoading = modelService.isLoraBaseLoading, + isLoaded = modelService.isLoraBaseLoaded, + downloadProgress = modelService.loraBaseDownloadProgress, + onLoadClick = { modelService.downloadAndLoadLoraBase() } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Load the Qwen 2.5 base model first. This model supports LoRA adapters for fine-tuned behavior.", + style = MaterialTheme.typography.bodySmall, + color = colors.textSecondary, + modifier = Modifier.padding(horizontal = 4.dp) + ) + + modelService.errorMessage?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = colors.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + + // LoRA controls (only when base model is loaded) + if (modelService.isLoraBaseLoaded) { + // Collapsible adapters panel + val chevronRotation by animateFloatAsState( + targetValue = if (isAdaptersPanelExpanded) 90f else 0f, + label = "chevron" + ) + + AppCard( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .animateContentSize() + ) { + // Header row - always visible, tappable to expand/collapse + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isAdaptersPanelExpanded = !isAdaptersPanelExpanded } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + TablerIcons.Tune, + contentDescription = null, + tint = colors.accent, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "LoRA Adapters", + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary + ) + Text( + text = if (activeCount > 0) "$activeCount active" else "None loaded", + style = MaterialTheme.typography.labelSmall, + color = if (activeCount > 0) colors.success else colors.textSecondary + ) + } + + // Active adapter dots (compact indicator when collapsed) + if (!isAdaptersPanelExpanded && activeCount > 0) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + loadedAdapters.forEach { adapter -> + val filename = adapter.path.substringAfterLast("/") + val def = ModelService.LORA_ADAPTERS.find { it.filename == filename } + if (def != null) { + Icon( + adapterIcon(def.id), + contentDescription = def.name, + tint = adapterColor(def.id), + modifier = Modifier.size(16.dp) + ) + } + } + } + Spacer(modifier = Modifier.width(4.dp)) + } + + if (loadedAdapters.size > 1) { + IconButton( + onClick = { + scope.launch { + try { + loraError = null + RunAnywhere.clearLoraAdapters() + refreshAdapters() + } catch (e: Exception) { + loraError = "Clear failed: ${e.message}" + } + } + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + TablerIcons.TrashX, + contentDescription = "Clear all", + tint = colors.tintPink, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + Icon( + TablerIcons.ArrowLeft, + contentDescription = if (isAdaptersPanelExpanded) "Collapse" else "Expand", + tint = colors.textSecondary, + modifier = Modifier + .size(20.dp) + .rotate(chevronRotation - 90f) + ) + } + + // Expanded content + AnimatedVisibility(visible = isAdaptersPanelExpanded) { + Column { + HorizontalDivider(color = colors.border, modifier = Modifier.padding(vertical = 8.dp)) + + // Scale slider + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Scale", + style = MaterialTheme.typography.labelMedium, + color = colors.textSecondary + ) + Text( + text = "%.2f".format(adapterScale), + style = MaterialTheme.typography.bodySmall, + color = colors.accent, + fontWeight = FontWeight.Bold + ) + } + + androidx.compose.material3.Slider( + value = adapterScale, + onValueChange = { adapterScale = it }, + valueRange = 0f..2f, + steps = 39, + modifier = Modifier.height(32.dp), + colors = SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.border + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Adapter rows - compact + ModelService.LORA_ADAPTERS.forEach { adapterDef -> + val isDownloading = downloadingAdapters[adapterDef.id] == true + val progress = downloadProgress[adapterDef.id] ?: 0f + val isDownloaded = downloadedAdapters[adapterDef.id] == true + val isLoaded = loadedAdapters.any { + it.path.substringAfterLast("/") == adapterDef.filename + } + val color = adapterColor(adapterDef.id) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + adapterIcon(adapterDef.id), + contentDescription = null, + tint = color, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = adapterDef.name, + style = MaterialTheme.typography.bodyMedium, + color = colors.textPrimary + ) + // Download progress inline + if (isDownloading) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .clip(RoundedCornerShape(1.dp)), + color = color, + trackColor = colors.border, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + + if (isLoaded) { + // Active badge + remove button + Text( + text = "Active", + style = MaterialTheme.typography.labelSmall, + color = colors.success + ) + IconButton( + onClick = { + scope.launch { + try { + val adapterPath = loadedAdapters.find { + it.path.substringAfterLast("/") == adapterDef.filename + }?.path + if (adapterPath != null) { + RunAnywhere.removeLoraAdapter(adapterPath) + refreshAdapters() + } + } catch (e: Exception) { + loraError = "Remove failed: ${e.message}" + } + } + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + TablerIcons.X, + contentDescription = "Remove", + tint = colors.tintPink, + modifier = Modifier.size(14.dp) + ) + } + } else { + // Load/Download button (compact) + AppButton( + onClick = { downloadAndLoadAdapter(adapterDef) }, + enabled = !isLoadingAdapter && !isDownloading, + color = color, + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + if (isDownloaded) TablerIcons.Tune else TablerIcons.Download, + null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + if (isDownloaded) "Load" else "Get", + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + } + + // Custom adapter + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + AppOutlinedButton( + onClick = { filePickerLauncher.launch(arrayOf("*/*")) }, + enabled = !isLoadingAdapter, + ) { + Icon(TablerIcons.Plus, null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Custom .gguf", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + + // Error display + loraError?.let { error -> + Text( + text = error, + color = colors.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp) + ) + } + + // Chat section + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + if (messages.isEmpty()) { + item { + EmptyChat( + icon = { + Icon( + imageVector = if (loadedAdapters.isNotEmpty()) TablerIcons.Sparkles else TablerIcons.Tune, + contentDescription = null, + tint = if (loadedAdapters.isNotEmpty()) colors.tintPurple else colors.tintCyan, + modifier = Modifier.size(48.dp) + ) + }, + title = if (loadedAdapters.isNotEmpty()) "LoRA adapter active" else "No adapters loaded", + subtitle = if (loadedAdapters.isNotEmpty()) + "Try an example prompt or type your own!" + else + "Expand the panel above to load a LoRA adapter" + ) + + // Example prompt chips in the chat area + if (currentExamplePrompts.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + currentExamplePrompts.forEach { prompt -> + Surface( + shape = RoundedCornerShape(12.dp), + color = colors.tintCyan.copy(alpha = 0.1f), + modifier = Modifier.clickable { sendMessage(prompt) } + ) { + Text( + text = prompt, + style = MaterialTheme.typography.bodySmall, + color = colors.tintCyan, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } + + items(messages) { message -> + MessageBubble( + text = message.text, + isUser = message.isUser, + accentColor = colors.tintCyan + ) + } + } + } + } +} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/SpeechToTextScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/SpeechToTextScreen.kt index 94ce0c6..e5ea903 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/SpeechToTextScreen.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/SpeechToTextScreen.kt @@ -7,31 +7,51 @@ import android.media.AudioRecord import android.media.MediaRecorder import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Mic -import androidx.compose.material.icons.rounded.Stop -import androidx.compose.material3.* -import androidx.compose.runtime.* +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.runanywhere.kotlin_starter_example.services.ModelService +import com.runanywhere.kotlin_starter_example.ui.components.AppButton +import com.runanywhere.kotlin_starter_example.ui.components.AppCard +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold import com.runanywhere.kotlin_starter_example.ui.components.ModelLoaderWidget -import com.runanywhere.kotlin_starter_example.ui.theme.* +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme import com.runanywhere.sdk.public.RunAnywhere import com.runanywhere.sdk.public.extensions.transcribe import kotlinx.coroutines.Dispatchers @@ -39,410 +59,167 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream -// Audio recording helper class private class AudioRecorder { private var audioRecord: AudioRecord? = null private var isRecording = false private val audioData = ByteArrayOutputStream() - + companion object { - const val SAMPLE_RATE = 16000 // 16kHz for STT + const val SAMPLE_RATE = 16000 const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT } - + fun startRecording(): Boolean { val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT) - if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { - return false - } - + if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) return false try { - audioRecord = AudioRecord( - MediaRecorder.AudioSource.MIC, - SAMPLE_RATE, - CHANNEL_CONFIG, - AUDIO_FORMAT, - bufferSize * 2 - ) - - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - return false - } - + audioRecord = AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, bufferSize * 2) + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) return false audioData.reset() audioRecord?.startRecording() isRecording = true - - // Start reading audio in a thread Thread { val buffer = ByteArray(bufferSize) while (isRecording) { val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0 - if (read > 0) { - synchronized(audioData) { - audioData.write(buffer, 0, read) - } - } + if (read > 0) synchronized(audioData) { audioData.write(buffer, 0, read) } } }.start() - return true - } catch (e: SecurityException) { - return false - } + } catch (e: SecurityException) { return false } } - + fun stopRecording(): ByteArray { isRecording = false audioRecord?.stop() audioRecord?.release() audioRecord = null - - synchronized(audioData) { - return audioData.toByteArray() - } + synchronized(audioData) { return audioData.toByteArray() } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SpeechToTextScreen( onNavigateBack: () -> Unit, modelService: ModelService = viewModel(), modifier: Modifier = Modifier ) { + val colors = AppTheme.colors var isRecording by remember { mutableStateOf(false) } var isTranscribing by remember { mutableStateOf(false) } var transcription by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } var hasPermission by remember { mutableStateOf(false) } - - // Audio recorder instance val audioRecorder = remember { AudioRecorder() } - val context = LocalContext.current val scope = rememberCoroutineScope() - - // Check permission + LaunchedEffect(Unit) { - hasPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.RECORD_AUDIO - ) == PackageManager.PERMISSION_GRANTED + hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED } - - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> + + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> hasPermission = isGranted - if (!isGranted) { - errorMessage = "Microphone permission is required for speech recognition" - } + if (!isGranted) errorMessage = "Microphone permission is required for speech recognition" } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Speech to Text") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = PrimaryDark - ) - ) - }, - containerColor = PrimaryDark - ) { padding -> + + AppScaffold(title = "Speech to Text", subtitle = "Whisper STT", onBack = onNavigateBack) { Column( - modifier = modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(24.dp), + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Model loader section if (!modelService.isSTTLoaded) { - ModelLoaderWidget( - modelName = "Whisper Tiny", - isDownloading = modelService.isSTTDownloading, - isLoading = modelService.isSTTLoading, - isLoaded = modelService.isSTTLoaded, - downloadProgress = modelService.sttDownloadProgress, - onLoadClick = { modelService.downloadAndLoadSTT() } - ) - Spacer(modifier = Modifier.height(24.dp)) + ModelLoaderWidget(modelName = "Whisper Tiny", isDownloading = modelService.isSTTDownloading, isLoading = modelService.isSTTLoading, isLoaded = modelService.isSTTLoaded, downloadProgress = modelService.sttDownloadProgress, onLoadClick = { modelService.downloadAndLoadSTT() }) + Spacer(modifier = Modifier.height(20.dp)) } - - // Permission check + if (!hasPermission) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Microphone permission required", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - ) { - Text("Grant Permission") - } + AppCard { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Microphone permission required", style = MaterialTheme.typography.titleMedium, color = colors.textPrimary) + Spacer(modifier = Modifier.height(10.dp)) + AppButton(onClick = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { Text("Grant Permission") } } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(20.dp)) } - - Spacer(modifier = Modifier.height(32.dp)) - - // Recording button with animation + + Spacer(modifier = Modifier.height(24.dp)) + if (modelService.isSTTLoaded && hasPermission) { - RecordingButton( - isRecording = isRecording, - isTranscribing = isTranscribing, - onClick = { - if (!isRecording && !isTranscribing) { - // Start recording - scope.launch { - try { - val started = withContext(Dispatchers.IO) { - audioRecorder.startRecording() - } - if (started) { - isRecording = true - errorMessage = null - } else { - errorMessage = "Failed to start audio recording" - } - } catch (e: Exception) { - errorMessage = "Recording failed: ${e.message}" - } - } - } else if (isRecording) { - // Stop recording and transcribe - isRecording = false - isTranscribing = true - scope.launch { - try { - // Stop recording and get audio data - val audioData = withContext(Dispatchers.IO) { - audioRecorder.stopRecording() - } - - if (audioData.isEmpty()) { - errorMessage = "No audio recorded" - return@launch - } - - // Transcribe using RunAnywhere SDK - val result = withContext(Dispatchers.IO) { - RunAnywhere.transcribe(audioData) - } - - if (result.isNotBlank()) { - transcription = result - errorMessage = null - } else { - errorMessage = "No speech detected" - } - } catch (e: Exception) { - errorMessage = "Transcription failed: ${e.message}" - } finally { - isTranscribing = false - } - } + RecordingButton(isRecording = isRecording, isTranscribing = isTranscribing, onClick = { + if (!isRecording && !isTranscribing) { + scope.launch { + try { + val started = withContext(Dispatchers.IO) { audioRecorder.startRecording() } + if (started) { isRecording = true; errorMessage = null } else errorMessage = "Failed to start audio recording" + } catch (e: Exception) { errorMessage = "Recording failed: ${e.message}" } + } + } else if (isRecording) { + isRecording = false; isTranscribing = true + scope.launch { + try { + val audioData = withContext(Dispatchers.IO) { audioRecorder.stopRecording() } + if (audioData.isEmpty()) { errorMessage = "No audio recorded"; return@launch } + val result = withContext(Dispatchers.IO) { RunAnywhere.transcribe(audioData) } + if (result.isNotBlank()) { transcription = result; errorMessage = null } else errorMessage = "No speech detected" + } catch (e: Exception) { errorMessage = "Transcription failed: ${e.message}" } finally { isTranscribing = false } } } - ) - - Spacer(modifier = Modifier.height(16.dp)) - + }) + Spacer(modifier = Modifier.height(14.dp)) Text( - text = when { - isRecording -> "Tap to stop recording" - isTranscribing -> "Transcribing..." - else -> "Tap to start recording" - }, - style = MaterialTheme.typography.bodyLarge, - color = when { - isRecording -> AccentViolet - isTranscribing -> AccentCyan - else -> TextMuted - } + text = when { isRecording -> "Tap to stop recording"; isTranscribing -> "Transcribing..."; else -> "Tap to start recording" }, + style = MaterialTheme.typography.bodyMedium, + color = when { isRecording -> colors.tintPurple; isTranscribing -> colors.accent; else -> colors.textSecondary } ) } - - Spacer(modifier = Modifier.height(32.dp)) - - // Transcription result + + Spacer(modifier = Modifier.height(28.dp)) + if (transcription.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = SurfaceCard - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "Transcription", - style = MaterialTheme.typography.titleMedium, - color = AccentCyan - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = transcription, - style = MaterialTheme.typography.bodyLarge, - color = TextPrimary - ) - } + AppCard { + Text("Transcription", style = MaterialTheme.typography.titleMedium, color = colors.accent) + Spacer(modifier = Modifier.height(10.dp)) + Text(transcription, style = MaterialTheme.typography.bodyLarge, color = colors.textPrimary) } } - - // Error message + errorMessage?.let { error -> - Spacer(modifier = Modifier.height(16.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Text( - text = error, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } + Spacer(modifier = Modifier.height(14.dp)) + AppCard { Text(error, style = MaterialTheme.typography.bodyMedium, color = colors.error) } } - - // Info card + if (modelService.isSTTLoaded && hasPermission) { - Spacer(modifier = Modifier.height(32.dp)) - InfoCard() + Spacer(modifier = Modifier.height(28.dp)) + AppCard { + Text("How it works", style = MaterialTheme.typography.titleMedium, color = colors.textPrimary) + Spacer(modifier = Modifier.height(10.dp)) + Text("Tap the microphone to start recording\nSpeak clearly into your device\nTap the stop button when finished\nView your transcribed text below", style = MaterialTheme.typography.bodyMedium, color = colors.textSecondary) + } } } } } @Composable -private fun RecordingButton( - isRecording: Boolean, - isTranscribing: Boolean, - onClick: () -> Unit -) { +private fun RecordingButton(isRecording: Boolean, isTranscribing: Boolean, onClick: () -> Unit) { + val colors = AppTheme.colors val infiniteTransition = rememberInfiniteTransition(label = "pulse") - val scale by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = if (isRecording) 1.1f else 1f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "scale" - ) - - Box( - modifier = Modifier.size(120.dp), - contentAlignment = Alignment.Center - ) { - // Outer glow effect when recording - if (isRecording) { - Box( - modifier = Modifier - .size(120.dp) - .scale(scale) - .background( - brush = Brush.radialGradient( - colors = listOf( - AccentViolet.copy(alpha = 0.3f), - Color.Transparent - ) - ), - shape = CircleShape - ) - ) - } - - // Button - FloatingActionButton( - onClick = onClick, - modifier = Modifier.size(80.dp), - containerColor = when { - isRecording -> AccentViolet - isTranscribing -> AccentCyan - else -> AccentCyan - }, - contentColor = Color.White - ) { - if (isTranscribing) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = Color.White - ) - } else { - Icon( - imageVector = if (isRecording) Icons.Rounded.Stop else Icons.Rounded.Mic, - contentDescription = if (isRecording) "Stop" else "Record", - modifier = Modifier.size(32.dp) - ) - } - } - } -} + val scale by infiniteTransition.animateFloat(1f, if (isRecording) 1.08f else 1f, infiniteRepeatable(tween(1000, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "scale") -@Composable -private fun InfoCard() { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = SurfaceCard.copy(alpha = 0.5f) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "How it works", - style = MaterialTheme.typography.titleMedium, - color = TextPrimary - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "• Tap the microphone to start recording\n" + - "• Speak clearly into your device\n" + - "• Tap the stop button when finished\n" + - "• View your transcribed text below", - style = MaterialTheme.typography.bodyMedium, - color = TextMuted + Box(modifier = Modifier.size(100.dp), contentAlignment = Alignment.Center) { + if (isRecording) Box(modifier = Modifier.size(100.dp).scale(scale).background(colors.tintPurple.copy(alpha = 0.15f), CircleShape)) + IconButton( + onClick = onClick, modifier = Modifier.size(72.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = when { isRecording -> colors.tintPurple; isTranscribing -> colors.accent; else -> colors.accent }, + contentColor = Color.White ) + ) { + if (isTranscribing) CircularProgressIndicator(Modifier.size(28.dp), Color.White, strokeWidth = 2.5.dp) + else Icon(if (isRecording) TablerIcons.PlayerStop else TablerIcons.Microphone, if (isRecording) "Stop" else "Record", Modifier.size(28.dp)) } } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/SplashScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/SplashScreen.kt new file mode 100644 index 0000000..69c6fff --- /dev/null +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/SplashScreen.kt @@ -0,0 +1,122 @@ +package com.runanywhere.kotlin_starter_example.ui.screens + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme +import kotlinx.coroutines.delay +import kotlin.math.hypot + +@Composable +fun SplashScreen( + onSplashComplete: () -> Unit +) { + val colors = AppTheme.colors + + // Circular reveal animation progress (0 -> 1) + val revealProgress = remember { Animatable(0f) } + // Content fade-in alpha + val contentAlpha = remember { Animatable(0f) } + // Content fade-out alpha + val fadeOutAlpha = remember { Animatable(1f) } + + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() } + val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } + // Max radius = diagonal from center to corner + val maxRadius = hypot(screenWidthPx / 2f, screenHeightPx / 2f) + + LaunchedEffect(Unit) { + // Start circular reveal from edges to center + revealProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 900, easing = FastOutSlowInEasing) + ) + // Fade in the text content + contentAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + ) + // Hold for a moment + delay(800) + // Fade out everything + fadeOutAlpha.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) + ) + onSplashComplete() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent) + .alpha(fadeOutAlpha.value), + contentAlignment = Alignment.Center + ) { + // Circular reveal canvas - circle shrinks from edges to center (inverted reveal) + // At progress=0, the entire screen is covered with accent color + // At progress=1, the circle has shrunk to nothing, revealing the background + Canvas(modifier = Modifier.fillMaxSize()) { + val centerX = size.width / 2f + val centerY = size.height / 2f + + // Draw the background first + drawRect(color = colors.background) + + // Draw accent-colored circle that shrinks from max radius to 0 + val currentRadius = maxRadius * (1f - revealProgress.value) + if (currentRadius > 0f) { + drawCircle( + color = colors.accent, + radius = currentRadius, + center = Offset(centerX, centerY) + ) + } + } + + // Center text content + Column( + modifier = Modifier.alpha(contentAlpha.value), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "RunAnywhere", + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp + ), + color = colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "On-Device AI", + style = MaterialTheme.typography.bodyLarge, + color = colors.textSecondary + ) + } + } +} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/TextToSpeechScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/TextToSpeechScreen.kt index 6bdff0a..4e56844 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/TextToSpeechScreen.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/TextToSpeechScreen.kt @@ -3,28 +3,54 @@ package com.runanywhere.kotlin_starter_example.ui.screens import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioTrack -import androidx.compose.animation.core.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.runanywhere.kotlin_starter_example.services.ModelService +import com.runanywhere.kotlin_starter_example.ui.components.AppCard +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold import com.runanywhere.kotlin_starter_example.ui.components.ModelLoaderWidget -import com.runanywhere.kotlin_starter_example.ui.theme.* +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme import com.runanywhere.sdk.public.RunAnywhere import com.runanywhere.sdk.public.extensions.synthesize import com.runanywhere.sdk.public.extensions.TTS.TTSOptions @@ -34,381 +60,92 @@ import kotlinx.coroutines.withContext import java.nio.ByteBuffer import java.nio.ByteOrder -/** - * Play WAV audio data using AudioTrack - */ private suspend fun playWavAudio(wavData: ByteArray) = withContext(Dispatchers.IO) { - // Parse WAV header to get audio parameters - if (wavData.size < 44) return@withContext // Invalid WAV - + if (wavData.size < 44) return@withContext val buffer = ByteBuffer.wrap(wavData).order(ByteOrder.LITTLE_ENDIAN) - - // Skip RIFF header (12 bytes) and fmt chunk header (8 bytes) = 20 bytes buffer.position(20) - val audioFormat = buffer.short.toInt() // Should be 1 for PCM - val numChannels = buffer.short.toInt() - val sampleRate = buffer.int - buffer.int // byteRate - buffer.short // blockAlign - val bitsPerSample = buffer.short.toInt() - - // Find data chunk + buffer.short.toInt(); val numChannels = buffer.short.toInt(); val sampleRate = buffer.int; buffer.int; buffer.short; val bitsPerSample = buffer.short.toInt() var dataOffset = 36 - while (dataOffset < wavData.size - 8) { - if (wavData[dataOffset].toInt().toChar() == 'd' && - wavData[dataOffset + 1].toInt().toChar() == 'a' && - wavData[dataOffset + 2].toInt().toChar() == 't' && - wavData[dataOffset + 3].toInt().toChar() == 'a') { - break - } - dataOffset++ - } - dataOffset += 8 // Skip "data" and size - + while (dataOffset < wavData.size - 8) { if (wavData[dataOffset].toInt().toChar() == 'd' && wavData[dataOffset + 1].toInt().toChar() == 'a' && wavData[dataOffset + 2].toInt().toChar() == 't' && wavData[dataOffset + 3].toInt().toChar() == 'a') break; dataOffset++ } + dataOffset += 8 if (dataOffset >= wavData.size) return@withContext - val pcmData = wavData.copyOfRange(dataOffset, wavData.size) - - val channelConfig = if (numChannels == 1) - AudioFormat.CHANNEL_OUT_MONO - else - AudioFormat.CHANNEL_OUT_STEREO - - val audioFormatConfig = if (bitsPerSample == 16) - AudioFormat.ENCODING_PCM_16BIT - else - AudioFormat.ENCODING_PCM_8BIT - + val channelConfig = if (numChannels == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO + val audioFormatConfig = if (bitsPerSample == 16) AudioFormat.ENCODING_PCM_16BIT else AudioFormat.ENCODING_PCM_8BIT val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormatConfig) - - val audioTrack = AudioTrack.Builder() - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(sampleRate) - .setEncoding(audioFormatConfig) - .setChannelMask(channelConfig) - .build() - ) - .setBufferSizeInBytes(maxOf(minBufferSize, pcmData.size)) - .setTransferMode(AudioTrack.MODE_STATIC) - .build() - - audioTrack.write(pcmData, 0, pcmData.size) - audioTrack.play() - - // Wait for playback to complete - val durationMs = (pcmData.size.toLong() * 1000) / (sampleRate * numChannels * (bitsPerSample / 8)) - Thread.sleep(durationMs + 100) // Add small buffer - - audioTrack.stop() - audioTrack.release() + val audioTrack = AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build()).setAudioFormat(AudioFormat.Builder().setSampleRate(sampleRate).setEncoding(audioFormatConfig).setChannelMask(channelConfig).build()).setBufferSizeInBytes(maxOf(minBufferSize, pcmData.size)).setTransferMode(AudioTrack.MODE_STATIC).build() + audioTrack.write(pcmData, 0, pcmData.size); audioTrack.play() + Thread.sleep((pcmData.size.toLong() * 1000) / (sampleRate * numChannels * (bitsPerSample / 8)) + 100) + audioTrack.stop(); audioTrack.release() } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun TextToSpeechScreen( - onNavigateBack: () -> Unit, - modelService: ModelService = viewModel(), - modifier: Modifier = Modifier -) { +fun TextToSpeechScreen(onNavigateBack: () -> Unit, modelService: ModelService = viewModel(), modifier: Modifier = Modifier) { + val colors = AppTheme.colors var inputText by remember { mutableStateOf("Hello! This is a test of the text-to-speech system.") } var isSpeaking by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Text to Speech") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = PrimaryDark - ) - ) - }, - containerColor = PrimaryDark - ) { padding -> - Column( - modifier = modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Model loader section + + AppScaffold(title = "Text to Speech", subtitle = "Piper TTS", onBack = onNavigateBack) { + Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) { if (!modelService.isTTSLoaded) { - ModelLoaderWidget( - modelName = "Piper TTS", - isDownloading = modelService.isTTSDownloading, - isLoading = modelService.isTTSLoading, - isLoaded = modelService.isTTSLoaded, - downloadProgress = modelService.ttsDownloadProgress, - onLoadClick = { modelService.downloadAndLoadTTS() } - ) - Spacer(modifier = Modifier.height(24.dp)) + ModelLoaderWidget(modelName = "Piper TTS", isDownloading = modelService.isTTSDownloading, isLoading = modelService.isTTSLoading, isLoaded = modelService.isTTSLoaded, downloadProgress = modelService.ttsDownloadProgress, onLoadClick = { modelService.downloadAndLoadTTS() }) + Spacer(modifier = Modifier.height(20.dp)) } - - // Text input if (modelService.isTTSLoaded) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = SurfaceCard - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "Enter text to speak", - style = MaterialTheme.typography.titleMedium, - color = AccentPink - ) - Spacer(modifier = Modifier.height(12.dp)) - TextField( - value = inputText, - onValueChange = { inputText = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Type something...") }, - enabled = !isSpeaking, - colors = TextFieldDefaults.colors( - focusedContainerColor = PrimaryMid, - unfocusedContainerColor = PrimaryMid, - disabledContainerColor = PrimaryMid, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - shape = RoundedCornerShape(12.dp), - minLines = 4, - maxLines = 8 - ) - } + AppCard { + Text("Enter text to speak", style = MaterialTheme.typography.titleMedium, color = colors.tintPink) + Spacer(modifier = Modifier.height(10.dp)) + TextField(value = inputText, onValueChange = { inputText = it }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("Type something...", color = colors.textTertiary) }, enabled = !isSpeaking, + colors = TextFieldDefaults.colors(focusedContainerColor = colors.surfaceContainer, unfocusedContainerColor = colors.surfaceContainer, disabledContainerColor = colors.surfaceContainer, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, cursorColor = colors.tintPink), + shape = RoundedCornerShape(12.dp), minLines = 4, maxLines = 8, textStyle = MaterialTheme.typography.bodyMedium.copy(color = colors.textPrimary)) } - - Spacer(modifier = Modifier.height(32.dp)) - - // Speak button with animation - SpeakButton( - isSpeaking = isSpeaking, - onClick = { - if (!isSpeaking && inputText.isNotBlank()) { - isSpeaking = true - scope.launch { - try { - // Synthesize audio - val output = withContext(Dispatchers.IO) { - RunAnywhere.synthesize(inputText, TTSOptions()) - } - - // Play the synthesized audio - playWavAudio(output.audioData) - - errorMessage = null - } catch (e: Exception) { - errorMessage = "TTS failed: ${e.message}" - } finally { - isSpeaking = false - } - } - } + Spacer(modifier = Modifier.height(28.dp)) + SpeakButton(isSpeaking = isSpeaking, onClick = { + if (!isSpeaking && inputText.isNotBlank()) { isSpeaking = true + scope.launch { try { val output = withContext(Dispatchers.IO) { RunAnywhere.synthesize(inputText, TTSOptions()) }; playWavAudio(output.audioData); errorMessage = null } catch (e: Exception) { errorMessage = "TTS failed: ${e.message}" } finally { isSpeaking = false } } } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = if (isSpeaking) "Speaking..." else "Tap to speak", - style = MaterialTheme.typography.bodyLarge, - color = if (isSpeaking) AccentPink else TextMuted - ) - } - - // Error message - errorMessage?.let { error -> - Spacer(modifier = Modifier.height(16.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Text( - text = error, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } + }) + Spacer(modifier = Modifier.height(14.dp)) + Text(if (isSpeaking) "Speaking..." else "Tap to speak", style = MaterialTheme.typography.bodyMedium, color = if (isSpeaking) colors.tintPink else colors.textSecondary) } - - // Sample texts + errorMessage?.let { Spacer(modifier = Modifier.height(14.dp)); AppCard { Text(it, style = MaterialTheme.typography.bodyMedium, color = colors.error) } } if (modelService.isTTSLoaded) { - Spacer(modifier = Modifier.height(32.dp)) - SampleTextsCard { sampleText -> - inputText = sampleText - } - - Spacer(modifier = Modifier.height(24.dp)) - InfoCard() + Spacer(modifier = Modifier.height(28.dp)) + SampleTextsCard { inputText = it } + Spacer(modifier = Modifier.height(20.dp)) + AppCard { Text("How it works", style = MaterialTheme.typography.titleMedium, color = colors.textPrimary); Spacer(Modifier.height(10.dp)); Text("Enter text in the field above\nOr select a sample text\nTap the speaker button to hear it\nAll processing happens on-device", style = MaterialTheme.typography.bodyMedium, color = colors.textSecondary) } } } } } @Composable -private fun SpeakButton( - isSpeaking: Boolean, - onClick: () -> Unit -) { +private fun SpeakButton(isSpeaking: Boolean, onClick: () -> Unit) { + val colors = AppTheme.colors val infiniteTransition = rememberInfiniteTransition(label = "pulse") - val scale by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = if (isSpeaking) 1.1f else 1f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "scale" - ) - - Box( - modifier = Modifier.size(120.dp), - contentAlignment = Alignment.Center - ) { - // Outer glow effect when speaking - if (isSpeaking) { - Box( - modifier = Modifier - .size(120.dp) - .scale(scale) - .background( - brush = Brush.radialGradient( - colors = listOf( - AccentPink.copy(alpha = 0.3f), - Color.Transparent - ) - ), - shape = CircleShape - ) - ) - } - - // Button - FloatingActionButton( - onClick = onClick, - modifier = Modifier.size(80.dp), - containerColor = if (isSpeaking) AccentViolet else AccentPink, - contentColor = Color.White - ) { - Icon( - imageVector = if (isSpeaking) Icons.Rounded.VolumeUp else Icons.Rounded.VolumeUp, - contentDescription = "Speak", - modifier = Modifier.size(32.dp) - ) + val scale by infiniteTransition.animateFloat(1f, if (isSpeaking) 1.08f else 1f, infiniteRepeatable(tween(1000, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "scale") + Box(modifier = Modifier.size(100.dp), contentAlignment = Alignment.Center) { + if (isSpeaking) Box(modifier = Modifier.size(100.dp).scale(scale).background(colors.tintPink.copy(alpha = 0.15f), CircleShape)) + IconButton(onClick = onClick, modifier = Modifier.size(72.dp), colors = IconButtonDefaults.iconButtonColors(containerColor = if (isSpeaking) colors.tintPink.copy(alpha = 0.8f) else colors.tintPink, contentColor = Color.White)) { + Icon(TablerIcons.Volume, "Speak", Modifier.size(28.dp)) } } } @Composable private fun SampleTextsCard(onSelectSample: (String) -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = SurfaceCard.copy(alpha = 0.5f) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "Sample Texts", - style = MaterialTheme.typography.titleMedium, - color = TextPrimary - ) - Spacer(modifier = Modifier.height(12.dp)) - - val samples = listOf( - "Hello! This is a test of the text-to-speech system.", - "The quick brown fox jumps over the lazy dog.", - "Artificial intelligence is transforming how we interact with technology.", - "Welcome to the future of on-device AI processing." - ) - - samples.forEach { sample -> - TextButton( - onClick = { onSelectSample(sample) }, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - Icon( - imageVector = Icons.Rounded.TextFields, - contentDescription = null, - tint = AccentPink, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = sample.take(50) + if (sample.length > 50) "..." else "", - style = MaterialTheme.typography.bodyMedium, - color = TextMuted - ) - } - } + val colors = AppTheme.colors + AppCard { + Text("Sample Texts", style = MaterialTheme.typography.titleMedium, color = colors.textPrimary) + Spacer(modifier = Modifier.height(10.dp)) + listOf("Hello! This is a test of the text-to-speech system.", "The quick brown fox jumps over the lazy dog.", "Artificial intelligence is transforming how we interact with technology.", "Welcome to the future of on-device AI processing.").forEach { sample -> + Row(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable { onSelectSample(sample) }.padding(vertical = 8.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(TablerIcons.Typography, null, tint = colors.tintPink, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(sample.take(50) + if (sample.length > 50) "..." else "", style = MaterialTheme.typography.bodySmall, color = colors.textSecondary) } } } } - -@Composable -private fun InfoCard() { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = SurfaceCard.copy(alpha = 0.5f) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ) { - Text( - text = "How it works", - style = MaterialTheme.typography.titleMedium, - color = TextPrimary - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "• Enter text in the field above\n" + - "• Or select a sample text\n" + - "• Tap the speaker button to hear it\n" + - "• All processing happens on-device", - style = MaterialTheme.typography.bodyMedium, - color = TextMuted - ) - } - } -} diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ToolCallingScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ToolCallingScreen.kt index bf9adee..1d28cb4 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ToolCallingScreen.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/ToolCallingScreen.kt @@ -1,34 +1,64 @@ package com.runanywhere.kotlin_starter_example.ui.screens -import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.Send -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.runanywhere.kotlin_starter_example.services.ModelService +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold +import com.runanywhere.kotlin_starter_example.ui.components.ChatInputBar +import com.runanywhere.kotlin_starter_example.ui.components.MessageBubble import com.runanywhere.kotlin_starter_example.ui.components.ModelLoaderWidget -import com.runanywhere.kotlin_starter_example.ui.theme.* -import com.runanywhere.sdk.public.extensions.LLM.* +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme +import com.runanywhere.sdk.public.extensions.LLM.RunAnywhereToolCalling +import com.runanywhere.sdk.public.extensions.LLM.ToolCallingOptions +import com.runanywhere.sdk.public.extensions.LLM.ToolDefinition +import com.runanywhere.sdk.public.extensions.LLM.ToolParameter +import com.runanywhere.sdk.public.extensions.LLM.ToolParameterType +import com.runanywhere.sdk.public.extensions.LLM.ToolValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -37,11 +67,10 @@ import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale +import java.util.TimeZone -/** - * Tool Call Info for UI display - */ data class ToolCallInfo( val toolName: String, val arguments: String, @@ -50,9 +79,6 @@ data class ToolCallInfo( val success: Boolean = true ) -/** - * Chat message that may contain tool calls - */ data class ToolChatMessage( val text: String, val isUser: Boolean, @@ -60,49 +86,44 @@ data class ToolChatMessage( val timestamp: Long = System.currentTimeMillis() ) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ToolCallingScreen( onNavigateBack: () -> Unit, modelService: ModelService = viewModel(), modifier: Modifier = Modifier ) { + val colors = AppTheme.colors var messages by remember { mutableStateOf(listOf()) } var inputText by remember { mutableStateOf("") } var isGenerating by remember { mutableStateOf(false) } var toolsRegistered by remember { mutableStateOf(false) } var selectedToolCall by remember { mutableStateOf(null) } - + val scope = rememberCoroutineScope() val listState = rememberLazyListState() - - // Suggestion prompts grouped by tool + val suggestionPrompts = remember { listOf( - // Weather tool "What's the weather in Tokyo?", "How's the weather in London?", "Is it raining in New York?", - // Time tool "What time is it?", "What's the current date and time?", - // Calculator tool "Calculate 15 * 7 + 23", "What is (100 - 37) / 9?", "Compute 2.5 * 4 + 10", ) } - - // Send message handler shared by input field and suggestion chips + val sendMessage: (String) -> Unit = { text -> if (text.isNotBlank() && !isGenerating) { messages = messages + ToolChatMessage(text, isUser = true) inputText = "" - + scope.launch { isGenerating = true listState.animateScrollToItem(messages.size) - + try { val result = RunAnywhereToolCalling.generateWithTools( prompt = text, @@ -113,7 +134,7 @@ fun ToolCallingScreen( maxTokens = 512 ) ) - + val toolCallInfos = result.toolCalls.mapIndexed { index, call -> val toolResult = result.toolResults.getOrNull(index) ToolCallInfo( @@ -126,7 +147,7 @@ fun ToolCallingScreen( success = toolResult?.success ?: false ) } - + messages = messages + ToolChatMessage( text = result.text, isUser = false, @@ -144,165 +165,103 @@ fun ToolCallingScreen( } } } - - // Register tools on first composition + LaunchedEffect(Unit) { if (!toolsRegistered) { registerDemoTools() toolsRegistered = true } } - - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("Tool Calling") - Text( - text = "LLM + Function Execution", - style = MaterialTheme.typography.bodySmall, - color = AccentCyan + + AppScaffold( + title = "Tool Calling", + subtitle = "LLM + Function Execution", + onBack = onNavigateBack, + bottomBar = { + if (modelService.isLLMLoaded) { + Column { + // Suggestion chips + if (!isGenerating && messages.isNotEmpty()) { + ToolSuggestionChipsRow( + suggestions = suggestionPrompts, + onSuggestionClick = sendMessage ) } - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = PrimaryDark - ) - ) - }, - containerColor = PrimaryDark - ) { padding -> - Column( - modifier = modifier - .fillMaxSize() - .padding(padding) - ) { - // Model loader section - if (!modelService.isLLMLoaded) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - ModelLoaderWidget( - modelName = "SmolLM2 360M", - isDownloading = modelService.isLLMDownloading, - isLoading = modelService.isLLMLoading, - isLoaded = modelService.isLLMLoaded, - downloadProgress = modelService.llmDownloadProgress, - onLoadClick = { modelService.downloadAndLoadLLM() } + + ChatInputBar( + value = inputText, + onValueChange = { inputText = it }, + onSend = { sendMessage(inputText) }, + isGenerating = isGenerating, + placeholder = "Try: What's the weather in Tokyo?", + accentColor = colors.tintOrange ) - - modelService.errorMessage?.let { error -> - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 4.dp) - ) - } } } - - // Tools info card - if (modelService.isLLMLoaded && toolsRegistered) { - ToolsInfoCard() - } - - // Chat messages - LazyColumn( - state = listState, + } + ) { + // Model loader + if (!modelService.isLLMLoaded) { + Column( modifier = Modifier - .weight(1f) .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(vertical = 16.dp) + .padding(16.dp) ) { - if (messages.isEmpty() && modelService.isLLMLoaded) { - item { - ToolCallingEmptyState( - suggestions = suggestionPrompts, - enabled = !isGenerating, - onSuggestionClick = sendMessage - ) - } - } - - items(messages) { message -> - ToolChatMessageBubble( - message = message, - onToolCallClick = { selectedToolCall = it } + ModelLoaderWidget( + modelName = "SmolLM2 360M", + isDownloading = modelService.isLLMDownloading, + isLoading = modelService.isLLMLoading, + isLoaded = modelService.isLLMLoaded, + downloadProgress = modelService.llmDownloadProgress, + onLoadClick = { modelService.downloadAndLoadLLM() } + ) + + modelService.errorMessage?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = colors.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) ) } } - - // Input section with suggestion chips - if (modelService.isLLMLoaded) { - // Suggestion chips row (scrollable, always visible when not generating) - if (!isGenerating && messages.isNotEmpty()) { - SuggestionChipsRow( + } + + // Tools info card + if (modelService.isLLMLoaded && toolsRegistered) { + ToolsInfoCard() + } + + // Chat messages + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + if (messages.isEmpty() && modelService.isLLMLoaded) { + item { + ToolCallingEmptyState( suggestions = suggestionPrompts, + enabled = !isGenerating, onSuggestionClick = sendMessage ) } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = SurfaceCard.copy(alpha = 0.8f), - shadowElevation = 8.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.Bottom - ) { - TextField( - value = inputText, - onValueChange = { inputText = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Try: What's the weather in Tokyo?") }, - readOnly = isGenerating, - colors = TextFieldDefaults.colors( - focusedContainerColor = PrimaryMid, - unfocusedContainerColor = PrimaryMid, - disabledContainerColor = PrimaryMid, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - shape = RoundedCornerShape(12.dp), - maxLines = 4 - ) - - Spacer(modifier = Modifier.width(8.dp)) - - FloatingActionButton( - onClick = { sendMessage(inputText) }, - containerColor = if (isGenerating) AccentViolet else if (inputText.isBlank()) TextMuted else AccentOrange - ) { - if (isGenerating) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White - ) - } else { - Icon(Icons.AutoMirrored.Rounded.Send, "Send") - } - } - } - } + } + + items(messages) { message -> + ToolChatMessageBubble( + message = message, + onToolCallClick = { selectedToolCall = it } + ) } } } - + // Tool call detail sheet selectedToolCall?.let { toolCall -> ToolCallDetailSheet( @@ -314,40 +273,35 @@ fun ToolCallingScreen( @Composable private fun ToolsInfoCard() { - Card( + val colors = AppTheme.colors + + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = AccentOrange.copy(alpha = 0.1f) - ) + .padding(horizontal = 16.dp, vertical = 8.dp) + .background(colors.tintOrange.copy(alpha = 0.08f), RoundedCornerShape(12.dp)) + .border(0.5.dp, colors.tintOrange.copy(alpha = 0.15f), RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Build, - contentDescription = null, - tint = AccentOrange, - modifier = Modifier.size(20.dp) + Icon( + imageVector = TablerIcons.Tool, + contentDescription = null, + tint = colors.tintOrange, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "3 Tools Available", + style = MaterialTheme.typography.labelLarge, + color = colors.textPrimary + ) + Text( + text = "Weather · Time · Calculator", + style = MaterialTheme.typography.bodySmall, + color = colors.textSecondary ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = "3 Tools Available", - style = MaterialTheme.typography.labelLarge, - color = TextPrimary - ) - Text( - text = "Weather • Time • Calculator", - style = MaterialTheme.typography.bodySmall, - color = TextMuted - ) - } } } } @@ -358,6 +312,8 @@ private fun ToolCallingEmptyState( enabled: Boolean, onSuggestionClick: (String) -> Unit ) { + val colors = AppTheme.colors + Column( modifier = Modifier .fillMaxWidth() @@ -365,89 +321,66 @@ private fun ToolCallingEmptyState( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - imageVector = Icons.Rounded.Build, + imageVector = TablerIcons.Tool, contentDescription = null, - tint = AccentOrange, - modifier = Modifier.size(64.dp) + tint = colors.tintOrange, + modifier = Modifier.size(48.dp) ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Tool Calling Demo", style = MaterialTheme.typography.titleLarge, - color = TextPrimary + color = colors.textPrimary ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) Text( text = "Tap a suggestion or type your own prompt", style = MaterialTheme.typography.bodyMedium, - color = TextMuted + color = colors.textSecondary ) - Spacer(modifier = Modifier.height(24.dp)) - - // Weather suggestions - SuggestionCategory( - icon = Icons.Rounded.Cloud, - label = "Weather", - color = AccentCyan, + Spacer(modifier = Modifier.height(20.dp)) + + ToolSuggestionCategory( + icon = TablerIcons.Cloud, label = "Weather", color = colors.tintCyan, prompts = suggestions.filter { it.lowercase().contains("weather") || it.lowercase().contains("rain") }, - enabled = enabled, - onSuggestionClick = onSuggestionClick + enabled = enabled, onSuggestionClick = onSuggestionClick ) - Spacer(modifier = Modifier.height(12.dp)) - - // Time suggestions - SuggestionCategory( - icon = Icons.Rounded.Schedule, - label = "Time", - color = AccentViolet, + ToolSuggestionCategory( + icon = TablerIcons.Clock, label = "Time", color = colors.tintPurple, prompts = suggestions.filter { it.lowercase().contains("time") || it.lowercase().contains("date") }, - enabled = enabled, - onSuggestionClick = onSuggestionClick + enabled = enabled, onSuggestionClick = onSuggestionClick ) - Spacer(modifier = Modifier.height(12.dp)) - - // Calculator suggestions - SuggestionCategory( - icon = Icons.Rounded.Calculate, - label = "Calculator", - color = AccentGreen, + ToolSuggestionCategory( + icon = TablerIcons.Calculator, label = "Calculator", color = colors.tintGreen, prompts = suggestions.filter { it.lowercase().contains("calc") || it.lowercase().contains("compute") || it.lowercase().contains("what is") }, - enabled = enabled, - onSuggestionClick = onSuggestionClick + enabled = enabled, onSuggestionClick = onSuggestionClick ) } } @Composable -private fun SuggestionCategory( - icon: androidx.compose.ui.graphics.vector.ImageVector, +private fun ToolSuggestionCategory( + icon: ImageVector, label: String, color: Color, prompts: List, enabled: Boolean, onSuggestionClick: (String) -> Unit ) { + val colors = AppTheme.colors + Column(modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 6.dp) ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = color, - modifier = Modifier.size(16.dp) - ) + Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(14.dp)) Spacer(modifier = Modifier.width(6.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = color - ) + Text(text = label, style = MaterialTheme.typography.labelMedium, color = color) } - + Row( modifier = Modifier .fillMaxWidth() @@ -455,66 +388,48 @@ private fun SuggestionCategory( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { prompts.forEach { prompt -> - SuggestionChip( - text = prompt, - accentColor = color, - enabled = enabled, - onClick = { onSuggestionClick(prompt) } - ) + ToolSuggestionChip(text = prompt, accentColor = color, enabled = enabled, onClick = { onSuggestionClick(prompt) }) } } } } @Composable -private fun SuggestionChip( - text: String, - accentColor: Color, - enabled: Boolean, - onClick: () -> Unit -) { +private fun ToolSuggestionChip(text: String, accentColor: Color, enabled: Boolean, onClick: () -> Unit) { + val colors = AppTheme.colors + Surface( modifier = Modifier .clip(RoundedCornerShape(20.dp)) .clickable(enabled = enabled) { onClick() }, - color = SurfaceCard, + color = colors.surfaceContainer, shape = RoundedCornerShape(20.dp), ) { Text( text = text, modifier = Modifier - .border(1.dp, accentColor.copy(alpha = 0.3f), RoundedCornerShape(20.dp)) - .padding(horizontal = 16.dp, vertical = 10.dp), + .border(0.5.dp, accentColor.copy(alpha = 0.2f), RoundedCornerShape(20.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), style = MaterialTheme.typography.bodySmall, - color = if (enabled) TextPrimary else TextMuted, + color = if (enabled) colors.textPrimary else colors.textTertiary, maxLines = 1, ) } } -/** - * Horizontally scrollable suggestion chips shown above the input bar - * after the user has sent at least one message. - */ @Composable -private fun SuggestionChipsRow( - suggestions: List, - onSuggestionClick: (String) -> Unit -) { +private fun ToolSuggestionChipsRow(suggestions: List, onSuggestionClick: (String) -> Unit) { + val colors = AppTheme.colors + Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { suggestions.forEach { prompt -> - SuggestionChip( - text = prompt, - accentColor = AccentOrange, - enabled = true, - onClick = { onSuggestionClick(prompt) } - ) + ToolSuggestionChip(text = prompt, accentColor = colors.tintOrange, enabled = true, onClick = { onSuggestionClick(prompt) }) } } } @@ -524,6 +439,8 @@ private fun ToolChatMessageBubble( message: ToolChatMessage, onToolCallClick: (ToolCallInfo) -> Unit ) { + val colors = AppTheme.colors + Column { // Tool call indicators if (message.toolCalls.isNotEmpty()) { @@ -542,54 +459,56 @@ private fun ToolChatMessageBubble( } } } - + // Message bubble Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start ) { if (!message.isUser) { - Icon( - imageVector = Icons.Rounded.SmartToy, - contentDescription = null, - tint = AccentOrange, + Box( modifier = Modifier - .size(32.dp) - .padding(top = 4.dp) - ) + .size(28.dp) + .background(colors.tintOrange.copy(alpha = 0.12f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(TablerIcons.Robot, null, tint = colors.tintOrange, modifier = Modifier.size(14.dp)) + } Spacer(modifier = Modifier.width(8.dp)) } - + Card( - modifier = Modifier.widthIn(max = 280.dp), + modifier = Modifier.widthIn(max = 290.dp), shape = RoundedCornerShape( - topStart = if (message.isUser) 16.dp else 4.dp, - topEnd = if (message.isUser) 4.dp else 16.dp, - bottomStart = 16.dp, - bottomEnd = 16.dp + topStart = if (message.isUser) 14.dp else 4.dp, + topEnd = if (message.isUser) 4.dp else 14.dp, + bottomStart = 14.dp, + bottomEnd = 14.dp ), colors = CardDefaults.cardColors( - containerColor = if (message.isUser) AccentCyan else SurfaceCard - ) + containerColor = if (message.isUser) colors.tintOrange else colors.surfaceElevated + ), + border = if (message.isUser) null else androidx.compose.foundation.BorderStroke(0.5.dp, colors.border), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Text( text = message.text, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), style = MaterialTheme.typography.bodyMedium, - color = if (message.isUser) Color.White else TextPrimary + color = if (message.isUser) Color.White else colors.textPrimary ) } - + if (message.isUser) { Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Rounded.Person, - contentDescription = null, - tint = AccentViolet, + Box( modifier = Modifier - .size(32.dp) - .padding(top = 4.dp) - ) + .size(28.dp) + .background(colors.tintPurple.copy(alpha = 0.12f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(TablerIcons.User, null, tint = colors.tintPurple, modifier = Modifier.size(14.dp)) + } } } } @@ -601,36 +520,27 @@ private fun ToolCallIndicator( onTap: () -> Unit, modifier: Modifier = Modifier ) { - val backgroundColor = if (toolCallInfo.success) { - AccentGreen.copy(alpha = 0.1f) - } else { - AccentPink.copy(alpha = 0.1f) - } - - val borderColor = if (toolCallInfo.success) { - AccentGreen.copy(alpha = 0.3f) - } else { - AccentPink.copy(alpha = 0.3f) - } - - val iconTint = if (toolCallInfo.success) AccentGreen else AccentPink + val colors = AppTheme.colors + val bgColor = if (toolCallInfo.success) colors.success.copy(alpha = 0.08f) else colors.tintPink.copy(alpha = 0.08f) + val borderColor = if (toolCallInfo.success) colors.success.copy(alpha = 0.2f) else colors.tintPink.copy(alpha = 0.2f) + val iconTint = if (toolCallInfo.success) colors.success else colors.tintPink Surface( modifier = modifier .clip(RoundedCornerShape(8.dp)) .clickable { onTap() }, - color = backgroundColor, + color = bgColor, shape = RoundedCornerShape(8.dp), ) { Row( modifier = Modifier .border(0.5.dp, borderColor, RoundedCornerShape(8.dp)) - .padding(horizontal = 10.dp, vertical = 6.dp), + .padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), ) { Icon( - imageVector = if (toolCallInfo.success) Icons.Rounded.CheckCircle else Icons.Rounded.Warning, + imageVector = if (toolCallInfo.success) TablerIcons.CircleCheck else TablerIcons.AlertTriangle, contentDescription = null, modifier = Modifier.size(12.dp), tint = iconTint, @@ -638,7 +548,7 @@ private fun ToolCallIndicator( Text( text = toolCallInfo.toolName, style = MaterialTheme.typography.labelSmall, - color = TextMuted, + color = colors.textSecondary, maxLines = 1, ) } @@ -651,108 +561,83 @@ private fun ToolCallDetailSheet( toolCallInfo: ToolCallInfo, onDismiss: () -> Unit ) { + val colors = AppTheme.colors val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = SurfaceCard, + containerColor = colors.surfaceElevated, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - // Header Text( text = "Tool Call Details", style = MaterialTheme.typography.headlineSmall, - color = TextPrimary, + color = colors.textPrimary, ) - - // Status + Row( modifier = Modifier .fillMaxWidth() .background( - if (toolCallInfo.success) AccentGreen.copy(alpha = 0.1f) - else AccentPink.copy(alpha = 0.1f), + if (toolCallInfo.success) colors.success.copy(alpha = 0.08f) else colors.tintPink.copy(alpha = 0.08f), RoundedCornerShape(12.dp) ) - .padding(16.dp), + .padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( - imageVector = if (toolCallInfo.success) Icons.Rounded.CheckCircle else Icons.Rounded.Cancel, + imageVector = if (toolCallInfo.success) TablerIcons.CircleCheck else TablerIcons.CircleX, contentDescription = null, - modifier = Modifier.size(24.dp), - tint = if (toolCallInfo.success) AccentGreen else AccentPink, + modifier = Modifier.size(22.dp), + tint = if (toolCallInfo.success) colors.success else colors.tintPink, ) Text( text = if (toolCallInfo.success) "Success" else "Failed", style = MaterialTheme.typography.titleMedium, - color = TextPrimary, + color = colors.textPrimary, ) } - - // Tool name - DetailRow(title = "Tool", content = toolCallInfo.toolName) - - // Arguments - CodeBlock(title = "Arguments", code = toolCallInfo.arguments) - - // Result - toolCallInfo.result?.let { result -> - CodeBlock(title = "Result", code = result) - } - - // Error - toolCallInfo.error?.let { error -> - DetailRow(title = "Error", content = error, isError = true) - } - - Spacer(modifier = Modifier.height(16.dp)) + + ToolDetailRow(title = "Tool", content = toolCallInfo.toolName) + ToolCodeBlock(title = "Arguments", code = toolCallInfo.arguments) + toolCallInfo.result?.let { result -> ToolCodeBlock(title = "Result", code = result) } + toolCallInfo.error?.let { error -> ToolDetailRow(title = "Error", content = error, isError = true) } } } } @Composable -private fun DetailRow(title: String, content: String, isError: Boolean = false) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - color = TextMuted, - ) - Text( - text = content, - style = MaterialTheme.typography.bodyMedium, - color = if (isError) AccentPink else TextPrimary, - ) +private fun ToolDetailRow(title: String, content: String, isError: Boolean = false) { + val colors = AppTheme.colors + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(text = title, style = MaterialTheme.typography.labelMedium, color = colors.textSecondary) + Text(text = content, style = MaterialTheme.typography.bodyMedium, color = if (isError) colors.tintPink else colors.textPrimary) } } @Composable -private fun CodeBlock(title: String, code: String) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - color = TextMuted, - ) +private fun ToolCodeBlock(title: String, code: String) { + val colors = AppTheme.colors + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(text = title, style = MaterialTheme.typography.labelMedium, color = colors.textSecondary) Box( modifier = Modifier .fillMaxWidth() - .background(PrimaryMid, RoundedCornerShape(8.dp)) + .background(colors.surfaceContainer, RoundedCornerShape(8.dp)) .padding(12.dp) ) { Text( text = code, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = AccentCyan, + color = colors.tintBlue, ) } } @@ -763,7 +648,6 @@ private fun CodeBlock(title: String, code: String) { // ============================================================================= private suspend fun registerDemoTools() { - // Weather Tool RunAnywhereToolCalling.registerTool( definition = ToolDefinition( name = "get_weather", @@ -783,8 +667,7 @@ private suspend fun registerDemoTools() { fetchWeather(location) } ) - - // Time Tool + RunAnywhereToolCalling.registerTool( definition = ToolDefinition( name = "get_current_time", @@ -797,7 +680,7 @@ private suspend fun registerDemoTools() { val dateFormatter = SimpleDateFormat("EEEE, MMMM d, yyyy 'at' h:mm:ss a", Locale.getDefault()) val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) val tz = TimeZone.getDefault() - + mapOf( "datetime" to ToolValue.string(dateFormatter.format(now)), "time" to ToolValue.string(timeFormatter.format(now)), @@ -806,8 +689,7 @@ private suspend fun registerDemoTools() { ) } ) - - // Calculator Tool + RunAnywhereToolCalling.registerTool( definition = ToolDefinition( name = "calculate", @@ -833,38 +715,34 @@ private suspend fun fetchWeather(location: String): Map { return withContext(Dispatchers.IO) { try { withTimeout(15_000L) { - // Geocode the location val geocodeUrl = "https://geocoding-api.open-meteo.com/v1/search?name=${URLEncoder.encode(location, "UTF-8")}&count=1" val geocodeResponse = fetchUrl(geocodeUrl) - + val latMatch = Regex("\"latitude\":\\s*(-?\\d+\\.?\\d*)").find(geocodeResponse) val lonMatch = Regex("\"longitude\":\\s*(-?\\d+\\.?\\d*)").find(geocodeResponse) val nameMatch = Regex("\"name\":\\s*\"([^\"]+)\"").find(geocodeResponse) - + if (latMatch == null || lonMatch == null) { - return@withTimeout mapOf( - "error" to ToolValue.string("Location not found: $location") - ) + return@withTimeout mapOf("error" to ToolValue.string("Location not found: $location")) } - + val lat = latMatch.groupValues[1] val lon = lonMatch.groupValues[1] val resolvedName = nameMatch?.groupValues?.get(1) ?: location - - // Fetch weather + val weatherUrl = "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m" val weatherResponse = fetchUrl(weatherUrl) - + val tempMatch = Regex("\"temperature_2m\":\\s*(-?\\d+\\.?\\d*)").find(weatherResponse) val humidityMatch = Regex("\"relative_humidity_2m\":\\s*(\\d+)").find(weatherResponse) val windMatch = Regex("\"wind_speed_10m\":\\s*(-?\\d+\\.?\\d*)").find(weatherResponse) val codeMatch = Regex("\"weather_code\":\\s*(\\d+)").find(weatherResponse) - + val temperature = tempMatch?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 val humidity = humidityMatch?.groupValues?.get(1)?.toIntOrNull() ?: 0 val windSpeed = windMatch?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 val weatherCode = codeMatch?.groupValues?.get(1)?.toIntOrNull() ?: 0 - + val condition = when (weatherCode) { 0 -> "Clear sky" 1, 2, 3 -> "Partly cloudy" @@ -876,7 +754,7 @@ private suspend fun fetchWeather(location: String): Map { 95, 96, 99 -> "Thunderstorm" else -> "Unknown" } - + mapOf( "location" to ToolValue.string(resolvedName), "temperature_celsius" to ToolValue.number(temperature), @@ -910,10 +788,10 @@ private fun evaluateMathExpression(expression: String): Map { val cleaned = expression .replace("=", "") .replace("x", "*") - .replace("×", "*") - .replace("÷", "/") + .replace("\u00d7", "*") + .replace("\u00f7", "/") .trim() - + val result = evaluateSimpleExpression(cleaned) mapOf( "result" to ToolValue.number(result), @@ -933,7 +811,7 @@ private fun evaluateSimpleExpression(expr: String): Double { private fun tokenize(expr: String): List { val tokens = mutableListOf() var current = StringBuilder() - + for (char in expr) { when { char.isDigit() || char == '.' -> current.append(char) @@ -993,7 +871,7 @@ private fun parseFactor(parser: TokenParser): Double { return when { token == "(" -> { val result = parseExpression(parser) - if (parser.hasNext()) parser.next() // consume ')' + if (parser.hasNext()) parser.next() result } token == "-" -> -parseFactor(parser) diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VisionScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VisionScreen.kt index e143319..1fccd80 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VisionScreen.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VisionScreen.kt @@ -9,18 +9,37 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,12 +47,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.runanywhere.kotlin_starter_example.services.ModelService +import com.runanywhere.kotlin_starter_example.ui.components.AppButton +import com.runanywhere.kotlin_starter_example.ui.components.AppCard +import com.runanywhere.kotlin_starter_example.ui.components.AppOutlinedButton +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold import com.runanywhere.kotlin_starter_example.ui.components.ModelLoaderWidget -import com.runanywhere.kotlin_starter_example.ui.theme.* +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme import com.runanywhere.sdk.public.RunAnywhere import com.runanywhere.sdk.public.extensions.VLM.VLMGenerationOptions import com.runanywhere.sdk.public.extensions.VLM.VLMImage @@ -45,13 +67,13 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream -@OptIn(ExperimentalMaterial3Api::class) @Composable fun VisionScreen( onNavigateBack: () -> Unit, modelService: ModelService = viewModel(), modifier: Modifier = Modifier ) { + val colors = AppTheme.colors val context = LocalContext.current var selectedImageUri by remember { mutableStateOf(null) } var selectedBitmap by remember { mutableStateOf(null) } @@ -80,190 +102,183 @@ fun VisionScreen( } } - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("Vision (VLM)") - Text( - text = "Image Understanding", - style = MaterialTheme.typography.bodySmall, - color = AccentPink - ) - } - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = PrimaryDark) - ) - }, - containerColor = PrimaryDark - ) { padding -> - Column( - modifier = modifier - .fillMaxSize() - .padding(padding) - ) { - // Model loader section - if (!modelService.isVLMLoaded) { - Column( + AppScaffold( + title = "Vision", + subtitle = "Image Understanding", + onBack = onNavigateBack, + bottomBar = { + if (modelService.isVLMLoaded) { + Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) - ) { - ModelLoaderWidget( - modelName = "SmolVLM 256M (~365 MB)", - isDownloading = modelService.isVLMDownloading, - isLoading = modelService.isVLMLoading, - isLoaded = modelService.isVLMLoaded, - downloadProgress = modelService.vlmDownloadProgress, - onLoadClick = { modelService.downloadAndLoadVLM() } - ) - - modelService.errorMessage?.let { error -> - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 4.dp) - ) - } - } - } else { - // Main VLM content - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + .background(colors.surface) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - // Image area - ImageArea( - bitmap = selectedBitmap, - onPickImage = { + AppOutlinedButton( + onClick = { photoPickerLauncher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) } - ) + ) { + Icon(TablerIcons.PhotoPlus, contentDescription = "Pick image", modifier = Modifier.size(18.dp)) + } - // Prompt input - PromptInput( - prompt = prompt, - onPromptChange = { prompt = it }, - onQuickPrompt = { prompt = it } - ) + AppButton( + onClick = { + if (isProcessing) { + RunAnywhere.cancelVLMGeneration() + } else { + val path = imageFilePath ?: return@AppButton + scope.launch { + isProcessing = true + description = "" + errorMessage = null + tokensPerSecond = 0f - // Description output - if (description.isNotEmpty() || isProcessing) { - DescriptionArea( - description = description, - isProcessing = isProcessing, - tokensPerSecond = tokensPerSecond + try { + val vlmImage = VLMImage.fromFilePath(path) + val options = VLMGenerationOptions(maxTokens = 300) + val startTime = System.currentTimeMillis() + var tokenCount = 0 + + RunAnywhere.processImageStream(vlmImage, prompt, options) + .collect { token -> + description += token + tokenCount++ + val elapsed = System.currentTimeMillis() - startTime + if (elapsed > 0) { + tokensPerSecond = tokenCount * 1000f / elapsed + } + } + } catch (e: Exception) { + errorMessage = "VLM Error: ${e.message}" + } finally { + isProcessing = false + } + } + } + }, + enabled = selectedBitmap != null || isProcessing, + color = if (isProcessing) colors.error else colors.tintPink, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = if (isProcessing) TablerIcons.PlayerStop else TablerIcons.Eye, + contentDescription = null, + modifier = Modifier.size(18.dp) ) + Text(if (isProcessing) "Stop" else "Analyze Image") } - - // Error - errorMessage?.let { VisionErrorView(it) } } + } + } + ) { + // Model loader + if (!modelService.isVLMLoaded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + ModelLoaderWidget( + modelName = "SmolVLM 256M (~365 MB)", + isDownloading = modelService.isVLMDownloading, + isLoading = modelService.isVLMLoading, + isLoaded = modelService.isVLMLoaded, + downloadProgress = modelService.vlmDownloadProgress, + onLoadClick = { modelService.downloadAndLoadVLM() } + ) - // Bottom action bar - ActionBar( - hasImage = selectedBitmap != null, - isProcessing = isProcessing, + modelService.errorMessage?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = colors.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } else { + // Main VLM content + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Image area + VisionImageArea( + bitmap = selectedBitmap, onPickImage = { photoPickerLauncher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) - }, - onAnalyze = { - val path = imageFilePath ?: return@ActionBar - scope.launch { - isProcessing = true - description = "" - errorMessage = null - tokensPerSecond = 0f - - try { - val vlmImage = VLMImage.fromFilePath(path) - val options = VLMGenerationOptions(maxTokens = 300) - val startTime = System.currentTimeMillis() - var tokenCount = 0 - - RunAnywhere.processImageStream(vlmImage, prompt, options) - .collect { token -> - description += token - tokenCount++ - val elapsed = System.currentTimeMillis() - startTime - if (elapsed > 0) { - tokensPerSecond = tokenCount * 1000f / elapsed - } - } - } catch (e: Exception) { - errorMessage = "VLM Error: ${e.message}" - } finally { - isProcessing = false - } - } - }, - onStop = { - RunAnywhere.cancelVLMGeneration() } ) + + // Prompt input + VisionPromptInput( + prompt = prompt, + onPromptChange = { prompt = it }, + onQuickPrompt = { prompt = it } + ) + + // Description output + if (description.isNotEmpty() || isProcessing) { + VisionDescriptionArea( + description = description, + isProcessing = isProcessing, + tokensPerSecond = tokensPerSecond + ) + } + + // Error + errorMessage?.let { VisionErrorView(it) } } } } } @Composable -private fun ImageArea(bitmap: Bitmap?, onPickImage: () -> Unit) { - Card( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 200.dp, max = 350.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = SurfaceCard) - ) { +private fun VisionImageArea(bitmap: Bitmap?, onPickImage: () -> Unit) { + val colors = AppTheme.colors + + AppCard { if (bitmap != null) { Image( bitmap = bitmap.asImageBitmap(), contentDescription = "Selected image", modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)), + .heightIn(min = 160.dp, max = 300.dp) + .clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Fit ) } else { Column( modifier = Modifier .fillMaxWidth() - .padding(40.dp), + .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( - imageVector = Icons.Rounded.PhotoLibrary, + imageVector = TablerIcons.Photo, contentDescription = null, - tint = AccentPink.copy(alpha = 0.5f), - modifier = Modifier.size(48.dp) + tint = colors.tintPink.copy(alpha = 0.4f), + modifier = Modifier.size(40.dp) ) Text( text = "Select an image to analyze", style = MaterialTheme.typography.bodyMedium, - color = TextMuted + color = colors.textSecondary ) - Button( - onClick = onPickImage, - colors = ButtonDefaults.buttonColors(containerColor = AccentPink) - ) { - Icon(Icons.Rounded.AddPhotoAlternate, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) + AppButton(onClick = onPickImage, color = colors.tintPink) { + Icon(TablerIcons.PhotoPlus, contentDescription = null, modifier = Modifier.size(18.dp)) Text("Choose Photo") } } @@ -272,34 +287,37 @@ private fun ImageArea(bitmap: Bitmap?, onPickImage: () -> Unit) { } @Composable -private fun PromptInput( +private fun VisionPromptInput( prompt: String, onPromptChange: (String) -> Unit, onQuickPrompt: (String) -> Unit ) { + val colors = AppTheme.colors + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Prompt", style = MaterialTheme.typography.labelLarge, - color = TextMuted + color = colors.textSecondary ) TextField( value = prompt, onValueChange = onPromptChange, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Ask about the image...") }, + placeholder = { Text("Ask about the image...", color = colors.textTertiary) }, colors = TextFieldDefaults.colors( - focusedContainerColor = SurfaceCard, - unfocusedContainerColor = SurfaceCard, + focusedContainerColor = colors.surfaceContainer, + unfocusedContainerColor = colors.surfaceContainer, focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent + unfocusedIndicatorColor = Color.Transparent, + cursorColor = colors.tintPink ), shape = RoundedCornerShape(12.dp), - maxLines = 3 + maxLines = 3, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = colors.textPrimary) ) - // Quick prompts Row( modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -314,14 +332,14 @@ private fun PromptInput( modifier = Modifier .clip(RoundedCornerShape(20.dp)) .clickable { onQuickPrompt(text) }, - color = AccentPink.copy(alpha = 0.1f), + color = colors.tintPink.copy(alpha = 0.08f), shape = RoundedCornerShape(20.dp) ) { Text( text = text, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), style = MaterialTheme.typography.bodySmall, - color = AccentPink + color = colors.tintPink ) } } @@ -330,11 +348,13 @@ private fun PromptInput( } @Composable -private fun DescriptionArea( +private fun VisionDescriptionArea( description: String, isProcessing: Boolean, tokensPerSecond: Float ) { + val colors = AppTheme.colors + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -344,7 +364,7 @@ private fun DescriptionArea( Text( text = "AI Description", style = MaterialTheme.typography.labelLarge, - color = TextMuted + color = colors.textSecondary ) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -354,29 +374,24 @@ private fun DescriptionArea( Text( text = String.format("%.1f tok/s", tokensPerSecond), style = MaterialTheme.typography.bodySmall, - color = AccentPink + color = colors.tintPink ) } if (isProcessing) { CircularProgressIndicator( - modifier = Modifier.size(16.dp), + modifier = Modifier.size(14.dp), strokeWidth = 2.dp, - color = AccentPink + color = colors.tintPink ) } } } - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = SurfaceCard) - ) { + AppCard { Text( text = description.ifEmpty { "Analyzing..." }, - modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium, - color = TextPrimary + color = colors.textPrimary ) } } @@ -384,86 +399,30 @@ private fun DescriptionArea( @Composable private fun VisionErrorView(message: String) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = Color(0x1AEF4444)) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } -} + val colors = AppTheme.colors -@Composable -private fun ActionBar( - hasImage: Boolean, - isProcessing: Boolean, - onPickImage: () -> Unit, - onAnalyze: () -> Unit, - onStop: () -> Unit -) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = PrimaryDark.copy(alpha = 0.9f), - shadowElevation = 8.dp + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.error.copy(alpha = 0.08f), RoundedCornerShape(12.dp)) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Pick image button - OutlinedButton( - onClick = onPickImage, - modifier = Modifier.size(48.dp), - contentPadding = PaddingValues(0.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = AccentPink), - border = ButtonDefaults.outlinedButtonBorder(enabled = true) - ) { - Icon(Icons.Rounded.AddPhotoAlternate, contentDescription = "Pick image") - } - - // Analyze / Stop button - Button( - onClick = { if (isProcessing) onStop() else onAnalyze() }, - modifier = Modifier - .weight(1f) - .height(48.dp), - enabled = hasImage || isProcessing, - colors = ButtonDefaults.buttonColors( - containerColor = if (isProcessing) MaterialTheme.colorScheme.error else AccentPink - ) - ) { - Icon( - imageVector = if (isProcessing) Icons.Rounded.Stop else Icons.Rounded.RemoveRedEye, - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (isProcessing) "Stop" else "Analyze Image") - } - } + Icon( + imageVector = TablerIcons.AlertTriangle, + contentDescription = null, + tint = colors.error, + modifier = Modifier.size(18.dp) + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = colors.error + ) } } -/** - * Load an image from a content URI, decode it, and save to a temp file - * so the VLM can access it via file path. - */ private suspend fun loadImageFromUri(context: Context, uri: Uri): Pair { return withContext(Dispatchers.IO) { try { @@ -471,7 +430,6 @@ private suspend fun loadImageFromUri(context: Context, uri: Uri): Pair bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VoicePipelineScreen.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VoicePipelineScreen.kt index c88bcdd..6542bc0 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VoicePipelineScreen.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/screens/VoicePipelineScreen.kt @@ -9,58 +9,78 @@ import android.media.AudioTrack import android.media.MediaRecorder import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import com.runanywhere.kotlin_starter_example.ui.icons.TablerIcons +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.runanywhere.kotlin_starter_example.services.ModelService +import com.runanywhere.kotlin_starter_example.ui.components.AppButton +import com.runanywhere.kotlin_starter_example.ui.components.AppCard +import com.runanywhere.kotlin_starter_example.ui.components.AppScaffold +import com.runanywhere.kotlin_starter_example.ui.components.EmptyChat import com.runanywhere.kotlin_starter_example.ui.components.ModelLoaderWidget -import com.runanywhere.kotlin_starter_example.ui.theme.* +import com.runanywhere.kotlin_starter_example.ui.theme.AppTheme import com.runanywhere.sdk.public.RunAnywhere import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionConfig import com.runanywhere.sdk.public.extensions.VoiceAgent.VoiceSessionEvent import com.runanywhere.sdk.public.extensions.streamVoiceSession -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* - -/** - * Voice Pipeline Screen - Full STT → LLM → TTS with automatic silence detection - * - * This screen demonstrates the simplest way to use RunAnywhere's voice pipeline. - * All the business logic (silence detection, STT→LLM→TTS orchestration) is handled - * by the SDK's streamVoiceSession API. - * - * The app only needs to: - * 1. Capture audio and provide it as a Flow - * 2. Collect VoiceSessionEvent to update UI - * 3. Play audio when TurnCompleted event is received - */ +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext enum class VoiceSessionState { - IDLE, - LISTENING, - SPEECH_DETECTED, - PROCESSING, - SPEAKING + IDLE, LISTENING, SPEECH_DETECTED, PROCESSING, SPEAKING } data class VoiceMessage( @@ -69,29 +89,23 @@ data class VoiceMessage( val timestamp: Long = System.currentTimeMillis() ) -/** - * Simple audio capture that emits chunks as a Flow. - * This is all the app needs to provide - the SDK handles everything else. - */ private class AudioCaptureService { private var audioRecord: AudioRecord? = null - + @Volatile private var isCapturing = false - + companion object { const val SAMPLE_RATE = 16000 - const val CHUNK_SIZE_MS = 100 // Emit chunks every 100ms + const val CHUNK_SIZE_MS = 100 } - + fun startCapture(): Flow = callbackFlow { val bufferSize = AudioRecord.getMinBufferSize( - SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) val chunkSize = (SAMPLE_RATE * 2 * CHUNK_SIZE_MS) / 1000 - + try { audioRecord = AudioRecord( MediaRecorder.AudioSource.MIC, @@ -100,15 +114,15 @@ private class AudioCaptureService { AudioFormat.ENCODING_PCM_16BIT, maxOf(bufferSize, chunkSize * 2) ) - + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { close(IllegalStateException("AudioRecord initialization failed")) return@callbackFlow } - + audioRecord?.startRecording() isCapturing = true - + val readJob = launch(Dispatchers.IO) { val buffer = ByteArray(chunkSize) while (isActive && isCapturing) { @@ -118,7 +132,7 @@ private class AudioCaptureService { } } } - + awaitClose { readJob.cancel() stopCapture() @@ -128,7 +142,7 @@ private class AudioCaptureService { close(e) } } - + fun stopCapture() { isCapturing = false try { @@ -139,23 +153,20 @@ private class AudioCaptureService { } } -/** - * Play WAV audio using AudioTrack - */ private suspend fun playWavAudio(wavData: ByteArray) = withContext(Dispatchers.IO) { if (wavData.size < 44) return@withContext - - val headerSize = if (wavData.size > 44 && - wavData[0] == 'R'.code.toByte() && + + val headerSize = if (wavData.size > 44 && + wavData[0] == 'R'.code.toByte() && wavData[1] == 'I'.code.toByte()) 44 else 0 - + val pcmData = wavData.copyOfRange(headerSize, wavData.size) val sampleRate = 22050 - + val bufferSize = AudioTrack.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT ) - + val audioTrack = AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() @@ -173,148 +184,112 @@ private suspend fun playWavAudio(wavData: ByteArray) = withContext(Dispatchers.I .setBufferSizeInBytes(maxOf(bufferSize, pcmData.size)) .setTransferMode(AudioTrack.MODE_STATIC) .build() - + audioTrack.write(pcmData, 0, pcmData.size) audioTrack.play() - + val durationMs = (pcmData.size.toLong() * 1000) / (sampleRate * 2) delay(durationMs + 100) - + audioTrack.stop() audioTrack.release() } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun VoicePipelineScreen( onNavigateBack: () -> Unit, modelService: ModelService = viewModel(), modifier: Modifier = Modifier ) { + val colors = AppTheme.colors var sessionState by remember { mutableStateOf(VoiceSessionState.IDLE) } var messages by remember { mutableStateOf(listOf()) } var errorMessage by remember { mutableStateOf(null) } var hasPermission by remember { mutableStateOf(false) } var audioLevel by remember { mutableFloatStateOf(0f) } - + val audioCaptureService = remember { AudioCaptureService() } - + val context = LocalContext.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() - - // Voice session job + var sessionJob by remember { mutableStateOf(null) } - - // Check permission + LaunchedEffect(Unit) { hasPermission = ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED } - + val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> hasPermission = isGranted if (!isGranted) errorMessage = "Microphone permission is required" } - - /** - * Start voice session using the SDK's streamVoiceSession API. - * - * This is the key integration point - the SDK handles all the business logic: - * - Silence detection - * - STT → LLM → TTS orchestration - * - Continuous conversation mode - */ + fun startSession() { sessionState = VoiceSessionState.LISTENING errorMessage = null messages = messages + VoiceMessage("Listening... speak and pause to send", "status") scope.launch { listState.animateScrollToItem(messages.size) } - - // Get audio capture flow + val audioFlow = audioCaptureService.startCapture() - - // Configure voice session + val config = VoiceSessionConfig( - silenceDuration = 1.5, // 1.5 seconds of silence triggers processing - speechThreshold = 0.1f, // Audio level threshold for speech detection - autoPlayTTS = false, // We'll handle playback ourselves - continuousMode = true // Auto-resume listening after each turn + silenceDuration = 1.5, + speechThreshold = 0.1f, + autoPlayTTS = false, + continuousMode = true ) - - // Start the SDK voice session - all business logic is handled by the SDK + sessionJob = scope.launch { try { RunAnywhere.streamVoiceSession(audioFlow, config).collect { event -> when (event) { - is VoiceSessionEvent.Started -> { - sessionState = VoiceSessionState.LISTENING - } - - is VoiceSessionEvent.Listening -> { - audioLevel = event.audioLevel - } - - is VoiceSessionEvent.SpeechStarted -> { - sessionState = VoiceSessionState.SPEECH_DETECTED - } - + is VoiceSessionEvent.Started -> sessionState = VoiceSessionState.LISTENING + is VoiceSessionEvent.Listening -> audioLevel = event.audioLevel + is VoiceSessionEvent.SpeechStarted -> sessionState = VoiceSessionState.SPEECH_DETECTED is VoiceSessionEvent.Processing -> { sessionState = VoiceSessionState.PROCESSING audioLevel = 0f } - is VoiceSessionEvent.Transcribed -> { messages = messages + VoiceMessage(event.text, "user") listState.animateScrollToItem(messages.size) } - is VoiceSessionEvent.Responded -> { messages = messages + VoiceMessage(event.text, "ai") listState.animateScrollToItem(messages.size) } - - is VoiceSessionEvent.Speaking -> { - sessionState = VoiceSessionState.SPEAKING - } - + is VoiceSessionEvent.Speaking -> sessionState = VoiceSessionState.SPEAKING is VoiceSessionEvent.TurnCompleted -> { - // Play the synthesized audio event.audio?.let { audio -> sessionState = VoiceSessionState.SPEAKING playWavAudio(audio) } - // Resume listening state sessionState = VoiceSessionState.LISTENING audioLevel = 0f } - is VoiceSessionEvent.Stopped -> { sessionState = VoiceSessionState.IDLE audioLevel = 0f } - is VoiceSessionEvent.Error -> { errorMessage = event.message sessionState = VoiceSessionState.IDLE } } } - } catch (e: CancellationException) { - // Expected when stopping + } catch (_: CancellationException) { } catch (e: Exception) { errorMessage = "Session error: ${e.message}" sessionState = VoiceSessionState.IDLE } } } - - /** - * Stop voice session - */ + fun stopSession() { sessionJob?.cancel() sessionJob = null @@ -322,76 +297,40 @@ fun VoicePipelineScreen( sessionState = VoiceSessionState.IDLE audioLevel = 0f } - - // Cleanup on dispose + DisposableEffect(Unit) { onDispose { sessionJob?.cancel() audioCaptureService.stopCapture() } } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Voice Pipeline") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = PrimaryDark) - ) - }, - containerColor = PrimaryDark - ) { padding -> - Column(modifier = modifier.fillMaxSize().padding(padding)) { - val allModelsLoaded = modelService.isLLMLoaded && - modelService.isSTTLoaded && - modelService.isTTSLoaded - - // Model loader section - if (!allModelsLoaded) { - ModelLoaderSection(modelService) - } - - // Permission check - if (!hasPermission && allModelsLoaded) { - PermissionCard { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } - } - - // Main content + + val allModelsLoaded = modelService.isLLMLoaded && + modelService.isSTTLoaded && + modelService.isTTSLoaded + + AppScaffold( + title = "Voice Pipeline", + subtitle = "STT + LLM + TTS", + onBack = onNavigateBack, + bottomBar = { if (allModelsLoaded && hasPermission) { - // Messages list - LazyColumn( - state = listState, - modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(vertical = 16.dp) - ) { - if (messages.isEmpty()) { - item { EmptyStateMessage() } - } - items(messages) { message -> VoiceMessageBubble(message) } - } - - // Control section Column( modifier = Modifier .fillMaxWidth() - .background(SurfaceCard.copy(alpha = 0.8f)) - .padding(24.dp), + .background(colors.surface) + .padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Audio level indicator (when listening) + // Audio level indicator if (sessionState == VoiceSessionState.LISTENING || sessionState == VoiceSessionState.SPEECH_DETECTED) { - AudioLevelIndicator(audioLevel, sessionState == VoiceSessionState.SPEECH_DETECTED) - Spacer(modifier = Modifier.height(16.dp)) + VoiceAudioLevelIndicator(audioLevel, sessionState == VoiceSessionState.SPEECH_DETECTED) + Spacer(modifier = Modifier.height(12.dp)) } - - StatusIndicator(sessionState) - Spacer(modifier = Modifier.height(24.dp)) - + + VoiceStatusIndicator(sessionState) + Spacer(modifier = Modifier.height(16.dp)) + VoiceButton( sessionState = sessionState, onClick = { @@ -401,30 +340,94 @@ fun VoicePipelineScreen( } } ) - - Spacer(modifier = Modifier.height(12.dp)) + + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = getVoiceStatusText(sessionState), + style = MaterialTheme.typography.bodyMedium, + color = getVoiceStatusColor(sessionState) + ) + } + } + } + ) { + // Model loader section + if (!allModelsLoaded) { + VoiceModelLoaderSection(modelService) + } + + // Permission check + if (!hasPermission && allModelsLoaded) { + AppCard(modifier = Modifier.padding(16.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( - text = getStatusText(sessionState), - style = MaterialTheme.typography.bodyLarge, - color = getStatusColor(sessionState) + "Microphone permission required", + style = MaterialTheme.typography.titleMedium, + color = colors.textPrimary ) + Spacer(modifier = Modifier.height(10.dp)) + AppButton(onClick = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { + Text("Grant Permission") + } + } + } + } + + // Messages + if (allModelsLoaded && hasPermission) { + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + if (messages.isEmpty()) { + item { + EmptyChat( + icon = { + Icon( + TablerIcons.Sparkles, + null, + tint = colors.tintGreen, + modifier = Modifier.size(48.dp) + ) + }, + title = "Voice Pipeline Ready", + subtitle = "Tap mic to start. Speak, then pause - it auto-detects silence." + ) + } } + items(messages) { message -> VoiceMessageBubble(message) } + } + } + + // Error + errorMessage?.let { error -> + AppCard(modifier = Modifier.padding(16.dp)) { + Text(error, style = MaterialTheme.typography.bodyMedium, color = colors.error) } - - // Error message - errorMessage?.let { ErrorCard(it) } } } } @Composable -private fun ModelLoaderSection(modelService: ModelService) { +private fun VoiceModelLoaderSection(modelService: ModelService) { + val colors = AppTheme.colors + Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Text("Voice Pipeline requires all models", style = MaterialTheme.typography.titleMedium, color = TextPrimary) - + Text("Voice Pipeline requires all models", style = MaterialTheme.typography.titleMedium, color = colors.textPrimary) + ModelLoaderWidget( modelName = "SmolLM2 (LLM)", isDownloading = modelService.isLLMDownloading, @@ -433,7 +436,7 @@ private fun ModelLoaderSection(modelService: ModelService) { downloadProgress = modelService.llmDownloadProgress, onLoadClick = { modelService.downloadAndLoadLLM() } ) - + ModelLoaderWidget( modelName = "Whisper (STT)", isDownloading = modelService.isSTTDownloading, @@ -442,7 +445,7 @@ private fun ModelLoaderSection(modelService: ModelService) { downloadProgress = modelService.sttDownloadProgress, onLoadClick = { modelService.downloadAndLoadSTT() } ) - + ModelLoaderWidget( modelName = "Piper (TTS)", isDownloading = modelService.isTTSDownloading, @@ -451,25 +454,31 @@ private fun ModelLoaderSection(modelService: ModelService) { downloadProgress = modelService.ttsDownloadProgress, onLoadClick = { modelService.downloadAndLoadTTS() } ) - - Button(onClick = { modelService.downloadAndLoadAllModels() }, modifier = Modifier.fillMaxWidth()) { + + AppButton( + onClick = { modelService.downloadAndLoadAllModels() }, + modifier = Modifier.fillMaxWidth() + ) { Text("Load All Models") } } } @Composable -private fun AudioLevelIndicator(audioLevel: Float, isSpeechDetected: Boolean) { +private fun VoiceAudioLevelIndicator(audioLevel: Float, isSpeechDetected: Boolean) { + val colors = AppTheme.colors + Column(horizontalAlignment = Alignment.CenterHorizontally) { - // Recording badge Row( modifier = Modifier - .background(if (isSpeechDetected) AccentGreen.copy(alpha = 0.2f) else Color.Red.copy(alpha = 0.1f), RoundedCornerShape(4.dp)) + .background( + if (isSpeechDetected) colors.success.copy(alpha = 0.12f) else colors.error.copy(alpha = 0.08f), + RoundedCornerShape(4.dp) + ) .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - // Pulsing dot val infiniteTransition = rememberInfiniteTransition(label = "pulse") val pulseAlpha by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 0.5f, @@ -477,30 +486,31 @@ private fun AudioLevelIndicator(audioLevel: Float, isSpeechDetected: Boolean) { label = "dot" ) Box( - modifier = Modifier.size(8.dp).background( - if (isSpeechDetected) AccentGreen.copy(alpha = pulseAlpha) else Color.Red.copy(alpha = pulseAlpha), - CircleShape - ) + modifier = Modifier + .size(6.dp) + .background( + if (isSpeechDetected) colors.success.copy(alpha = pulseAlpha) else colors.error.copy(alpha = pulseAlpha), + CircleShape + ) ) Text( text = if (isSpeechDetected) "SPEECH DETECTED" else "LISTENING", style = MaterialTheme.typography.labelSmall, - color = if (isSpeechDetected) AccentGreen else Color.Red + color = if (isSpeechDetected) colors.success else colors.error ) } - - Spacer(modifier = Modifier.height(8.dp)) - - // Audio level bars - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + + Spacer(modifier = Modifier.height(6.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) { repeat(10) { index -> val isActive = index < (audioLevel * 10).toInt() Box( modifier = Modifier - .width(25.dp) - .height(8.dp) + .width(22.dp) + .height(6.dp) .background( - if (isActive) AccentGreen else TextMuted.copy(alpha = 0.3f), + if (isActive) colors.success else colors.textTertiary.copy(alpha = 0.3f), RoundedCornerShape(2.dp) ) ) @@ -510,106 +520,78 @@ private fun AudioLevelIndicator(audioLevel: Float, isSpeechDetected: Boolean) { } @Composable -private fun PermissionCard(onRequestPermission: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) - ) { - Column(modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text("Microphone permission required", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onRequestPermission) { Text("Grant Permission") } - } - } -} - -@Composable -private fun ErrorCard(error: String) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) - ) { - Text(error, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun StatusIndicator(state: VoiceSessionState) { +private fun VoiceStatusIndicator(state: VoiceSessionState) { + val colors = AppTheme.colors Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - StatusDot("Listen", state == VoiceSessionState.LISTENING || state == VoiceSessionState.SPEECH_DETECTED, AccentCyan) - StatusDot("Process", state == VoiceSessionState.PROCESSING, AccentViolet) - StatusDot("Speak", state == VoiceSessionState.SPEAKING, AccentPink) + VoiceStatusDot("Listen", state == VoiceSessionState.LISTENING || state == VoiceSessionState.SPEECH_DETECTED, colors.tintCyan) + VoiceStatusDot("Process", state == VoiceSessionState.PROCESSING, colors.tintPurple) + VoiceStatusDot("Speak", state == VoiceSessionState.SPEAKING, colors.tintPink) } } @Composable -private fun StatusDot(label: String, isActive: Boolean, color: Color) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(60.dp)) { - Box(modifier = Modifier.size(12.dp).background(if (isActive) color else TextMuted.copy(alpha = 0.3f), CircleShape)) +private fun VoiceStatusDot(label: String, isActive: Boolean, color: Color) { + val colors = AppTheme.colors + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(56.dp)) { + Box( + modifier = Modifier + .size(10.dp) + .background(if (isActive) color else colors.textTertiary.copy(alpha = 0.3f), CircleShape) + ) Spacer(modifier = Modifier.height(4.dp)) - Text(label, style = MaterialTheme.typography.bodySmall, color = if (isActive) color else TextMuted) + Text(label, style = MaterialTheme.typography.labelSmall, color = if (isActive) color else colors.textSecondary) } } @Composable private fun VoiceButton(sessionState: VoiceSessionState, onClick: () -> Unit) { + val colors = AppTheme.colors val infiniteTransition = rememberInfiniteTransition(label = "pulse") val scale by infiniteTransition.animateFloat( initialValue = 1f, - targetValue = if (sessionState != VoiceSessionState.IDLE) 1.1f else 1f, + targetValue = if (sessionState != VoiceSessionState.IDLE) 1.08f else 1f, animationSpec = infiniteRepeatable(tween(1000, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "scale" ) - - Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) { + + Box(modifier = Modifier.size(100.dp), contentAlignment = Alignment.Center) { if (sessionState != VoiceSessionState.IDLE) { Box( - modifier = Modifier.size(120.dp).scale(scale).background( - brush = Brush.radialGradient(listOf(AccentGreen.copy(alpha = 0.3f), Color.Transparent)), - shape = CircleShape - ) + modifier = Modifier + .size(100.dp) + .scale(scale) + .background(colors.tintGreen.copy(alpha = 0.15f), CircleShape) ) } - - FloatingActionButton( + + IconButton( onClick = onClick, - modifier = Modifier.size(80.dp), - containerColor = when (sessionState) { - VoiceSessionState.IDLE -> AccentGreen - VoiceSessionState.LISTENING, VoiceSessionState.SPEECH_DETECTED -> AccentViolet - else -> AccentCyan - }, - contentColor = Color.White + modifier = Modifier.size(72.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = when (sessionState) { + VoiceSessionState.IDLE -> colors.tintGreen + VoiceSessionState.LISTENING, VoiceSessionState.SPEECH_DETECTED -> colors.tintPurple + else -> colors.tintCyan + }, + contentColor = Color.White + ) ) { when (sessionState) { - VoiceSessionState.PROCESSING -> CircularProgressIndicator(Modifier.size(32.dp), Color.White) - VoiceSessionState.SPEAKING -> Icon(Icons.Rounded.VolumeUp, "Speaking", Modifier.size(32.dp)) - VoiceSessionState.IDLE -> Icon(Icons.Rounded.Mic, "Start", Modifier.size(32.dp)) - else -> Icon(Icons.Rounded.Stop, "Stop", Modifier.size(32.dp)) + VoiceSessionState.PROCESSING -> CircularProgressIndicator( + Modifier.size(28.dp), Color.White, strokeWidth = 2.5.dp + ) + VoiceSessionState.SPEAKING -> Icon(TablerIcons.Volume, "Speaking", Modifier.size(28.dp)) + VoiceSessionState.IDLE -> Icon(TablerIcons.Microphone, "Start", Modifier.size(28.dp)) + else -> Icon(TablerIcons.PlayerStop, "Stop", Modifier.size(28.dp)) } } } } -@Composable -private fun EmptyStateMessage() { - Column(modifier = Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Rounded.AutoAwesome, null, tint = AccentGreen, modifier = Modifier.size(64.dp)) - Spacer(modifier = Modifier.height(16.dp)) - Text("Voice Pipeline Ready", style = MaterialTheme.typography.titleLarge, color = TextPrimary) - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Tap mic to start. Speak, then pause - it auto-detects silence and processes.", - style = MaterialTheme.typography.bodyMedium, - color = TextMuted - ) - } -} - @Composable private fun VoiceMessageBubble(message: VoiceMessage) { + val colors = AppTheme.colors + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = when (message.type) { @@ -619,37 +601,58 @@ private fun VoiceMessageBubble(message: VoiceMessage) { } ) { if (message.type == "ai") { - Icon(Icons.Rounded.SmartToy, null, tint = AccentCyan, modifier = Modifier.size(32.dp).padding(top = 4.dp)) + Box( + modifier = Modifier + .size(28.dp) + .background(colors.tintCyan.copy(alpha = 0.12f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(TablerIcons.Robot, null, tint = colors.tintCyan, modifier = Modifier.size(14.dp)) + } Spacer(modifier = Modifier.width(8.dp)) } - - Card( - modifier = Modifier.widthIn(max = if (message.type == "status") 300.dp else 280.dp), + + androidx.compose.material3.Card( + modifier = Modifier.widthIn(max = if (message.type == "status") 300.dp else 290.dp), shape = RoundedCornerShape( - topStart = if (message.type == "user") 16.dp else 4.dp, - topEnd = if (message.type == "user") 4.dp else 16.dp, - bottomStart = 16.dp, bottomEnd = 16.dp + topStart = if (message.type == "user") 14.dp else 4.dp, + topEnd = if (message.type == "user") 4.dp else 14.dp, + bottomStart = 14.dp, bottomEnd = 14.dp ), - colors = CardDefaults.cardColors( + colors = androidx.compose.material3.CardDefaults.cardColors( containerColor = when (message.type) { - "user" -> AccentCyan - "status" -> SurfaceCard.copy(alpha = 0.5f) - else -> SurfaceCard + "user" -> colors.accent + "status" -> colors.surfaceElevated.copy(alpha = 0.5f) + else -> colors.surfaceElevated } - ) + ), + border = if (message.type != "user") androidx.compose.foundation.BorderStroke(0.5.dp, colors.border) else null, + elevation = androidx.compose.material3.CardDefaults.cardElevation(defaultElevation = 0.dp) ) { - Text(message.text, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium, - color = if (message.type == "user") Color.White else TextPrimary) + Text( + message.text, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodyMedium, + color = if (message.type == "user") Color.White else colors.textPrimary + ) } - + if (message.type == "user") { Spacer(modifier = Modifier.width(8.dp)) - Icon(Icons.Rounded.Person, null, tint = AccentViolet, modifier = Modifier.size(32.dp).padding(top = 4.dp)) + Box( + modifier = Modifier + .size(28.dp) + .background(colors.tintPurple.copy(alpha = 0.12f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(TablerIcons.User, null, tint = colors.tintPurple, modifier = Modifier.size(14.dp)) + } } } } -private fun getStatusText(state: VoiceSessionState) = when (state) { +@Composable +private fun getVoiceStatusText(state: VoiceSessionState) = when (state) { VoiceSessionState.IDLE -> "Tap to start" VoiceSessionState.LISTENING -> "Listening... pause to send" VoiceSessionState.SPEECH_DETECTED -> "Speaking detected..." @@ -657,10 +660,14 @@ private fun getStatusText(state: VoiceSessionState) = when (state) { VoiceSessionState.SPEAKING -> "Speaking..." } -private fun getStatusColor(state: VoiceSessionState) = when (state) { - VoiceSessionState.IDLE -> TextMuted - VoiceSessionState.LISTENING -> AccentCyan - VoiceSessionState.SPEECH_DETECTED -> AccentGreen - VoiceSessionState.PROCESSING -> AccentViolet - VoiceSessionState.SPEAKING -> AccentPink +@Composable +private fun getVoiceStatusColor(state: VoiceSessionState): Color { + val colors = AppTheme.colors + return when (state) { + VoiceSessionState.IDLE -> colors.textSecondary + VoiceSessionState.LISTENING -> colors.tintCyan + VoiceSessionState.SPEECH_DETECTED -> colors.success + VoiceSessionState.PROCESSING -> colors.tintPurple + VoiceSessionState.SPEAKING -> colors.tintPink + } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Theme.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Theme.kt index 3cba263..fe51f14 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Theme.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Theme.kt @@ -1,34 +1,145 @@ package com.runanywhere.kotlin_starter_example.ui.theme +import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat -// Color palette matching Flutter app -val PrimaryDark = Color(0xFF0A0E1A) -val PrimaryMid = Color(0xFF1A1F35) -val SurfaceCard = Color(0xFF1E2536) -val AccentCyan = Color(0xFF06B6D4) -val AccentViolet = Color(0xFF8B5CF6) -val AccentPink = Color(0xFFEC4899) -val AccentGreen = Color(0xFF10B981) -val AccentOrange = Color(0xFFF97316) -val TextPrimary = Color(0xFFFFFFFF) -val TextMuted = Color(0xFF94A3B8) +@Immutable +data class AppColors( + val background: Color, + val surface: Color, + val surfaceContainer: Color, + val surfaceElevated: Color, + val border: Color, + val accent: Color, + val accentSubtle: Color, + val success: Color, + val warning: Color, + val error: Color, + val info: Color, + val tintBlue: Color, + val tintPurple: Color, + val tintPink: Color, + val tintGreen: Color, + val tintOrange: Color, + val tintCyan: Color, + val textPrimary: Color, + val textSecondary: Color, + val textTertiary: Color, + val isDark: Boolean, +) + +// ── Dark palette ── inspired by Linear, Raycast, Arc +private val DarkAppColors = AppColors( + background = Color(0xFF101014), + surface = Color(0xFF18181C), + surfaceContainer = Color(0xFF1F1F24), + surfaceElevated = Color(0xFF26262C), + border = Color(0xFF2E2E36), + accent = Color(0xFF6C8EEF), + accentSubtle = Color(0x1F6C8EEF), + success = Color(0xFF5CB176), + warning = Color(0xFFD4A04A), + error = Color(0xFFD46A6A), + info = Color(0xFF6C8EEF), + tintBlue = Color(0xFF6C8EEF), + tintPurple = Color(0xFF9B7AE8), + tintPink = Color(0xFFD472A4), + tintGreen = Color(0xFF5CB176), + tintOrange = Color(0xFFD4A04A), + tintCyan = Color(0xFF56B5C4), + textPrimary = Color(0xFFE8E8ED), + textSecondary = Color(0xFF9898A4), + textTertiary = Color(0xFF5E5E6E), + isDark = true, +) + +// ── Light palette ── clean, airy, iOS-inspired +private val LightAppColors = AppColors( + background = Color(0xFFF6F6FA), + surface = Color(0xFFFFFFFF), + surfaceContainer = Color(0xFFEEEEF3), + surfaceElevated = Color(0xFFFFFFFF), + border = Color(0xFFDCDCE4), + accent = Color(0xFF4A6FE5), + accentSubtle = Color(0x144A6FE5), + success = Color(0xFF3A9259), + warning = Color(0xFFB87D1A), + error = Color(0xFFC24444), + info = Color(0xFF4A6FE5), + tintBlue = Color(0xFF4A6FE5), + tintPurple = Color(0xFF7B5BD6), + tintPink = Color(0xFFC0508A), + tintGreen = Color(0xFF3A9259), + tintOrange = Color(0xFFB87D1A), + tintCyan = Color(0xFF3098A8), + textPrimary = Color(0xFF1A1A1F), + textSecondary = Color(0xFF6E6E7A), + textTertiary = Color(0xFFA0A0AC), + isDark = false, +) + +val LocalAppColors = staticCompositionLocalOf { DarkAppColors } + +object AppTheme { + val colors: AppColors + @Composable + get() = LocalAppColors.current +} + +// ── Material color schemes ── private val DarkColorScheme = darkColorScheme( - primary = AccentCyan, - secondary = AccentViolet, - tertiary = AccentPink, - background = PrimaryDark, - surface = SurfaceCard, + primary = DarkAppColors.accent, + secondary = DarkAppColors.tintPurple, + tertiary = DarkAppColors.tintPink, + background = DarkAppColors.background, + surface = DarkAppColors.surface, + surfaceVariant = DarkAppColors.surfaceContainer, + surfaceContainerHigh = DarkAppColors.surfaceElevated, + outline = DarkAppColors.border, + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = DarkAppColors.textPrimary, + onSurface = DarkAppColors.textPrimary, + onSurfaceVariant = DarkAppColors.textSecondary, + error = DarkAppColors.error, + onError = Color.White, + errorContainer = DarkAppColors.error.copy(alpha = 0.12f), + onErrorContainer = DarkAppColors.error, +) + +private val LightColorScheme = lightColorScheme( + primary = LightAppColors.accent, + secondary = LightAppColors.tintPurple, + tertiary = LightAppColors.tintPink, + background = LightAppColors.background, + surface = LightAppColors.surface, + surfaceVariant = LightAppColors.surfaceContainer, + surfaceContainerHigh = LightAppColors.surfaceElevated, + outline = LightAppColors.border, onPrimary = Color.White, onSecondary = Color.White, onTertiary = Color.White, - onBackground = TextPrimary, - onSurface = TextPrimary, + onBackground = LightAppColors.textPrimary, + onSurface = LightAppColors.textPrimary, + onSurfaceVariant = LightAppColors.textSecondary, + error = LightAppColors.error, + onError = Color.White, + errorContainer = LightAppColors.error.copy(alpha = 0.08f), + onErrorContainer = LightAppColors.error, ) @Composable @@ -36,9 +147,29 @@ fun KotlinStarterTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { - MaterialTheme( - colorScheme = DarkColorScheme, - typography = Typography, - content = content - ) + val appColors = if (darkTheme) DarkAppColors else LightAppColors + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + @Suppress("DEPRECATION") + window.statusBarColor = appColors.background.toArgb() + @Suppress("DEPRECATION") + window.navigationBarColor = appColors.background.toArgb() + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } + } + + CompositionLocalProvider(LocalAppColors provides appColors) { + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) + } } diff --git a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Type.kt b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Type.kt index c92bf16..75d88a8 100644 --- a/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Type.kt +++ b/app/src/main/java/com/runanywhere/kotlin_starter_example/ui/theme/Type.kt @@ -6,61 +6,93 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -val Typography = Typography( +// Clean SF-style sans-serif hierarchy with proper optical sizing +val AppTypography = Typography( headlineLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = (-0.5).sp + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 34.sp, + letterSpacing = (-0.2).sp ), headlineMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 28.sp, - lineHeight = 36.sp, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, letterSpacing = 0.sp ), - titleLarge = TextStyle( - fontFamily = FontFamily.Default, + headlineSmall = TextStyle( + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp, + fontSize = 20.sp, + lineHeight = 26.sp, letterSpacing = 0.sp ), - titleMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 18.sp, lineHeight = 24.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + lineHeight = 18.sp, letterSpacing = 0.1.sp ), bodyLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Normal, fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp + lineHeight = 22.sp, + letterSpacing = 0.sp ), bodyMedium = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, - letterSpacing = 0.25.sp + letterSpacing = 0.1.sp ), bodySmall = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, - letterSpacing = 0.4.sp + letterSpacing = 0.2.sp ), labelLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp + fontSize = 13.sp, + lineHeight = 18.sp, + letterSpacing = 0.3.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp ) ) + +// Keep old name for backward compatibility +val Typography = AppTypography diff --git a/app/src/main/res/drawable/ic_adjustments.xml b/app/src/main/res/drawable/ic_adjustments.xml new file mode 100644 index 0000000..4cb41de --- /dev/null +++ b/app/src/main/res/drawable/ic_adjustments.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_alert_triangle.xml b/app/src/main/res/drawable/ic_alert_triangle.xml new file mode 100644 index 0000000..b072ee0 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert_triangle.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_left.xml b/app/src/main/res/drawable/ic_arrow_left.xml new file mode 100644 index 0000000..9d01f4c --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_left.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_calculator.xml b/app/src/main/res/drawable/ic_calculator.xml new file mode 100644 index 0000000..994dda5 --- /dev/null +++ b/app/src/main/res/drawable/ic_calculator.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_check.xml b/app/src/main/res/drawable/ic_circle_check.xml new file mode 100644 index 0000000..43da82c --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_check.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_x.xml b/app/src/main/res/drawable/ic_circle_x.xml new file mode 100644 index 0000000..590f3af --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_x.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_clock.xml b/app/src/main/res/drawable/ic_clock.xml new file mode 100644 index 0000000..14afc27 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_cloud.xml b/app/src/main/res/drawable/ic_cloud.xml new file mode 100644 index 0000000..cf111b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_cpu.xml b/app/src/main/res/drawable/ic_cpu.xml new file mode 100644 index 0000000..e15d490 --- /dev/null +++ b/app/src/main/res/drawable/ic_cpu.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..26f550e --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_ear.xml b/app/src/main/res/drawable/ic_ear.xml new file mode 100644 index 0000000..a499e3d --- /dev/null +++ b/app/src/main/res/drawable/ic_ear.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 0000000..c165b67 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml new file mode 100644 index 0000000..209e955 --- /dev/null +++ b/app/src/main/res/drawable/ic_language.xml @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml new file mode 100644 index 0000000..a9f5dbb --- /dev/null +++ b/app/src/main/res/drawable/ic_message.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_microphone.xml b/app/src/main/res/drawable/ic_microphone.xml new file mode 100644 index 0000000..34dcec6 --- /dev/null +++ b/app/src/main/res/drawable/ic_microphone.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_photo.xml b/app/src/main/res/drawable/ic_photo.xml new file mode 100644 index 0000000..40c90aa --- /dev/null +++ b/app/src/main/res/drawable/ic_photo.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_photo_plus.xml b/app/src/main/res/drawable/ic_photo_plus.xml new file mode 100644 index 0000000..dd10197 --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_plus.xml @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_player_stop.xml b/app/src/main/res/drawable/ic_player_stop.xml new file mode 100644 index 0000000..d8bb273 --- /dev/null +++ b/app/src/main/res/drawable/ic_player_stop.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000..ba34806 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_robot.xml b/app/src/main/res/drawable/ic_robot.xml new file mode 100644 index 0000000..c5495c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_robot.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..329ae94 --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_shield_check.xml b/app/src/main/res/drawable/ic_shield_check.xml new file mode 100644 index 0000000..0ec77a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_check.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_sparkles.xml b/app/src/main/res/drawable/ic_sparkles.xml new file mode 100644 index 0000000..89ce057 --- /dev/null +++ b/app/src/main/res/drawable/ic_sparkles.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_speakerphone.xml b/app/src/main/res/drawable/ic_speakerphone.xml new file mode 100644 index 0000000..2fcb54b --- /dev/null +++ b/app/src/main/res/drawable/ic_speakerphone.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_tool.xml b/app/src/main/res/drawable/ic_tool.xml new file mode 100644 index 0000000..bb4e21f --- /dev/null +++ b/app/src/main/res/drawable/ic_tool.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_trash_x.xml b/app/src/main/res/drawable/ic_trash_x.xml new file mode 100644 index 0000000..e9e41a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash_x.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tune.xml b/app/src/main/res/drawable/ic_tune.xml new file mode 100644 index 0000000..fe999ae --- /dev/null +++ b/app/src/main/res/drawable/ic_tune.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_typography.xml b/app/src/main/res/drawable/ic_typography.xml new file mode 100644 index 0000000..ffc6ab9 --- /dev/null +++ b/app/src/main/res/drawable/ic_typography.xml @@ -0,0 +1,43 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user.xml b/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000..cbfe183 --- /dev/null +++ b/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_volume.xml b/app/src/main/res/drawable/ic_volume.xml new file mode 100644 index 0000000..eaeeb38 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_x.xml b/app/src/main/res/drawable/ic_x.xml new file mode 100644 index 0000000..3ad460f --- /dev/null +++ b/app/src/main/res/drawable/ic_x.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index b2c8728..9c9d74a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.compose.compiler) apply false } \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..2ef1b89 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/39846e8427e64a3824c13e399d7d813c/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57af2e0..f64e0b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,19 @@ [versions] -agp = "8.13.2" -kotlin = "2.2.0" -coreKtx = "1.15.0" +agp = "9.0.1" +kotlin = "2.3.10" +coreKtx = "1.17.0" junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" -appcompat = "1.7.0" -material = "1.12.0" -activityCompose = "1.9.3" -composeBom = "2024.12.01" -lifecycleViewmodelCompose = "2.8.7" -navigationCompose = "2.8.5" -coroutines = "1.9.0" -runanywhere = "0.20.6" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.13.0" +activityCompose = "1.12.4" +composeBom = "2026.02.00" +lifecycleViewmodelCompose = "2.10.0" +navigationCompose = "2.9.7" +coroutines = "1.10.2" +markdownRenderer = "0.28.0" +runanywhere = "0.20.7" [libraries] # RunAnywhere SDK (0.20.5 - VLM support, tool calling APIs, streaming fixes) @@ -40,6 +41,9 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +# Markdown +markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } + # Coroutines kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index db47afa..51b3b32 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Wed Jan 14 15:40:46 PST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 1c1e9ae..18713d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories {