pkg: restructure BottomAppBars into separate files in a child package

This commit is contained in:
Han Yin 2025-04-21 19:05:29 -07:00
parent 0bcb182d17
commit 3c539dc146
8 changed files with 720 additions and 613 deletions

View File

@ -38,11 +38,11 @@ import com.example.llama.revamp.navigation.NavigationActions
import com.example.llama.revamp.ui.scaffold.AnimatedNavHost
import com.example.llama.revamp.ui.scaffold.AppNavigationDrawer
import com.example.llama.revamp.ui.scaffold.AppScaffold
import com.example.llama.revamp.ui.scaffold.BottomBarConfig
import com.example.llama.revamp.ui.scaffold.NavigationIcon
import com.example.llama.revamp.ui.scaffold.ScaffoldConfig
import com.example.llama.revamp.ui.scaffold.ScaffoldEvent
import com.example.llama.revamp.ui.scaffold.TopBarConfig
import com.example.llama.revamp.ui.scaffold.bottombar.BottomBarConfig
import com.example.llama.revamp.ui.screens.BenchmarkScreen
import com.example.llama.revamp.ui.screens.ConversationScreen
import com.example.llama.revamp.ui.screens.ModelLoadingScreen

View File

@ -7,6 +7,11 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.example.llama.revamp.ui.scaffold.bottombar.BenchmarkBottomBar
import com.example.llama.revamp.ui.scaffold.bottombar.BottomBarConfig
import com.example.llama.revamp.ui.scaffold.bottombar.ConversationBottomBar
import com.example.llama.revamp.ui.scaffold.bottombar.ModelSelectionBottomBar
import com.example.llama.revamp.ui.scaffold.bottombar.ModelsManagementBottomBar
/**
* Configuration of both [TopBarConfig] and [BottomBarConfig]

View File

@ -1,612 +0,0 @@
package com.example.llama.revamp.ui.scaffold
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.example.llama.R
import com.example.llama.revamp.APP_NAME
import com.example.llama.revamp.data.model.ModelFilter
import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.data.model.ModelSortOrder
/**
* [BottomAppBar] configurations
*/
sealed class BottomBarConfig {
object None : BottomBarConfig()
data class ModelSelection(
val search: SearchConfig,
val sorting: SortingConfig,
val filtering: FilteringConfig,
val runAction: RunActionConfig
) : BottomBarConfig() {
data class SearchConfig(
val isActive: Boolean,
val onToggleSearch: (Boolean) -> Unit,
val textFieldState: TextFieldState,
val onSearch: (String) -> Unit,
)
data class SortingConfig(
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig(
val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class RunActionConfig(
val selectedModel: ModelInfo?,
val onRun: (ModelInfo) -> Unit
)
}
data class ModelsManagement(
val sorting: SortingConfig,
val filtering: FilteringConfig,
val selection: SelectionConfig,
val importing: ImportConfig
) : BottomBarConfig() {
data class SortingConfig(
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig(
val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class SelectionConfig(
val isActive: Boolean,
val toggleMode: (Boolean) -> Unit,
val selectedModels: Map<String, ModelInfo>,
val toggleAllSelection: (Boolean) -> Unit,
val deleteSelected: () -> Unit
)
data class ImportConfig(
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val importFromLocal: () -> Unit,
val importFromHuggingFace: () -> Unit
)
}
data class Benchmark(
val engineIdle: Boolean,
val onRerun: () -> Unit,
val onShare: () -> Unit,
) : BottomBarConfig()
data class Conversation(
val isEnabled: Boolean,
val textFieldState: TextFieldState,
val onSendClick: () -> Unit,
val onAttachPhotoClick: () -> Unit,
val onAttachFileClick: () -> Unit,
val onAudioInputClick: () -> Unit,
) : BottomBarConfig()
}
@Composable
fun ModelSelectionBottomBar(
search: BottomBarConfig.ModelSelection.SearchConfig,
sorting: BottomBarConfig.ModelSelection.SortingConfig,
filtering: BottomBarConfig.ModelSelection.FilteringConfig,
runAction: BottomBarConfig.ModelSelection.RunActionConfig
) {
BottomAppBar(
actions = {
if (search.isActive) {
// Quit search action
IconButton(onClick = { search.onToggleSearch(false) }) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = "Quit search mode"
)
}
// Clear query action
IconButton(onClick = { search.textFieldState.clearText() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Backspace,
contentDescription = "Clear query text"
)
}
} else {
// Enter search action
IconButton(onClick = { search.onToggleSearch(true) }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search models"
)
}
// Sorting action
IconButton(onClick = { sorting.toggleMenu(true) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sorting dropdown menu
DropdownMenu(
expanded = sorting.isMenuVisible,
onDismissRequest = { sorting.toggleMenu(false) }
) {
val sortOptions = listOf(
Triple(ModelSortOrder.NAME_ASC, "Name (A-Z)", "Sort by name in ascending order"),
Triple(ModelSortOrder.NAME_DESC, "Name (Z-A)", "Sort by name in descending order"),
Triple(ModelSortOrder.SIZE_ASC, "Size (Smallest first)", "Sort by size in ascending order"),
Triple(ModelSortOrder.SIZE_DESC, "Size (Largest first)", "Sort by size in descending order"),
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
)
sortOptions.forEach { (order, label, description) ->
DropdownMenuItem(
text = { Text(label) },
trailingIcon = {
if (sorting.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sorting.selectOrder(order) }
)
}
}
// Filter action
IconButton(onClick = { filtering.toggleMenu(true) }) {
Icon(
imageVector =
if (filtering.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models"
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filtering.isMenuVisible,
onDismissRequest = { filtering.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
filtering.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filtering.onClearFilters()
filtering.toggleMenu(false)
}
)
}
}
},
floatingActionButton = {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runAction.selectedModel != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = { runAction.selectedModel?.let { runAction.onRun(it) } },
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
}
}
}
)
}
@Composable
fun ModelsManagementBottomBar(
sorting: BottomBarConfig.ModelsManagement.SortingConfig,
filtering: BottomBarConfig.ModelsManagement.FilteringConfig,
selection: BottomBarConfig.ModelsManagement.SelectionConfig,
importing: BottomBarConfig.ModelsManagement.ImportConfig
) {
BottomAppBar(
actions = {
if (selection.isActive) {
/* Multi-selection mode actions */
IconButton(
onClick = selection.deleteSelected,
enabled = selection.selectedModels.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete selected",
tint = if (selection.selectedModels.isNotEmpty())
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
IconButton(onClick = { selection.toggleAllSelection(false) }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(onClick = { selection.toggleAllSelection(true) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
} else {
/* Default mode actions */
// Multi-selection action
IconButton(onClick = { selection.toggleMode(true) }) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete models"
)
}
// Sorting action
IconButton(onClick = { sorting.toggleMenu(true) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sorting dropdown menu
DropdownMenu(
expanded = sorting.isMenuVisible,
onDismissRequest = { sorting.toggleMenu(false) }
) {
val sortOptions = listOf(
Triple(ModelSortOrder.NAME_ASC, "Name (A-Z)", "Sort by name in ascending order"),
Triple(ModelSortOrder.NAME_DESC, "Name (Z-A)", "Sort by name in descending order"),
Triple(ModelSortOrder.SIZE_ASC, "Size (Smallest first)", "Sort by size in ascending order"),
Triple(ModelSortOrder.SIZE_DESC, "Size (Largest first)", "Sort by size in descending order"),
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
)
sortOptions.forEach { (order, label, description) ->
DropdownMenuItem(
text = { Text(label) },
trailingIcon = {
if (sorting.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sorting.selectOrder(order) }
)
}
}
// Filtering action
IconButton(onClick = { filtering.toggleMenu(true) }) {
Icon(
imageVector =
if (filtering.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models"
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filtering.isMenuVisible,
onDismissRequest = { filtering.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
filtering.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filtering.onClearFilters()
filtering.toggleMenu(false)
}
)
}
}
},
floatingActionButton = {
FloatingActionButton(
onClick = { if (selection.isActive) selection.toggleMode(false) else importing.toggleMenu(true) },
containerColor = MaterialTheme.colorScheme.primaryContainer
) {
Icon(
imageVector = if (selection.isActive) Icons.Default.Close else Icons.Default.Add,
contentDescription = if (selection.isActive) "Exit selection mode" else "Add model"
)
}
// Add model dropdown menu
DropdownMenu(
expanded = importing.isMenuVisible,
onDismissRequest = { importing.toggleMenu(false) }
) {
DropdownMenuItem(
text = { Text("Import local model") },
leadingIcon = {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = "Import a local model on the device"
)
},
onClick = importing.importFromLocal
)
DropdownMenuItem(
text = { Text("Download from HuggingFace") },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.logo_huggingface),
contentDescription = "Browse and download a model from HuggingFace",
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
},
onClick = importing.importFromHuggingFace
)
}
}
)
}
@Composable
fun BenchmarkBottomBar(
engineIdle: Boolean,
onRerun: () -> Unit,
onShare: () -> Unit,
) {
BottomAppBar(
actions = {
IconButton(onClick = onRerun) {
Icon(
imageVector = Icons.Default.Replay,
contentDescription = "Run the benchmark again",
tint =
if (engineIdle) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
},
floatingActionButton = {
// Only show FAB if the benchmark result is ready
AnimatedVisibility(
visible = engineIdle,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = onShare,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share the benchmark results"
)
}
}
}
)
}
@Composable
fun ConversationBottomBar(
textFieldState: TextFieldState,
isReady: Boolean,
onSendClick: () -> Unit,
onAttachPhotoClick: () -> Unit,
onAttachFileClick: () -> Unit,
onAudioInputClick: () -> Unit,
) {
val placeholder = if (isReady) "Message $APP_NAME..." else "Please wait for $APP_NAME to finish"
Column(
modifier = Modifier.fillMaxWidth()
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = BottomAppBarDefaults.containerColor,
tonalElevation = BottomAppBarDefaults.ContainerElevation,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)
) {
Box(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, end = 16.dp),
) {
OutlinedTextField(
state = textFieldState,
modifier = Modifier.fillMaxWidth().padding(end = 8.dp),
enabled = isReady,
placeholder = { Text(placeholder) },
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 5),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceDim,
),
shape = RoundedCornerShape(16.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
onKeyboardAction = { if (isReady) { onSendClick() } }
)
}
}
BottomAppBar(
actions = {
IconButton(onClick = onAttachPhotoClick) {
Icon(
imageVector = Icons.Outlined.AddPhotoAlternate,
contentDescription = "Attach a photo",
)
}
IconButton(onClick = onAttachFileClick) {
Icon(
imageVector = Icons.Outlined.AttachFile,
contentDescription = "Attach a file",
)
}
IconButton(onClick = onAudioInputClick) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Input with voice",
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = { if (isReady) { onSendClick() } },
containerColor = MaterialTheme.colorScheme.primary
) {
if (isReady) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeCap = StrokeCap.Round,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
)
}
}

View File

@ -0,0 +1,55 @@
package com.example.llama.revamp.ui.scaffold.bottombar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun BenchmarkBottomBar(
engineIdle: Boolean,
onRerun: () -> Unit,
onShare: () -> Unit,
) {
BottomAppBar(
actions = {
IconButton(onClick = onRerun) {
Icon(
imageVector = Icons.Default.Replay,
contentDescription = "Run the benchmark again",
tint =
if (engineIdle) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
},
floatingActionButton = {
// Only show FAB if the benchmark result is ready
AnimatedVisibility(
visible = engineIdle,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = onShare,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share the benchmark results"
)
}
}
}
)
}

View File

@ -0,0 +1,102 @@
package com.example.llama.revamp.ui.scaffold.bottombar
import androidx.compose.foundation.text.input.TextFieldState
import com.example.llama.revamp.data.model.ModelFilter
import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.data.model.ModelSortOrder
/**
* [BottomAppBar] configurations
*/
sealed class BottomBarConfig {
object None : BottomBarConfig()
data class ModelSelection(
val search: SearchConfig,
val sorting: SortingConfig,
val filtering: FilteringConfig,
val runAction: RunActionConfig
) : BottomBarConfig() {
data class SearchConfig(
val isActive: Boolean,
val onToggleSearch: (Boolean) -> Unit,
val textFieldState: TextFieldState,
val onSearch: (String) -> Unit,
)
data class SortingConfig(
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig(
val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class RunActionConfig(
val selectedModel: ModelInfo?,
val onRun: (ModelInfo) -> Unit
)
}
data class ModelsManagement(
val sorting: SortingConfig,
val filtering: FilteringConfig,
val selection: SelectionConfig,
val importing: ImportConfig
) : BottomBarConfig() {
data class SortingConfig(
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig(
val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class SelectionConfig(
val isActive: Boolean,
val toggleMode: (Boolean) -> Unit,
val selectedModels: Map<String, ModelInfo>,
val toggleAllSelection: (Boolean) -> Unit,
val deleteSelected: () -> Unit
)
data class ImportConfig(
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val importFromLocal: () -> Unit,
val importFromHuggingFace: () -> Unit
)
}
data class Benchmark(
val engineIdle: Boolean,
val onRerun: () -> Unit,
val onShare: () -> Unit,
) : BottomBarConfig()
data class Conversation(
val isEnabled: Boolean,
val textFieldState: TextFieldState,
val onSendClick: () -> Unit,
val onAttachPhotoClick: () -> Unit,
val onAttachFileClick: () -> Unit,
val onAudioInputClick: () -> Unit,
) : BottomBarConfig()
}

View File

@ -0,0 +1,131 @@
package com.example.llama.revamp.ui.scaffold.bottombar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.example.llama.revamp.APP_NAME
@Composable
fun ConversationBottomBar(
textFieldState: TextFieldState,
isReady: Boolean,
onSendClick: () -> Unit,
onAttachPhotoClick: () -> Unit,
onAttachFileClick: () -> Unit,
onAudioInputClick: () -> Unit,
) {
val placeholder = if (isReady) "Message ${APP_NAME}..." else "Please wait for ${APP_NAME} to finish"
Column(
modifier = Modifier.Companion.fillMaxWidth()
) {
Surface(
modifier = Modifier.Companion.fillMaxWidth(),
color = BottomAppBarDefaults.containerColor,
tonalElevation = BottomAppBarDefaults.ContainerElevation,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)
) {
Box(
modifier = Modifier.Companion.fillMaxWidth()
.padding(start = 16.dp, top = 16.dp, end = 16.dp),
) {
OutlinedTextField(
state = textFieldState,
modifier = Modifier.Companion.fillMaxWidth().padding(end = 8.dp),
enabled = isReady,
placeholder = { Text(placeholder) },
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 5),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(
alpha = 0.5f
),
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceDim,
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Companion.Send),
onKeyboardAction = {
if (isReady) {
onSendClick()
}
}
)
}
}
BottomAppBar(
actions = {
IconButton(onClick = onAttachPhotoClick) {
Icon(
imageVector = Icons.Outlined.AddPhotoAlternate,
contentDescription = "Attach a photo",
)
}
IconButton(onClick = onAttachFileClick) {
Icon(
imageVector = Icons.Outlined.AttachFile,
contentDescription = "Attach a file",
)
}
IconButton(onClick = onAudioInputClick) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Input with voice",
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = {
if (isReady) {
onSendClick()
}
},
containerColor = MaterialTheme.colorScheme.primary
) {
if (isReady) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
)
} else {
CircularProgressIndicator(
modifier = Modifier.Companion.size(24.dp),
strokeCap = StrokeCap.Companion.Round,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
)
}
}

View File

@ -0,0 +1,192 @@
package com.example.llama.revamp.ui.scaffold.bottombar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.revamp.data.model.ModelSortOrder
@Composable
fun ModelSelectionBottomBar(
search: BottomBarConfig.ModelSelection.SearchConfig,
sorting: BottomBarConfig.ModelSelection.SortingConfig,
filtering: BottomBarConfig.ModelSelection.FilteringConfig,
runAction: BottomBarConfig.ModelSelection.RunActionConfig
) {
BottomAppBar(
actions = {
if (search.isActive) {
// Quit search action
IconButton(onClick = { search.onToggleSearch(false) }) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = "Quit search mode"
)
}
// Clear query action
IconButton(onClick = { search.textFieldState.clearText() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Backspace,
contentDescription = "Clear query text"
)
}
} else {
// Enter search action
IconButton(onClick = { search.onToggleSearch(true) }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search models"
)
}
// Sorting action
IconButton(onClick = { sorting.toggleMenu(true) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sorting dropdown menu
DropdownMenu(
expanded = sorting.isMenuVisible,
onDismissRequest = { sorting.toggleMenu(false) }
) {
val sortOptions = listOf(
Triple(
ModelSortOrder.NAME_ASC,
"Name (A-Z)",
"Sort by name in ascending order"
),
Triple(
ModelSortOrder.NAME_DESC,
"Name (Z-A)",
"Sort by name in descending order"
),
Triple(
ModelSortOrder.SIZE_ASC,
"Size (Smallest first)",
"Sort by size in ascending order"
),
Triple(
ModelSortOrder.SIZE_DESC,
"Size (Largest first)",
"Sort by size in descending order"
),
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
)
sortOptions.forEach { (order, label, description) ->
DropdownMenuItem(
text = { Text(label) },
trailingIcon = {
if (sorting.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sorting.selectOrder(order) }
)
}
}
// Filter action
IconButton(onClick = { filtering.toggleMenu(true) }) {
Icon(
imageVector =
if (filtering.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models"
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filtering.isMenuVisible,
onDismissRequest = { filtering.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.Companion.padding(horizontal = 16.dp, vertical = 8.dp)
)
filtering.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filtering.onClearFilters()
filtering.toggleMenu(false)
}
)
}
}
},
floatingActionButton = {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runAction.selectedModel != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = { runAction.selectedModel?.let { runAction.onRun(it) } },
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
}
}
}
)
}

View File

@ -0,0 +1,234 @@
package com.example.llama.revamp.ui.scaffold.bottombar
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.example.llama.R
import com.example.llama.revamp.data.model.ModelSortOrder
@Composable
fun ModelsManagementBottomBar(
sorting: BottomBarConfig.ModelsManagement.SortingConfig,
filtering: BottomBarConfig.ModelsManagement.FilteringConfig,
selection: BottomBarConfig.ModelsManagement.SelectionConfig,
importing: BottomBarConfig.ModelsManagement.ImportConfig
) {
BottomAppBar(
actions = {
if (selection.isActive) {
/* Multi-selection mode actions */
IconButton(
onClick = selection.deleteSelected,
enabled = selection.selectedModels.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete selected",
tint = if (selection.selectedModels.isNotEmpty())
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
IconButton(onClick = { selection.toggleAllSelection(false) }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(onClick = { selection.toggleAllSelection(true) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
} else {
/* Default mode actions */
// Multi-selection action
IconButton(onClick = { selection.toggleMode(true) }) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete models"
)
}
// Sorting action
IconButton(onClick = { sorting.toggleMenu(true) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sorting dropdown menu
DropdownMenu(
expanded = sorting.isMenuVisible,
onDismissRequest = { sorting.toggleMenu(false) }
) {
val sortOptions = listOf(
Triple(
ModelSortOrder.NAME_ASC,
"Name (A-Z)",
"Sort by name in ascending order"
),
Triple(
ModelSortOrder.NAME_DESC,
"Name (Z-A)",
"Sort by name in descending order"
),
Triple(
ModelSortOrder.SIZE_ASC,
"Size (Smallest first)",
"Sort by size in ascending order"
),
Triple(
ModelSortOrder.SIZE_DESC,
"Size (Largest first)",
"Sort by size in descending order"
),
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
)
sortOptions.forEach { (order, label, description) ->
DropdownMenuItem(
text = { Text(label) },
trailingIcon = {
if (sorting.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sorting.selectOrder(order) }
)
}
}
// Filtering action
IconButton(onClick = { filtering.toggleMenu(true) }) {
Icon(
imageVector =
if (filtering.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models"
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filtering.isMenuVisible,
onDismissRequest = { filtering.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.Companion.padding(horizontal = 16.dp, vertical = 8.dp)
)
filtering.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filtering.onClearFilters()
filtering.toggleMenu(false)
}
)
}
}
},
floatingActionButton = {
FloatingActionButton(
onClick = {
if (selection.isActive) selection.toggleMode(false) else importing.toggleMenu(
true
)
},
containerColor = MaterialTheme.colorScheme.primaryContainer
) {
Icon(
imageVector = if (selection.isActive) Icons.Default.Close else Icons.Default.Add,
contentDescription = if (selection.isActive) "Exit selection mode" else "Add model"
)
}
// Add model dropdown menu
DropdownMenu(
expanded = importing.isMenuVisible,
onDismissRequest = { importing.toggleMenu(false) }
) {
DropdownMenuItem(
text = { Text("Import local model") },
leadingIcon = {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = "Import a local model on the device"
)
},
onClick = importing.importFromLocal
)
DropdownMenuItem(
text = { Text("Download from HuggingFace") },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.logo_huggingface),
contentDescription = "Browse and download a model from HuggingFace",
modifier = Modifier.Companion.size(24.dp),
tint = Color.Companion.Unspecified,
)
},
onClick = importing.importFromHuggingFace
)
}
}
)
}