Skip to content

Commit 7ed42a7

Browse files
Merge pull request #439 from RunanywhereAI/smonga/android_fixes
Android app UI bug fixes, responsive dimensions, and dark mode
2 parents 195a1a2 + a4a14ec commit 7ed42a7

23 files changed

+511
-317
lines changed

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/RunAnywhereApplication.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,12 @@ class RunAnywhereApplication : Application() {
208208
isSDKInitialized = RunAnywhere.isInitialized
209209

210210
// Update observable state for Compose UI
211+
val error = initializationError
211212
if (isSDKInitialized) {
212213
_initializationState.value = SDKInitializationState.Ready
213214
Timber.i("🎉 App is ready to use!")
214-
} else if (initializationError != null) {
215-
_initializationState.value = SDKInitializationState.Error(initializationError!!)
215+
} else if (error != null) {
216+
_initializationState.value = SDKInitializationState.Error(error)
216217
} else {
217218
// SDK reported not initialized but no error - treat as ready for offline mode
218219
_initializationState.value = SDKInitializationState.Ready

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ConversationStore.kt

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
1212
import kotlinx.coroutines.flow.MutableStateFlow
1313
import kotlinx.coroutines.flow.StateFlow
1414
import kotlinx.coroutines.flow.asStateFlow
15+
import kotlinx.coroutines.flow.update
1516
import kotlinx.coroutines.launch
1617
import kotlinx.serialization.decodeFromString
1718
import kotlinx.serialization.encodeToString
@@ -79,9 +80,7 @@ class ConversationStore private constructor(context: Context) {
7980
performanceSummary = null,
8081
)
8182

82-
val updated = _conversations.value.toMutableList()
83-
updated.add(0, conversation)
84-
_conversations.value = updated
83+
_conversations.update { list -> listOf(conversation) + list }
8584
_currentConversation.value = conversation
8685

8786
saveConversation(conversation)
@@ -93,31 +92,36 @@ class ConversationStore private constructor(context: Context) {
9392
* If not present (by id), adds it at the front so it appears in history.
9493
*/
9594
fun ensureConversationInList(conversation: Conversation) {
96-
val index = _conversations.value.indexOfFirst { it.id == conversation.id }
97-
if (index == -1) {
98-
val list = _conversations.value.toMutableList()
99-
list.add(0, conversation)
100-
_conversations.value = list
101-
saveConversation(conversation)
95+
var wasAdded = false
96+
_conversations.update { list ->
97+
if (list.any { it.id == conversation.id }) {
98+
list
99+
} else {
100+
wasAdded = true
101+
listOf(conversation) + list
102+
}
102103
}
104+
if (wasAdded) saveConversation(conversation)
103105
}
104106

105107
/**
106108
* Update an existing conversation
107109
*/
108110
fun updateConversation(conversation: Conversation) {
109111
val updated = conversation.copy(updatedAt = System.currentTimeMillis())
110-
111-
val index = _conversations.value.indexOfFirst { it.id == conversation.id }
112-
if (index != -1) {
113-
val list = _conversations.value.toMutableList()
114-
list[index] = updated
115-
_conversations.value = list
116-
112+
var found = false
113+
_conversations.update { list ->
114+
list.map {
115+
if (it.id == conversation.id) {
116+
found = true
117+
updated
118+
} else it
119+
}
120+
}
121+
if (found) {
117122
if (_currentConversation.value?.id == conversation.id) {
118123
_currentConversation.value = updated
119124
}
120-
121125
saveConversation(updated)
122126
}
123127
}
@@ -126,7 +130,7 @@ class ConversationStore private constructor(context: Context) {
126130
* Delete a conversation
127131
*/
128132
fun deleteConversation(conversation: Conversation) {
129-
_conversations.value = _conversations.value.filter { it.id != conversation.id }
133+
_conversations.update { list -> list.filter { it.id != conversation.id } }
130134

131135
if (_currentConversation.value?.id == conversation.id) {
132136
_currentConversation.value = _conversations.value.firstOrNull()
@@ -183,9 +187,7 @@ class ConversationStore private constructor(context: Context) {
183187
try {
184188
val jsonString = file.readText()
185189
val loaded = json.decodeFromString<Conversation>(jsonString)
186-
val list = _conversations.value.toMutableList()
187-
list.add(loaded)
188-
_conversations.value = list
190+
_conversations.update { list -> list + loaded }
189191
_currentConversation.value = loaded
190192
return loaded
191193
} catch (e: Exception) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.runanywhere.runanywhereai.data
2+
3+
/**
4+
* Example prompts for each LoRA adapter, keyed by adapter filename.
5+
* These are shown in the active LoRA card so users can quickly test the adapter.
6+
*/
7+
object LoraExamplePrompts {
8+
9+
private val promptsByFilename: Map<String, List<String>> = mapOf(
10+
"code-assistant-Q8_0.gguf" to listOf(
11+
"Write a Python function to reverse a linked list",
12+
"Explain the difference between a stack and a queue with code examples",
13+
),
14+
"reasoning-logic-Q8_0.gguf" to listOf(
15+
"If all roses are flowers and some flowers fade quickly, can we conclude some roses fade quickly?",
16+
"A farmer has 17 sheep. All but 9 die. How many are left?",
17+
),
18+
"medical-qa-Q8_0.gguf" to listOf(
19+
"What are the common symptoms of vitamin D deficiency?",
20+
"Explain the difference between Type 1 and Type 2 diabetes",
21+
),
22+
"creative-writing-Q8_0.gguf" to listOf(
23+
"Write a short story about a robot discovering emotions for the first time",
24+
"Describe a sunset over the ocean using vivid sensory language",
25+
),
26+
)
27+
28+
/**
29+
* Get example prompts for a loaded adapter by its file path.
30+
* Extracts the filename from the path and looks up prompts.
31+
*/
32+
fun forAdapterPath(path: String): List<String> {
33+
val filename = path.substringAfterLast("/")
34+
return promptsByFilename[filename] ?: emptyList()
35+
}
36+
}

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatScreen.kt

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,6 @@ fun ThinkingToggle(
894894
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
895895
shape = RoundedCornerShape(Dimensions.cornerRadiusRegular),
896896
) {
897-
val scrollState = rememberScrollState()
898897
Text(
899898
text = thinkingContent,
900899
style = MaterialTheme.typography.bodySmall.copy(
@@ -903,8 +902,8 @@ fun ThinkingToggle(
903902
color = MaterialTheme.colorScheme.onSurfaceVariant,
904903
modifier = Modifier
905904
.heightIn(max = 200.dp)
906-
.verticalScroll(scrollState)
907905
.padding(Dimensions.mediumLarge),
906+
overflow = TextOverflow.Ellipsis,
908907
)
909908
}
910909
}
@@ -1101,15 +1100,12 @@ fun EmptyStateView(
11011100
// Small delay before starting auto-scroll
11021101
kotlinx.coroutines.delay(800)
11031102
while (!userHasScrolled) {
1104-
val currentOffset = promptListState.firstVisibleItemScrollOffset
11051103
promptListState.animateScrollBy(
11061104
value = 1.5f,
11071105
animationSpec = tween(durationMillis = 16, easing = LinearEasing),
11081106
)
11091107
// If we can't scroll further, wrap back to start
1110-
if (promptListState.firstVisibleItemScrollOffset == currentOffset &&
1111-
promptListState.firstVisibleItemIndex == promptListState.layoutInfo.totalItemsCount - 1
1112-
) {
1108+
if (!promptListState.canScrollForward) {
11131109
promptListState.scrollToItem(0)
11141110
}
11151111
kotlinx.coroutines.delay(16)
@@ -1331,7 +1327,8 @@ fun ConversationListSheet(
13311327
modifier =
13321328
Modifier
13331329
.fillMaxWidth()
1334-
.fillMaxHeight(0.85f),
1330+
.fillMaxHeight(0.85f)
1331+
.imePadding(),
13351332
) {
13361333
// Header
13371334
Row(

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/chat/ChatViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ data class ChatUiState(
7979
class ChatViewModel(application: Application) : AndroidViewModel(application) {
8080
private val app = application as RunAnywhereApplication
8181
private val conversationStore = ConversationStore.getInstance(application)
82-
private val tokensPerSecondHistory = mutableListOf<Double>()
82+
private val tokensPerSecondHistory = java.util.concurrent.CopyOnWriteArrayList<Double>()
8383

8484
private val _uiState = MutableStateFlow(ChatUiState())
8585
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()

examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraAdapterPickerSheet.kt

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
1717
import androidx.compose.material.icons.Icons
1818
import androidx.compose.material.icons.filled.Close
1919
import androidx.compose.material.icons.filled.CloudDownload
20+
import androidx.compose.material.icons.filled.ContentCopy
2021
import androidx.compose.material.icons.filled.Delete
2122
import androidx.compose.material.icons.filled.LinkOff
2223
import androidx.compose.material.icons.filled.PlayArrow
@@ -35,16 +36,20 @@ import androidx.compose.material3.Text
3536
import androidx.compose.material3.TextButton
3637
import androidx.compose.material3.rememberModalBottomSheetState
3738
import androidx.compose.runtime.Composable
38-
import androidx.compose.runtime.collectAsState
39+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3940
import androidx.compose.runtime.getValue
4041
import androidx.compose.runtime.mutableFloatStateOf
4142
import androidx.compose.runtime.remember
4243
import androidx.compose.runtime.setValue
4344
import androidx.compose.ui.Alignment
4445
import androidx.compose.ui.Modifier
4546
import androidx.compose.ui.draw.clip
47+
import androidx.compose.ui.platform.LocalClipboardManager
48+
import androidx.compose.ui.text.AnnotatedString
4649
import androidx.compose.ui.text.font.FontWeight
50+
import androidx.compose.ui.text.style.TextOverflow
4751
import androidx.compose.ui.unit.dp
52+
import com.runanywhere.runanywhereai.data.LoraExamplePrompts
4853
import com.runanywhere.runanywhereai.ui.theme.AppColors
4954
import com.runanywhere.runanywhereai.ui.theme.Dimensions
5055
import com.runanywhere.sdk.public.extensions.LoraAdapterCatalogEntry
@@ -60,7 +65,7 @@ fun LoraAdapterPickerSheet(
6065
loraViewModel: LoraViewModel,
6166
onDismiss: () -> Unit,
6267
) {
63-
val state by loraViewModel.uiState.collectAsState()
68+
val state by loraViewModel.uiState.collectAsStateWithLifecycle()
6469

6570
ModalBottomSheet(
6671
onDismissRequest = onDismiss,
@@ -181,33 +186,80 @@ private fun LoadedAdapterRow(
181186
adapter: LoRAAdapterInfo,
182187
onRemove: () -> Unit,
183188
) {
184-
Row(
189+
val clipboardManager = LocalClipboardManager.current
190+
val examplePrompts = remember(adapter.path) { LoraExamplePrompts.forAdapterPath(adapter.path) }
191+
192+
Column(
185193
modifier = Modifier
186194
.fillMaxWidth()
187195
.clip(RoundedCornerShape(Dimensions.cornerRadiusRegular))
188196
.background(MaterialTheme.colorScheme.surfaceVariant)
189197
.padding(Dimensions.mediumLarge),
190-
verticalAlignment = Alignment.CenterVertically,
191198
) {
192-
Column(modifier = Modifier.weight(1f)) {
193-
Text(
194-
adapter.path.substringAfterLast("/"),
195-
style = MaterialTheme.typography.bodyMedium,
196-
fontWeight = FontWeight.Medium,
197-
)
199+
Row(
200+
modifier = Modifier.fillMaxWidth(),
201+
verticalAlignment = Alignment.CenterVertically,
202+
) {
203+
Column(modifier = Modifier.weight(1f)) {
204+
Text(
205+
adapter.path.substringAfterLast("/"),
206+
style = MaterialTheme.typography.bodyMedium,
207+
fontWeight = FontWeight.Medium,
208+
)
209+
Text(
210+
"Scale: %.2f".format(adapter.scale),
211+
style = MaterialTheme.typography.bodySmall,
212+
color = MaterialTheme.colorScheme.onSurfaceVariant,
213+
)
214+
}
215+
IconButton(onClick = onRemove) {
216+
Icon(
217+
Icons.Default.Close,
218+
contentDescription = "Remove",
219+
tint = AppColors.primaryRed,
220+
modifier = Modifier.size(20.dp),
221+
)
222+
}
223+
}
224+
225+
// Example prompts
226+
if (examplePrompts.isNotEmpty()) {
227+
Spacer(modifier = Modifier.height(Dimensions.smallMedium))
198228
Text(
199-
"Scale: %.2f".format(adapter.scale),
200-
style = MaterialTheme.typography.bodySmall,
229+
"Try it out:",
230+
style = MaterialTheme.typography.labelSmall,
201231
color = MaterialTheme.colorScheme.onSurfaceVariant,
232+
fontWeight = FontWeight.SemiBold,
202233
)
203-
}
204-
IconButton(onClick = onRemove) {
205-
Icon(
206-
Icons.Default.Close,
207-
contentDescription = "Remove",
208-
tint = AppColors.primaryRed,
209-
modifier = Modifier.size(20.dp),
210-
)
234+
Spacer(modifier = Modifier.height(Dimensions.xSmall))
235+
examplePrompts.forEach { prompt ->
236+
Row(
237+
modifier = Modifier
238+
.fillMaxWidth()
239+
.padding(vertical = Dimensions.xxSmall),
240+
verticalAlignment = Alignment.CenterVertically,
241+
) {
242+
Text(
243+
"\u201C$prompt\u201D",
244+
style = MaterialTheme.typography.labelSmall,
245+
color = AppColors.primaryPurple,
246+
modifier = Modifier.weight(1f),
247+
maxLines = 2,
248+
overflow = TextOverflow.Ellipsis,
249+
)
250+
IconButton(
251+
onClick = { clipboardManager.setText(AnnotatedString(prompt)) },
252+
modifier = Modifier.size(Dimensions.iconRegular),
253+
) {
254+
Icon(
255+
Icons.Default.ContentCopy,
256+
contentDescription = "Copy prompt",
257+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
258+
modifier = Modifier.size(Dimensions.regular),
259+
)
260+
}
261+
}
262+
}
211263
}
212264
}
213265
}

0 commit comments

Comments
 (0)