UX: implement onboarding tooltips for model import and onboarding

This commit is contained in:
Han Yin 2025-08-31 11:17:46 -07:00
parent 1c73f6215f
commit 5471635c9d
12 changed files with 147 additions and 50 deletions

View File

@ -97,7 +97,7 @@ fun AppContent(
// App core states
val engineState by mainViewModel.engineState.collectAsState()
val showModelImportTooltip by mainViewModel.showModelImportTooltip.collectAsState()
val showChatTooltip by mainViewModel.showModelImportTooltip.collectAsState()
val showChatTooltip by mainViewModel.showChatTooltip.collectAsState()
// Model state
val modelScreenUiMode by modelsViewModel.modelScreenUiMode.collectAsState()
@ -222,9 +222,12 @@ fun AppContent(
modelsViewModel.resetPreselection()
openDrawer()
},
onToggleManaging = if (hasModelsInstalled) {
{ modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) }
} else null,
showManagingToggle = !showChatTooltip && hasModelsInstalled,
onToggleManaging = {
if (hasModelsInstalled) {
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
}
},
)
ModelScreenUiMode.SEARCHING ->
TopBarConfig.None()
@ -234,7 +237,9 @@ fun AppContent(
navigationIcon = NavigationIcon.Back {
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
},
storageMetrics = if (isMonitoringEnabled) storageMetrics else null,
storageMetrics =
if (isMonitoringEnabled && !showModelImportTooltip)
storageMetrics else null,
)
ModelScreenUiMode.DELETING ->
TopBarConfig.ModelsDeleting(
@ -272,6 +277,7 @@ fun AppContent(
toggleMenu = modelsViewModel::toggleFilterMenu
),
runAction = BottomBarConfig.Models.RunActionConfig(
showTooltip = showChatTooltip,
preselectedModelToRun = preselection,
onClickRun = {
if (modelsViewModel.selectModel(it)) {
@ -290,6 +296,7 @@ fun AppContent(
},
onSearch = { /* No-op for now */ },
runAction = BottomBarConfig.Models.RunActionConfig(
showTooltip = false,
preselectedModelToRun = preselection,
onClickRun = {
if (modelsViewModel.selectModel(it)) {
@ -477,15 +484,20 @@ fun AppContent(
// Model Selection Screen
composable(AppDestinations.MODELS_ROUTE) {
ModelsScreen(
showModelImportTooltip = showModelImportTooltip,
onFirstModelImportSuccess = { model ->
if (showModelImportTooltip) {
mainViewModel.waiveModelImportTooltip()
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
modelsViewModel.preselectModel(model, true)
}
},
showChatTooltip = showChatTooltip,
onConfirmSelection = { modelInfo, ramWarning ->
if (modelsViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
navigationActions.navigateToModelLoading()
}
},
onFirstModelImportSuccess =
if (showModelImportTooltip) {
{ mainViewModel.waiveModelImportTooltip() }
} else null,
onScaffoldEvent = handleScaffoldEvent,
modelsViewModel = modelsViewModel,
managementViewModel = modelsManagementViewModel,
@ -498,7 +510,12 @@ fun AppContent(
onScaffoldEvent = handleScaffoldEvent,
onNavigateBack = { navigationActions.navigateUp() },
onNavigateToBenchmark = { navigationActions.navigateToBenchmark(it) },
onNavigateToConversation = { navigationActions.navigateToConversation(it) },
onNavigateToConversation = {
navigationActions.navigateToConversation(it)
if (showChatTooltip) {
mainViewModel.waiveChatTooltip()
}
},
viewModel = modelLoadingViewModel
)
}

View File

@ -19,7 +19,7 @@ import com.example.llama.ui.scaffold.topbar.DefaultTopBar
import com.example.llama.ui.scaffold.topbar.ModelsBrowsingTopBar
import com.example.llama.ui.scaffold.topbar.NavigationIcon
import com.example.llama.ui.scaffold.topbar.PerformanceTopBar
import com.example.llama.ui.scaffold.topbar.StorageTopBar
import com.example.llama.ui.scaffold.topbar.ModelsManagementTopBar
import com.example.llama.ui.scaffold.topbar.TopBarConfig
/**
@ -71,6 +71,7 @@ fun AppScaffold(
is TopBarConfig.ModelsBrowsing -> ModelsBrowsingTopBar(
title = topBarconfig.title,
showManagingToggle = topBarconfig.showManagingToggle,
onToggleManaging = topBarconfig.onToggleManaging,
onNavigateBack = topBarconfig.navigationIcon.backAction,
onMenuOpen = topBarconfig.navigationIcon.menuAction
@ -83,7 +84,7 @@ fun AppScaffold(
onQuit = topBarconfig.navigationIcon.quitAction
)
is TopBarConfig.ModelsManagement -> StorageTopBar(
is TopBarConfig.ModelsManagement -> ModelsManagementTopBar(
title = topBarconfig.title,
storageMetrics = topBarconfig.storageMetrics,
onScaffoldEvent = onScaffoldEvent,

View File

@ -89,6 +89,7 @@ sealed class BottomBarConfig {
) : BottomBarConfig()
data class RunActionConfig(
val showTooltip: Boolean,
val preselectedModelToRun: PreselectedModelToRun?,
val onClickRun: (PreselectedModelToRun) -> Unit,
)

View File

@ -18,19 +18,26 @@ import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelSortOrder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsBrowsingBottomBar(
isSearchingEnabled: Boolean,
@ -39,6 +46,17 @@ fun ModelsBrowsingBottomBar(
filteringConfig: BottomBarConfig.Models.Browsing.FilteringConfig,
runActionConfig: BottomBarConfig.Models.RunActionConfig,
) {
val tooltipState = rememberTooltipState(
initialIsVisible = runActionConfig.showTooltip,
isPersistent = runActionConfig.showTooltip
)
LaunchedEffect(runActionConfig.preselectedModelToRun) {
if (runActionConfig.showTooltip && runActionConfig.preselectedModelToRun != null) {
tooltipState.show()
}
}
BottomAppBar(
actions = {
// Enter search action
@ -167,23 +185,35 @@ fun ModelsBrowsingBottomBar(
}
},
floatingActionButton = {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runActionConfig.preselectedModelToRun != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above),
state = tooltipState,
tooltip = {
PlainTooltip {
Text("Tap this button to run your first model!")
}
},
onDismissRequest = {}
) {
FloatingActionButton(
onClick = {
runActionConfig.preselectedModelToRun?.let {
runActionConfig.onClickRun(it)
}
},
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runActionConfig.preselectedModelToRun != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
FloatingActionButton(
onClick = {
runActionConfig.preselectedModelToRun?.let {
runActionConfig.onClickRun(it)
}
},
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
}
}
}
}

View File

@ -46,7 +46,9 @@ fun ModelsManagementBottomBar(
filteringConfig: BottomBarConfig.Models.Managing.FilteringConfig,
importingConfig: BottomBarConfig.Models.Managing.ImportConfig,
) {
val tooltipState = rememberTooltipState()
val tooltipState = rememberTooltipState(
initialIsVisible = false,
isPersistent = importingConfig.showTooltip)
LaunchedEffect(importingConfig) {
if (importingConfig.showTooltip && !importingConfig.isMenuVisible) {
@ -181,12 +183,13 @@ fun ModelsManagementBottomBar(
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above),
state = tooltipState,
tooltip = {
PlainTooltip {
Text("Tap this button to install your first model!")
}
},
state = tooltipState
onDismissRequest = {}
) {
FloatingActionButton(
onClick = { importingConfig.toggleMenu(true) },
@ -204,7 +207,7 @@ fun ModelsManagementBottomBar(
onDismissRequest = { importingConfig.toggleMenu(false) }
) {
DropdownMenuItem(
text = { Text("Import a local model") },
text = { Text("Import a local GGUF model") },
leadingIcon = {
Icon(
imageVector = Icons.Default.FolderOpen,

View File

@ -24,7 +24,8 @@ import androidx.compose.ui.unit.dp
@Composable
fun ModelsBrowsingTopBar(
title: String,
onToggleManaging: (() -> Unit)? = null,
showManagingToggle: Boolean,
onToggleManaging: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null,
) {
@ -52,9 +53,9 @@ fun ModelsBrowsingTopBar(
}
},
actions = {
onToggleManaging?.let {
ModelManageActionToggle(it)
}
if (showManagingToggle) {
ModelManageActionToggle(onToggleManaging)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,

View File

@ -24,7 +24,7 @@ import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StorageTopBar(
fun ModelsManagementTopBar(
title: String,
storageMetrics: StorageMetrics?,
onScaffoldEvent: (ScaffoldEvent) -> Unit,

View File

@ -28,7 +28,8 @@ sealed class TopBarConfig {
data class ModelsBrowsing(
override val title: String,
override val navigationIcon: NavigationIcon,
val onToggleManaging: (() -> Unit)?,
val showManagingToggle: Boolean,
val onToggleManaging: () -> Unit,
) : TopBarConfig()
// Model batch-deletion top bar with a toggle to turn on/off manage mode

View File

@ -90,7 +90,6 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import java.util.Locale
private const val AUTO_REPOSITION_INTERVAL = 150L
/**
* Screen for LLM conversation with user.

View File

@ -22,6 +22,7 @@ import com.example.llama.viewmodel.PreselectedModelToRun
@Composable
fun ModelsBrowsingScreen(
showChatTooltip: Boolean,
filteredModels: List<ModelInfo>?,
activeFiltersCount: Int,
preselection: PreselectedModelToRun?,
@ -58,7 +59,7 @@ fun ModelsBrowsingScreen(
} else {
// Model cards
LazyColumn(
Modifier.fillMaxSize(), // .padding(horizontal = 16.dp),
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
) {

View File

@ -19,15 +19,19 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.automirrored.outlined.ContactSupport
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Attribution
import androidx.compose.material.icons.filled.Celebration
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
@ -80,10 +84,11 @@ import java.util.Locale
*/
@Composable
fun ModelsManagementAndDeletingScreen(
showModelImportTooltip: Boolean,
onFirstModelImportSuccess: (ModelInfo) -> Unit,
filteredModels: List<ModelInfo>?,
activeFiltersCount: Int,
isDeleting: Boolean,
onFirstModelImportSuccess: (() -> Unit)?,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
@ -118,7 +123,7 @@ fun ModelsManagementAndDeletingScreen(
}
val message = "If you already have GGUF models on your computer, " +
"please transfer it onto your device, and then select \"Import a local model\".\n\n" +
"please transfer it onto your device, and then select \"Import a local GGUF model\".\n\n" +
"Otherwise, select \"Download from HuggingFace\" and pick one you like."
InfoView(
@ -208,14 +213,20 @@ fun ModelsManagementAndDeletingScreen(
}
is Importation.Success -> {
LaunchedEffect(state) {
onScaffoldEvent(
ScaffoldEvent.ShowSnackbar(
message = "Imported model: ${state.model.name}"
if (showModelImportTooltip) {
FirstModelImportSuccessDialog {
onFirstModelImportSuccess(state.model)
managementViewModel.resetManagementState()
}
} else {
LaunchedEffect(state) {
onScaffoldEvent(
ScaffoldEvent.ShowSnackbar(
message = "Imported model: ${state.model.name}"
)
)
)
onFirstModelImportSuccess?.invoke()
managementViewModel.resetManagementState()
managementViewModel.resetManagementState()
}
}
}
@ -616,6 +627,34 @@ fun HuggingFaceModelListItem(
}
}
@Composable
private fun FirstModelImportSuccessDialog(
onConfirm: () -> Unit,
) {
AlertDialog(
// Prevent dismissal via back button during deletion
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
),
onDismissRequest = {},
text = {
InfoView(
title = "Congratulations",
icon = Icons.Default.Celebration,
message = "You have just installed your first Large Language Model!\n\n"
+ "Tap \"Continue\" to check it out!",
action = InfoAction(
label = "Continue",
icon = Icons.AutoMirrored.Default.ArrowForward,
onAction = onConfirm
)
)
},
confirmButton = {}
)
}
@Composable
private fun BatchDeleteConfirmationDialog(
count: Int,

View File

@ -33,8 +33,10 @@ import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsScreen(
showModelImportTooltip: Boolean,
onFirstModelImportSuccess: (ModelInfo) -> Unit,
showChatTooltip: Boolean,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
onFirstModelImportSuccess: (() -> Unit)?,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
@ -79,6 +81,7 @@ fun ModelsScreen(
when (currentMode) {
ModelScreenUiMode.BROWSING ->
ModelsBrowsingScreen(
showChatTooltip = showChatTooltip,
filteredModels = filteredModels,
preselection = preselection,
onManageModelsClicked = {
@ -95,11 +98,12 @@ fun ModelsScreen(
)
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
ModelsManagementAndDeletingScreen(
showModelImportTooltip = showModelImportTooltip,
onFirstModelImportSuccess = onFirstModelImportSuccess,
filteredModels = filteredModels,
isDeleting = currentMode == ModelScreenUiMode.DELETING,
onScaffoldEvent = onScaffoldEvent,
activeFiltersCount = activeFiltersCount,
onFirstModelImportSuccess = onFirstModelImportSuccess,
modelsViewModel = modelsViewModel,
managementViewModel = managementViewModel,
)