UI: centralize the AppScaffold and modularize its configs

This commit is contained in:
Han Yin 2025-04-17 14:03:12 -07:00
parent 72e97b93c5
commit 63fc56d603
15 changed files with 1346 additions and 1119 deletions

View File

@ -4,17 +4,22 @@ import android.llama.cpp.InferenceEngine.State
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -24,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@ -32,6 +38,10 @@ import com.example.llama.revamp.navigation.AppDestinations
import com.example.llama.revamp.navigation.NavigationActions
import com.example.llama.revamp.ui.components.AnimatedNavHost
import com.example.llama.revamp.ui.components.AppNavigationDrawer
import com.example.llama.revamp.ui.components.AppScaffold
import com.example.llama.revamp.ui.components.BottomBarConfig
import com.example.llama.revamp.ui.components.ScaffoldEvent
import com.example.llama.revamp.ui.components.TopBarConfig
import com.example.llama.revamp.ui.components.UnloadModelConfirmationDialog
import com.example.llama.revamp.ui.screens.BenchmarkScreen
import com.example.llama.revamp.ui.screens.ConversationScreen
@ -42,6 +52,8 @@ import com.example.llama.revamp.ui.screens.SettingsGeneralScreen
import com.example.llama.revamp.ui.theme.LlamaTheme
import com.example.llama.revamp.viewmodel.ConversationViewModel
import com.example.llama.revamp.viewmodel.MainViewModel
import com.example.llama.revamp.viewmodel.ModelsManagementViewModel
import com.example.llama.revamp.viewmodel.PerformanceViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -66,10 +78,13 @@ class MainActivity : ComponentActivity() {
@Composable
fun AppContent(
mainViewModel: MainViewModel = hiltViewModel(),
performanceViewModel: PerformanceViewModel = hiltViewModel(),
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
conversationViewModel: ConversationViewModel = hiltViewModel(),
) {
val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// Inference engine state
val engineState by mainViewModel.engineState.collectAsState()
@ -87,45 +102,34 @@ fun AppContent(
}
}
// Metric states for scaffolds
val memoryUsage by performanceViewModel.memoryUsage.collectAsState()
val temperatureInfo by performanceViewModel.temperatureMetrics.collectAsState()
val useFahrenheit by performanceViewModel.useFahrenheitUnit.collectAsState()
val storageMetrics by performanceViewModel.storageMetrics.collectAsState()
// Navigation
val navController = rememberNavController()
val navigationActions = remember(navController) { NavigationActions(navController) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute by remember {
val currentRoute by remember(navBackStackEntry) {
derivedStateOf { navBackStackEntry?.destination?.route ?: "" }
}
var pendingNavigation by remember { mutableStateOf<(() -> Unit)?>(null) }
LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ ->
// Log navigation for debugging
println("Navigation: ${destination.route}")
}
}
// Determine if current route requires model unloading
val routeNeedsModelUnloading by remember(currentRoute) {
derivedStateOf {
currentRoute == AppDestinations.CONVERSATION_ROUTE
|| currentRoute == AppDestinations.BENCHMARK_ROUTE
|| currentRoute == AppDestinations.MODEL_LOADING_ROUTE
}
}
// Model unloading confirmation
var showUnloadDialog by remember { mutableStateOf(false) }
val handleBackWithModelCheck = {
when {
isModelUninterruptible -> {
// If model is non-interruptible at all, ignore the request
true // Mark as handled
}
isModelLoaded -> {
showUnloadDialog = true
pendingNavigation = { navigationActions.navigateUp() }
true // Mark as handled
}
else -> {
navigationActions.navigateUp()
true // Mark as handled
}
}
}
@ -142,128 +146,260 @@ fun AppContent(
}
val openDrawer: () -> Unit = { coroutineScope.launch { drawerState.open() } }
// Register a system back handler for screens that need unload confirmation
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
DisposableEffect(lifecycleOwner, backDispatcher, currentRoute, isModelLoaded) {
val callback = object : OnBackPressedCallback(
// Only enable for screens that need model unloading confirmation
routeNeedsModelUnloading && isModelLoaded
) {
override fun handleOnBackPressed() {
handleBackWithModelCheck()
// Create scaffold's top & bottom bar configs based on current route
val topBarConfig = when (currentRoute) {
// (Home) Model selection screen
AppDestinations.MODEL_SELECTION_ROUTE -> {
TopBarConfig.Default(
title = "Models",
navigationIcon = TopBarConfig.NavigationIcon.Menu(openDrawer)
)
}
// Settings screen
AppDestinations.SETTINGS_GENERAL_ROUTE -> {
TopBarConfig.Default(
title = "Settings",
navigationIcon = TopBarConfig.NavigationIcon.Back { navigationActions.navigateUp() }
)
}
// Storage management screen
AppDestinations.MODELS_MANAGEMENT_ROUTE -> {
TopBarConfig.Storage(
title = "Models Management",
navigationIcon = TopBarConfig.NavigationIcon.Back { navigationActions.navigateUp() },
storageMetrics = storageMetrics
)
}
// Model loading screen
AppDestinations.MODEL_LOADING_ROUTE -> {
TopBarConfig.Performance(
title = "Load Model",
navigationIcon = TopBarConfig.NavigationIcon.Back(handleBackWithModelCheck),
memoryMetrics = memoryUsage,
temperatureInfo = null
)
}
// Benchmark and Conversation screens
AppDestinations.BENCHMARK_ROUTE, AppDestinations.CONVERSATION_ROUTE -> {
TopBarConfig.Performance(
title = when(currentRoute) {
AppDestinations.CONVERSATION_ROUTE -> "Chat"
AppDestinations.BENCHMARK_ROUTE -> "Benchmark"
else -> "LlamaAndroid"
},
navigationIcon = TopBarConfig.NavigationIcon.Back(handleBackWithModelCheck),
memoryMetrics = memoryUsage,
temperatureInfo = Pair(temperatureInfo, useFahrenheit)
)
}
// Fallback for unknown routes
else -> {
TopBarConfig.Default(
title = "LlamaAndroid",
navigationIcon = TopBarConfig.NavigationIcon.None
)
}
}
val bottomBarConfig = when (currentRoute) {
AppDestinations.MODELS_MANAGEMENT_ROUTE -> {
// Collect the needed states
val sortOrder by modelsManagementViewModel.sortOrder.collectAsState()
val isMultiSelectionMode by modelsManagementViewModel.isMultiSelectionMode.collectAsState()
val selectedModels by modelsManagementViewModel.selectedModels.collectAsState()
val showSortMenu by modelsManagementViewModel.showSortMenu.collectAsState()
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
// Create file launcher for importing local models
val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { modelsManagementViewModel.localModelFileSelected(it) } }
BottomBarConfig.ModelsManagement(
isMultiSelectionMode = isMultiSelectionMode,
selectedModels = selectedModels,
onSelectAll = { modelsManagementViewModel.selectAllModels() },
onDeselectAll = { modelsManagementViewModel.clearSelectedModels() },
onDeleteSelected = {
if (selectedModels.isNotEmpty()) {
modelsManagementViewModel.batchDeletionClicked(selectedModels.toMap())
}
},
onSortClicked = { modelsManagementViewModel.toggleSortMenu(true) },
onFilterClicked = { /* TODO: implement filtering */ },
onDeleteModeClicked = { modelsManagementViewModel.setMultiSelectionMode(true) },
onAddModelClicked = { modelsManagementViewModel.toggleImportMenu(true) },
onExitSelectionMode = { modelsManagementViewModel.setMultiSelectionMode(false) },
showSortMenu = showSortMenu,
onSortMenuDismissed = { modelsManagementViewModel.toggleSortMenu(false) },
currentSortOrder = sortOrder,
onSortOptionSelected = {
modelsManagementViewModel.setSortOrder(it)
modelsManagementViewModel.toggleSortMenu(false)
},
showImportModelMenu = showImportModelMenu,
onImportMenuDismissed = { modelsManagementViewModel.toggleImportMenu(false) },
onImportLocalModelClicked = {
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
modelsManagementViewModel.toggleImportMenu(false)
},
onImportHuggingFaceClicked = {
modelsManagementViewModel.importFromHuggingFace()
modelsManagementViewModel.toggleImportMenu(false)
}
)
}
else -> BottomBarConfig.None
}
// Handle child screens' scaffold events
val handleScaffoldEvent: (ScaffoldEvent) -> Unit = { event ->
when (event) {
is ScaffoldEvent.ShowSnackbar -> {
coroutineScope.launch {
if (event.actionLabel != null && event.onAction != null) {
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.actionLabel,
withDismissAction = event.withDismissAction,
duration = event.duration
)
if (result == SnackbarResult.ActionPerformed) {
event.onAction()
}
} else {
snackbarHostState.showSnackbar(
message = event.message,
withDismissAction = event.withDismissAction,
duration = event.duration
)
}
}
}
is ScaffoldEvent.ChangeTitle -> {
// TODO-han.yin: TBD
}
}
backDispatcher?.addCallback(lifecycleOwner, callback)
// Remove the callback when the effect leaves the composition
onDispose {
callback.remove()
}
}
// Added protection to handle Compose-based back navigation
BackHandler(
enabled = routeNeedsModelUnloading && isModelLoaded
&& drawerState.currentValue == DrawerValue.Closed
) {
handleBackWithModelCheck()
}
// Main Content with navigation drawer wrapper
// Register system back handler
BackHandlerSetup(
lifecycleOwner = lifecycleOwner,
backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher,
currentRoute = currentRoute,
isModelLoaded = isModelLoaded,
handleBackWithModelCheck = handleBackWithModelCheck
)
// Main UI hierarchy
AppNavigationDrawer(
drawerState = drawerState,
navigationActions = navigationActions,
gesturesEnabled = drawerGesturesEnabled,
currentRoute = currentRoute
) {
AnimatedNavHost(
navController = navController,
startDestination = AppDestinations.MODEL_SELECTION_ROUTE
) {
// Model Selection Screen
composable(AppDestinations.MODEL_SELECTION_ROUTE) {
ModelSelectionScreen(
onModelSelected = { modelInfo ->
navigationActions.navigateToModelLoading()
},
onManageModelsClicked = {
navigationActions.navigateToModelsManagement()
},
onMenuClicked = openDrawer,
)
}
// The AppScaffold now uses the config we created
AppScaffold(
topBarconfig = topBarConfig,
bottomBarConfig = bottomBarConfig,
snackbarHostState = snackbarHostState,
) { paddingValues ->
// AnimatedNavHost inside the scaffold content
AnimatedNavHost(
navController = navController,
startDestination = AppDestinations.MODEL_SELECTION_ROUTE,
modifier = Modifier.padding(paddingValues)
) {
// Model Selection Screen
composable(AppDestinations.MODEL_SELECTION_ROUTE) {
ModelSelectionScreen(
onModelSelected = { modelInfo ->
navigationActions.navigateToModelLoading()
},
onManageModelsClicked = {
navigationActions.navigateToModelsManagement()
},
)
}
// Mode Selection Screen
composable(AppDestinations.MODEL_LOADING_ROUTE) {
ModelLoadingScreen(
engineState = engineState,
onBenchmarkSelected = { prepareJob ->
// Wait for preparation to complete, then navigate if still active
val loadingJob = coroutineScope.launch {
prepareJob.join()
if (isActive) { navigationActions.navigateToBenchmark() }
// Mode Selection Screen
composable(AppDestinations.MODEL_LOADING_ROUTE) {
ModelLoadingScreen(
engineState = engineState,
onBenchmarkSelected = { prepareJob ->
// Wait for preparation to complete, then navigate if still active
val loadingJob = coroutineScope.launch {
prepareJob.join()
if (isActive) { navigationActions.navigateToBenchmark() }
}
pendingNavigation = {
prepareJob.cancel()
loadingJob.cancel()
navigationActions.navigateUp()
}
},
onConversationSelected = { systemPrompt, prepareJob ->
// Wait for preparation to complete, then navigate if still active
val loadingJob = coroutineScope.launch {
prepareJob.join()
if (isActive) { navigationActions.navigateToConversation() }
}
pendingNavigation = {
prepareJob.cancel()
loadingJob.cancel()
navigationActions.navigateUp()
}
},
onBackPressed = {
// Need to unload model before going back
handleBackWithModelCheck()
},
)
}
// Benchmark Screen
composable(AppDestinations.BENCHMARK_ROUTE) {
BenchmarkScreen(
onBackPressed = {
// Need to unload model before going back
handleBackWithModelCheck()
}
)
}
pendingNavigation = {
prepareJob.cancel()
loadingJob.cancel()
navigationActions.navigateUp()
}
},
onConversationSelected = { systemPrompt, prepareJob ->
// Wait for preparation to complete, then navigate if still active
val loadingJob = coroutineScope.launch {
prepareJob.join()
if (isActive) { navigationActions.navigateToConversation() }
}
// Conversation Screen
composable(AppDestinations.CONVERSATION_ROUTE) {
ConversationScreen(
onBackPressed = {
// Need to unload model before going back
handleBackWithModelCheck()
},
viewModel = conversationViewModel
)
}
pendingNavigation = {
prepareJob.cancel()
loadingJob.cancel()
navigationActions.navigateUp()
}
},
onBackPressed = {
// Need to unload model before going back
handleBackWithModelCheck()
},
)
}
// Settings General Screen
composable(AppDestinations.SETTINGS_GENERAL_ROUTE) {
SettingsGeneralScreen(
onBackPressed = { navigationActions.navigateUp() },
)
}
// Benchmark Screen
composable(AppDestinations.BENCHMARK_ROUTE) {
BenchmarkScreen(
onBackPressed = {
// Need to unload model before going back
handleBackWithModelCheck()
}
)
}
// Conversation Screen
composable(AppDestinations.CONVERSATION_ROUTE) {
ConversationScreen(
onBackPressed = {
// Need to unload model before going back
handleBackWithModelCheck()
},
viewModel = conversationViewModel
)
}
// Settings General Screen
composable(AppDestinations.SETTINGS_GENERAL_ROUTE) {
SettingsGeneralScreen(
onBackPressed = { navigationActions.navigateUp() },
onMenuClicked = openDrawer
)
}
// Models Management Screen
composable(AppDestinations.MODELS_MANAGEMENT_ROUTE) {
ModelsManagementScreen(
onBackPressed = { navigationActions.navigateUp() },
)
// Models Management Screen
composable(AppDestinations.MODELS_MANAGEMENT_ROUTE) {
ModelsManagementScreen(
onBackPressed = { navigationActions.navigateUp() },
onScaffoldEvent = handleScaffoldEvent,
viewModel = modelsManagementViewModel
)
}
}
}
}
@ -276,6 +412,7 @@ fun AppContent(
onConfirm = {
isUnloading = true
coroutineScope.launch {
// TODO-han.yin: Clear conversation upon normal exiting
// Handle screen specific cleanups
when(engineState) {
is State.Benchmarking -> {}
@ -301,3 +438,39 @@ fun AppContent(
)
}
}
@Composable
private fun BackHandlerSetup(
lifecycleOwner: LifecycleOwner,
backDispatcher: OnBackPressedDispatcher?,
currentRoute: String,
isModelLoaded: Boolean,
handleBackWithModelCheck: () -> Unit
) {
val routeNeedsModelUnloading = currentRoute in listOf(
AppDestinations.CONVERSATION_ROUTE,
AppDestinations.BENCHMARK_ROUTE,
AppDestinations.MODEL_LOADING_ROUTE
)
DisposableEffect(lifecycleOwner, backDispatcher, currentRoute, isModelLoaded) {
android.util.Log.w("JOJO", "BackHandlerSetup: currentRoute: $currentRoute")
val callback = object : OnBackPressedCallback(
routeNeedsModelUnloading && isModelLoaded
) {
override fun handleOnBackPressed() {
handleBackWithModelCheck()
}
}
backDispatcher?.addCallback(lifecycleOwner, callback)
onDispose { callback.remove() }
}
BackHandler(
enabled = routeNeedsModelUnloading && isModelLoaded
) {
handleBackWithModelCheck()
}
}

View File

@ -71,6 +71,8 @@ class UserPreferences @Inject constructor (
/**
* Gets the monitoring interval in milliseconds.
*
* TODO-han.yin: replace with Enum value instead of millisecond value
*/
fun getMonitoringInterval(): Flow<Long> {
return context.dataStore.data.map { preferences ->

View File

@ -325,7 +325,7 @@ class ModelRepositoryImpl @Inject constructor(
private const val INTERNAL_STORAGE_PATH = "models"
private const val STORAGE_METRICS_UPDATE_INTERVAL = 5_000L
private const val STORAGE_METRICS_UPDATE_INTERVAL = 10_000L
private const val BYTES_IN_GB = 1024f * 1024f * 1024f
private const val MODEL_IMPORT_SPACE_BUFFER_SCALE = 1.2f

View File

@ -0,0 +1,100 @@
package com.example.llama.revamp.ui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* Events called back from child screens
*/
sealed class ScaffoldEvent {
data class ShowSnackbar(
val message: String,
val duration: SnackbarDuration = SnackbarDuration.Short,
val withDismissAction: Boolean = false,
val actionLabel: String? = null,
val onAction: (() -> Unit)? = null
) : ScaffoldEvent()
data class ChangeTitle(val newTitle: String) : ScaffoldEvent()
}
@Composable
fun AppScaffold(
topBarconfig: TopBarConfig,
bottomBarConfig: BottomBarConfig = BottomBarConfig.None,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (PaddingValues) -> Unit
) {
val topBar: @Composable () -> Unit = {
when (topBarconfig) {
is TopBarConfig.Performance -> {
PerformanceTopBar(
title = topBarconfig.title,
memoryMetrics = topBarconfig.memoryMetrics,
temperatureDisplay = topBarconfig.temperatureInfo,
onNavigateBack = (topBarconfig.navigationIcon as? TopBarConfig.NavigationIcon.Back)?.onNavigateBack,
onMenuOpen = (topBarconfig.navigationIcon as? TopBarConfig.NavigationIcon.Menu)?.onMenuOpen
)
}
is TopBarConfig.Storage -> {
StorageTopBar(
title = topBarconfig.title,
storageMetrics = topBarconfig.storageMetrics,
onNavigateBack = (topBarconfig.navigationIcon as? TopBarConfig.NavigationIcon.Back)?.onNavigateBack
)
}
is TopBarConfig.Default -> {
DefaultTopBar(
title = topBarconfig.title,
onNavigateBack = (topBarconfig.navigationIcon as? TopBarConfig.NavigationIcon.Back)?.onNavigateBack,
onMenuOpen = (topBarconfig.navigationIcon as? TopBarConfig.NavigationIcon.Menu)?.onMenuOpen
)
}
}
}
val bottomBar: @Composable () -> Unit = {
when (bottomBarConfig) {
is BottomBarConfig.None -> {
/* No bottom bar */
}
is BottomBarConfig.ModelsManagement -> {
ModelsManagementBottomBar(
isMultiSelectionMode = bottomBarConfig.isMultiSelectionMode,
selectedModels = bottomBarConfig.selectedModels,
onSelectAll = bottomBarConfig.onSelectAll,
onDeselectAll = bottomBarConfig.onDeselectAll,
onDeleteSelected = bottomBarConfig.onDeleteSelected,
onSortClicked = bottomBarConfig.onSortClicked,
onFilterClicked = bottomBarConfig.onFilterClicked,
onDeleteModeClicked = bottomBarConfig.onDeleteModeClicked,
onAddModelClicked = bottomBarConfig.onAddModelClicked,
onExitSelectionMode = bottomBarConfig.onExitSelectionMode,
showSortMenu = bottomBarConfig.showSortMenu,
onSortMenuDismissed = bottomBarConfig.onSortMenuDismissed,
currentSortOrder = bottomBarConfig.currentSortOrder,
onSortOptionSelected = bottomBarConfig.onSortOptionSelected,
showImportModelMenu = bottomBarConfig.showImportModelMenu,
onImportMenuDismissed = bottomBarConfig.onImportMenuDismissed,
onImportLocalModelClicked = bottomBarConfig.onImportLocalModelClicked,
onImportHuggingFaceClicked = bottomBarConfig.onImportHuggingFaceClicked
)
}
}
}
Scaffold(
topBar = topBar,
bottomBar = bottomBar,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
content = content
)
}

View File

@ -1,94 +0,0 @@
package com.example.llama.revamp.ui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.llama.revamp.viewmodel.PerformanceViewModel
@Composable
fun DefaultAppScaffold(
title: String,
onNavigateBack: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
topBar = {
DefaultTopBar(
title = title,
onNavigateBack = onNavigateBack,
onMenuOpen = onMenuOpen
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
content = content
)
}
@Composable
fun PerformanceAppScaffold(
performanceViewModel: PerformanceViewModel = hiltViewModel(),
title: String,
onNavigateBack: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null,
showTemperature: Boolean = false,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (PaddingValues) -> Unit
) {
// Collect performance metrics
val memoryUsage by performanceViewModel.memoryUsage.collectAsState()
val temperatureInfo by performanceViewModel.temperatureMetrics.collectAsState()
val useFahrenheit by performanceViewModel.useFahrenheitUnit.collectAsState()
Scaffold(
topBar = {
PerformanceTopBar(
title = title,
memoryMetrics = memoryUsage,
temperatureDisplay = if (showTemperature) Pair(temperatureInfo, useFahrenheit) else null,
onNavigateBack = onNavigateBack,
onMenuOpen = onMenuOpen,
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
content = content
)
}
@Composable
fun StorageAppScaffold(
title: String,
storageUsed: Float,
storageTotal: Float,
onNavigateBack: (() -> Unit)? = null,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
bottomBar: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
topBar = {
StorageTopBar(
title = title,
storageUsed = storageUsed,
storageTotal = storageTotal,
onNavigateBack = onNavigateBack,
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
bottomBar = bottomBar,
content = content
)
}

View File

@ -0,0 +1,254 @@
package com.example.llama.revamp.ui.components
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.DeleteSweep
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
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.ModelInfo
import com.example.llama.revamp.viewmodel.ModelSortOrder
/**
* [BottomAppBar] configurations
*/
sealed class BottomBarConfig {
object None : BottomBarConfig()
data class ModelsManagement(
val isMultiSelectionMode: Boolean,
val selectedModels: Map<String, ModelInfo>,
val onSelectAll: () -> Unit,
val onDeselectAll: () -> Unit,
val onDeleteSelected: () -> Unit,
val onSortClicked: () -> Unit,
val onFilterClicked: () -> Unit,
val onDeleteModeClicked: () -> Unit,
val onAddModelClicked: () -> Unit,
val onExitSelectionMode: () -> Unit,
val showSortMenu: Boolean,
val onSortMenuDismissed: () -> Unit,
val currentSortOrder: ModelSortOrder,
val onSortOptionSelected: (ModelSortOrder) -> Unit,
val showImportModelMenu: Boolean,
val onImportMenuDismissed: () -> Unit,
val onImportLocalModelClicked: () -> Unit,
val onImportHuggingFaceClicked: () -> Unit
) : BottomBarConfig()
// TODO-han.yin: add more bottom bar types here
}
@Composable
fun ModelsManagementBottomBar(
isMultiSelectionMode: Boolean,
selectedModels: Map<String, ModelInfo>,
onSelectAll: () -> Unit,
onDeselectAll: () -> Unit,
onDeleteSelected: () -> Unit,
onSortClicked: () -> Unit,
onFilterClicked: () -> Unit,
onDeleteModeClicked: () -> Unit,
onAddModelClicked: () -> Unit,
onExitSelectionMode: () -> Unit,
showSortMenu: Boolean,
onSortMenuDismissed: () -> Unit,
currentSortOrder: ModelSortOrder,
onSortOptionSelected: (ModelSortOrder) -> Unit,
showImportModelMenu: Boolean,
onImportMenuDismissed: () -> Unit,
onImportLocalModelClicked: () -> Unit,
onImportHuggingFaceClicked: () -> Unit
) {
BottomAppBar(
actions = {
if (isMultiSelectionMode) {
// Multi-selection mode actions
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
IconButton(onClick = onDeselectAll) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(
onClick = onDeleteSelected,
enabled = selectedModels.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete selected",
tint = if (selectedModels.isNotEmpty())
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
} else {
// Default mode actions
IconButton(onClick = onSortClicked) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sort dropdown menu
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = onSortMenuDismissed
) {
DropdownMenuItem(
text = { Text("Name (A-Z)") },
trailingIcon = {
if (currentSortOrder == ModelSortOrder.NAME_ASC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by name in ascending order, selected"
)
},
onClick = {
onSortOptionSelected(ModelSortOrder.NAME_ASC)
}
)
DropdownMenuItem(
text = { Text("Name (Z-A)") },
trailingIcon = {
if (currentSortOrder == ModelSortOrder.NAME_DESC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by name in descending order, selected"
)
},
onClick = {
onSortOptionSelected(ModelSortOrder.NAME_DESC)
}
)
DropdownMenuItem(
text = { Text("Size (Smallest first)") },
trailingIcon = {
if (currentSortOrder == ModelSortOrder.SIZE_ASC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by size in ascending order, selected"
)
},
onClick = {
onSortOptionSelected(ModelSortOrder.SIZE_ASC)
}
)
DropdownMenuItem(
text = { Text("Size (Largest first)") },
trailingIcon = {
if (currentSortOrder == ModelSortOrder.SIZE_DESC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by size in descending order, selected"
)
},
onClick = {
onSortOptionSelected(ModelSortOrder.SIZE_DESC)
}
)
DropdownMenuItem(
text = { Text("Last used") },
trailingIcon = {
if (currentSortOrder == ModelSortOrder.LAST_USED)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by last used, selected"
)
},
onClick = {
onSortOptionSelected(ModelSortOrder.LAST_USED)
}
)
}
IconButton(
onClick = onFilterClicked
) {
Icon(
imageVector = Icons.Default.FilterAlt,
contentDescription = "Filter models"
)
}
IconButton(onClick = onDeleteModeClicked) {
Icon(
imageVector = Icons.Default.DeleteSweep,
contentDescription = "Delete models"
)
}
}
},
floatingActionButton = {
FloatingActionButton(
onClick = if (isMultiSelectionMode) onExitSelectionMode else onAddModelClicked,
containerColor = MaterialTheme.colorScheme.primaryContainer
) {
Icon(
imageVector = if (isMultiSelectionMode) Icons.Default.Close else Icons.Default.Add,
contentDescription = if (isMultiSelectionMode) "Exit selection mode" else "Add model"
)
}
// Add model dropdown menu
DropdownMenu(
expanded = showImportModelMenu,
onDismissRequest = onImportMenuDismissed
) {
DropdownMenuItem(
text = { Text("Import local model") },
leadingIcon = {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = "Import a local model on the device"
)
},
onClick = onImportLocalModelClicked
)
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 = onImportHuggingFaceClicked
)
}
}
)
}

View File

@ -22,9 +22,47 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.revamp.data.repository.StorageMetrics
import com.example.llama.revamp.monitoring.MemoryMetrics
import com.example.llama.revamp.monitoring.TemperatureMetrics
import com.example.llama.revamp.monitoring.TemperatureWarningLevel
import java.util.Locale
/**
* [TopAppBar] configurations
*/
sealed class TopBarConfig {
abstract val title: String
abstract val navigationIcon: NavigationIcon
// Data class for performance monitoring scaffolds
data class Performance(
override val title: String,
override val navigationIcon: NavigationIcon,
val memoryMetrics: MemoryMetrics,
val temperatureInfo: Pair<TemperatureMetrics, Boolean>?,
) : TopBarConfig()
// Data class for storage management scaffolds
data class Storage(
override val title: String,
override val navigationIcon: NavigationIcon,
val storageMetrics: StorageMetrics?
) : TopBarConfig()
// Data class for default/simple scaffolds
data class Default(
override val title: String,
override val navigationIcon: NavigationIcon
) : TopBarConfig()
// Helper class for navigation icon configuration
sealed class NavigationIcon {
data class Menu(val onMenuOpen: () -> Unit) : NavigationIcon()
data class Back(val onNavigateBack: () -> Unit) : NavigationIcon()
object None : NavigationIcon()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -118,8 +156,7 @@ fun PerformanceTopBar(
@Composable
fun StorageTopBar(
title: String,
storageUsed: Float,
storageTotal: Float,
storageMetrics: StorageMetrics?,
onNavigateBack: (() -> Unit)? = null,
) {
TopAppBar(
@ -135,7 +172,7 @@ fun StorageTopBar(
}
},
actions = {
StorageIndicator(usedGB = storageUsed, totalGB = storageTotal)
StorageIndicator(storageMetrics = storageMetrics)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
@ -159,10 +196,10 @@ fun MemoryIndicator(memoryUsage: MemoryMetrics) {
Spacer(modifier = Modifier.width(4.dp))
val memoryText = String.format("%.1f / %.1f GB", memoryUsage.availableGb, memoryUsage.totalGb)
Text(
text = memoryText,
text = String.format(
Locale.getDefault(), "%.1f / %.1f GB", memoryUsage.availableGb, memoryUsage.totalGb
),
style = MaterialTheme.typography.bodySmall,
)
}
@ -199,14 +236,23 @@ fun TemperatureIndicator(temperatureMetrics: TemperatureMetrics, useFahrenheit:
}
@Composable
fun StorageIndicator(usedGB: Float, totalGB: Float) {
fun StorageIndicator(storageMetrics: StorageMetrics?) {
val usedGb = storageMetrics?.usedGB
val totalGb = storageMetrics?.totalGB
val usedRatio = if (usedGb != null && totalGb != null && totalGb > 0.0f) {
usedGb / totalGb
} else {
null
}
Row(modifier = Modifier.padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.SdStorage,
contentDescription = "Storage",
tint = when {
usedGB / totalGB > 0.9f -> MaterialTheme.colorScheme.error
usedGB / totalGB > 0.7f -> MaterialTheme.colorScheme.tertiary
usedRatio == null -> MaterialTheme.colorScheme.onSurface
usedRatio > 0.9f -> MaterialTheme.colorScheme.error
usedRatio > 0.7f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
@ -214,7 +260,9 @@ fun StorageIndicator(usedGB: Float, totalGB: Float) {
Spacer(modifier = Modifier.width(2.dp))
Text(
text = String.format("%.1f / %.1f GB", usedGB, totalGB),
text = storageMetrics?.let {
String.format(Locale.getDefault(), "%.1f / %.1f GB", it.usedGB, it.totalGB)
} ?: String.format(Locale.getDefault(), " - / - GB"),
style = MaterialTheme.typography.bodySmall
)
}

View File

@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.llama.revamp.ui.components.ModelCard
import com.example.llama.revamp.ui.components.PerformanceAppScaffold
import com.example.llama.revamp.ui.theme.MonospacedTextStyle
import com.example.llama.revamp.viewmodel.BenchmarkViewModel
@ -42,85 +41,78 @@ fun BenchmarkScreen(
viewModel.runBenchmark()
}
PerformanceAppScaffold(
title = "Chat",
onNavigateBack = onBackPressed,
showTemperature = true
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
// Selected model card
selectedModel?.let { model ->
ModelCard(
model = model,
onClick = { /* No action on click */ },
modifier = Modifier.padding(bottom = 16.dp),
isSelected = null
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
// Selected model card
selectedModel?.let { model ->
ModelCard(
model = model,
onClick = { /* No action on click */ },
modifier = Modifier.padding(bottom = 16.dp),
isSelected = null
)
}
// Benchmark results or loading indicator
when {
engineState is State.Benchmarking -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Running benchmark...",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
// Benchmark results or loading indicator
when {
engineState is State.Benchmarking -> {
benchmarkResults != null -> {
Card(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Running benchmark...",
style = MaterialTheme.typography.bodyMedium
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
}
}
}
benchmarkResults != null -> {
Card(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
.padding(16.dp)
) {
Text(
text = benchmarkResults ?: "",
style = MonospacedTextStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
.padding(16.dp)
) {
Text(
text = "Benchmark results will appear here",
style = MaterialTheme.typography.bodyMedium,
text = benchmarkResults ?: "",
style = MonospacedTextStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Benchmark results will appear here",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}

View File

@ -52,12 +52,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.example.llama.revamp.ui.components.ModelCardWithSystemPrompt
import com.example.llama.revamp.ui.components.PerformanceAppScaffold
import com.example.llama.revamp.viewmodel.ConversationViewModel
import com.example.llama.revamp.viewmodel.Message
import kotlinx.coroutines.launch
@ -113,46 +111,39 @@ fun ConversationScreen(
}
}
PerformanceAppScaffold(
title = "Chat",
onNavigateBack = onBackPressed,
showTemperature = true
) { paddingValues ->
Column(
Column(
modifier = Modifier
.fillMaxSize()
) {
// System prompt display (collapsible)
selectedModel?.let {
ModelCardWithSystemPrompt(it, systemPrompt)
}
// Messages list
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.weight(1f)
.fillMaxWidth()
) {
// System prompt display (collapsible)
selectedModel?.let {
ModelCardWithSystemPrompt(it, systemPrompt)
}
// Messages list
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
ConversationMessageList(
messages = messages,
listState = listState,
)
}
// Input area
ConversationInputField(
value = inputText,
onValueChange = { inputText = it },
onSendClick = {
if (inputText.isNotBlank()) {
viewModel.sendMessage(inputText)
inputText = ""
}
},
isEnabled = !isProcessing && !isGenerating
ConversationMessageList(
messages = messages,
listState = listState,
)
}
// Input area
ConversationInputField(
value = inputText,
onValueChange = { inputText = it },
onSendClick = {
if (inputText.isNotBlank()) {
viewModel.sendMessage(inputText)
inputText = ""
}
},
isEnabled = !isProcessing && !isGenerating
)
}
}
@ -187,6 +178,7 @@ fun MessageBubble(message: Message) {
formattedTime = message.formattedTime,
content = message.content
)
is Message.Assistant.Ongoing -> AssistantMessageBubble(
formattedTime = message.formattedTime,
content = message.content,
@ -194,6 +186,7 @@ fun MessageBubble(message: Message) {
isComplete = false,
metrics = null
)
is Message.Assistant.Completed -> AssistantMessageBubble(
formattedTime = message.formattedTime,
content = message.content,
@ -301,7 +294,9 @@ fun AssistantMessageBubble(
// Show metrics or generation status below the bubble
Row(
modifier = Modifier.height(20.dp).padding(top = 4.dp),
modifier = Modifier
.height(20.dp)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (!isComplete) {

View File

@ -51,7 +51,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.llama.revamp.data.model.SystemPrompt
import com.example.llama.revamp.ui.components.ModelCard
import com.example.llama.revamp.ui.components.PerformanceAppScaffold
import com.example.llama.revamp.viewmodel.ModelLoadingViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@ -118,327 +117,323 @@ fun ModelLoadingScreen(
onConversationSelected(systemPrompt, prepareJob)
}
PerformanceAppScaffold(
title = "Load Model",
onNavigateBack = onBackPressed,
showTemperature = false
) { paddingValues ->
Column(
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Selected model card
selectedModel?.let { model ->
ModelCard(
model = model,
onClick = { /* No action on click */ },
modifier = Modifier.padding(bottom = 16.dp),
isSelected = null
)
}
// Benchmark card
Card(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.fillMaxWidth()
.padding(bottom = 8.dp)
.selectable(
selected = selectedMode == Mode.BENCHMARK,
onClick = {
selectedMode = Mode.BENCHMARK
useSystemPrompt = false
},
enabled = !isLoading,
role = Role.RadioButton
)
) {
// Selected model card
selectedModel?.let { model ->
ModelCard(
model = model,
onClick = { /* No action on click */ },
modifier = Modifier.padding(bottom = 16.dp),
isSelected = null
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMode == Mode.BENCHMARK,
onClick = null // handled by parent selectable
)
Text(
text = "Benchmark",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Benchmark card
Card(
// Conversation card with integrated system prompt picker & editor
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
// Only fill height if system prompt is active
.then(if (useSystemPrompt) Modifier.weight(1f) else Modifier)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.selectable(
selected = selectedMode == Mode.BENCHMARK,
onClick = {
selectedMode = Mode.BENCHMARK
useSystemPrompt = false
},
enabled = !isLoading,
role = Role.RadioButton
)
.padding(bottom = 12.dp)
// Only fill height if system prompt is active
.then(if (useSystemPrompt) Modifier.fillMaxSize() else Modifier)
) {
// Conversation option
Row(
modifier = Modifier.padding(16.dp),
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedMode == Mode.CONVERSATION,
onClick = { selectedMode = Mode.CONVERSATION },
enabled = !isLoading,
role = Role.RadioButton
)
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMode == Mode.BENCHMARK,
selected = selectedMode == Mode.CONVERSATION,
onClick = null // handled by parent selectable
)
Text(
text = "Benchmark",
text = "Conversation",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Conversation card with integrated system prompt picker & editor
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
// Only fill height if system prompt is active
.then(if (useSystemPrompt) Modifier.weight(1f) else Modifier)
) {
Column(
// System prompt row with switch
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
// Only fill height if system prompt is active
.then(if (useSystemPrompt) Modifier.fillMaxSize() else Modifier)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Conversation option
Row(
Text(
text = "Use system prompt",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedMode == Mode.CONVERSATION,
onClick = { selectedMode = Mode.CONVERSATION },
enabled = !isLoading,
role = Role.RadioButton
)
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMode == Mode.CONVERSATION,
onClick = null // handled by parent selectable
)
Text(
text = "Conversation",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp)
)
}
.padding(start = 32.dp) // Align with radio text
.weight(1f)
)
// System prompt row with switch
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Use system prompt",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(start = 32.dp) // Align with radio text
.weight(1f)
)
Switch(
checked = useSystemPrompt,
onCheckedChange = {
useSystemPrompt = it
if (it && selectedMode != Mode.CONVERSATION) {
selectedMode = Mode.CONVERSATION
}
},
enabled = !isLoading
)
}
// System prompt content (visible when switch is on)
AnimatedVisibility(
visible = useSystemPrompt && selectedMode == Mode.CONVERSATION,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.padding(start = 48.dp, end = 16.dp)
) {
HorizontalDivider(
modifier = Modifier
.padding(top = 4.dp, bottom = 8.dp)
)
// Tab selector using SegmentedButton
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
SegmentedButton(
selected = selectedTab == SystemPromptTab.PRESETS,
onClick = { selectedTab = SystemPromptTab.PRESETS },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3),
icon = {
if (selectedTab == SystemPromptTab.PRESETS) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
},
label = { Text("Presets") }
)
SegmentedButton(
selected = selectedTab == SystemPromptTab.CUSTOM,
onClick = { selectedTab = SystemPromptTab.CUSTOM },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3),
icon = {
if (selectedTab == SystemPromptTab.CUSTOM) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
},
label = { Text("Custom") }
)
SegmentedButton(
selected = selectedTab == SystemPromptTab.RECENTS,
onClick = { selectedTab = SystemPromptTab.RECENTS },
shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3),
icon = {
if (selectedTab == SystemPromptTab.RECENTS) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
},
label = { Text("Recents") }
)
Switch(
checked = useSystemPrompt,
onCheckedChange = {
useSystemPrompt = it
if (it && selectedMode != Mode.CONVERSATION) {
selectedMode = Mode.CONVERSATION
}
},
enabled = !isLoading
)
}
Spacer(modifier = Modifier.height(8.dp))
// System prompt content (visible when switch is on)
AnimatedVisibility(
visible = useSystemPrompt && selectedMode == Mode.CONVERSATION,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.padding(start = 48.dp, end = 16.dp)
) {
HorizontalDivider(
modifier = Modifier
.padding(top = 4.dp, bottom = 8.dp)
)
// Content based on selected tab
when (selectedTab) {
SystemPromptTab.PRESETS -> {
if (presetPrompts.isEmpty()) {
Text(
text = "No preset prompts available.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
} else {
PromptList(
prompts = presetPrompts,
selectedPromptId = selectedPrompt?.id,
expandedPromptId = expandedPromptId,
onPromptSelected = {
selectedPrompt = it
expandedPromptId = it.id
},
onExpandPrompt = { expandedPromptId = it }
// Tab selector using SegmentedButton
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
SegmentedButton(
selected = selectedTab == SystemPromptTab.PRESETS,
onClick = { selectedTab = SystemPromptTab.PRESETS },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3),
icon = {
if (selectedTab == SystemPromptTab.PRESETS) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
}
},
label = { Text("Presets") }
)
SystemPromptTab.CUSTOM -> {
// Custom prompt editor (fill remaining space)
OutlinedTextField(
value = customPromptText,
onValueChange = {
customPromptText = it
// Deselect any preset prompt if typing custom
if (it.isNotBlank()) {
selectedPrompt = null
}
SegmentedButton(
selected = selectedTab == SystemPromptTab.CUSTOM,
onClick = { selectedTab = SystemPromptTab.CUSTOM },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3),
icon = {
if (selectedTab == SystemPromptTab.CUSTOM) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
},
label = { Text("Custom") }
)
SegmentedButton(
selected = selectedTab == SystemPromptTab.RECENTS,
onClick = { selectedTab = SystemPromptTab.RECENTS },
shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3),
icon = {
if (selectedTab == SystemPromptTab.RECENTS) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
},
label = { Text("Recents") }
)
}
Spacer(modifier = Modifier.height(8.dp))
// Content based on selected tab
when (selectedTab) {
SystemPromptTab.PRESETS -> {
if (presetPrompts.isEmpty()) {
Text(
text = "No preset prompts available.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
} else {
PromptList(
prompts = presetPrompts,
selectedPromptId = selectedPrompt?.id,
expandedPromptId = expandedPromptId,
onPromptSelected = {
selectedPrompt = it
expandedPromptId = it.id
},
modifier = Modifier
.fillMaxWidth()
.fillMaxSize(), // Fill available space
label = { Text("Enter system prompt") },
placeholder = { Text("You are a helpful assistant...") },
minLines = 5
onExpandPrompt = { expandedPromptId = it }
)
}
}
SystemPromptTab.RECENTS -> {
if (recentPrompts.isEmpty()) {
Text(
text = "No recent prompts found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
} else {
PromptList(
prompts = recentPrompts,
selectedPromptId = selectedPrompt?.id,
expandedPromptId = expandedPromptId,
onPromptSelected = {
selectedPrompt = it
expandedPromptId = it.id
},
onExpandPrompt = { expandedPromptId = it }
)
}
SystemPromptTab.CUSTOM -> {
// Custom prompt editor (fill remaining space)
OutlinedTextField(
value = customPromptText,
onValueChange = {
customPromptText = it
// Deselect any preset prompt if typing custom
if (it.isNotBlank()) {
selectedPrompt = null
}
},
modifier = Modifier
.fillMaxWidth()
.fillMaxSize(), // Fill available space
label = { Text("Enter system prompt") },
placeholder = { Text("You are a helpful assistant...") },
minLines = 5
)
}
SystemPromptTab.RECENTS -> {
if (recentPrompts.isEmpty()) {
Text(
text = "No recent prompts found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
} else {
PromptList(
prompts = recentPrompts,
selectedPromptId = selectedPrompt?.id,
expandedPromptId = expandedPromptId,
onPromptSelected = {
selectedPrompt = it
expandedPromptId = it.id
},
onExpandPrompt = { expandedPromptId = it }
)
}
}
}
}
}
}
}
// Flexible spacer when system prompt is not active
if (!useSystemPrompt) {
Spacer(modifier = Modifier.weight(1f))
} else {
Spacer(modifier = Modifier.height(8.dp))
}
// Flexible spacer when system prompt is not active
if (!useSystemPrompt) {
Spacer(modifier = Modifier.weight(1f))
} else {
Spacer(modifier = Modifier.height(8.dp))
}
// Start button
Button(
onClick = {
when (selectedMode) {
Mode.BENCHMARK -> handleBenchmarkSelected()
// Start button
Button(
onClick = {
when (selectedMode) {
Mode.BENCHMARK -> handleBenchmarkSelected()
Mode.CONVERSATION -> {
val systemPrompt = if (useSystemPrompt) {
when (selectedTab) {
SystemPromptTab.PRESETS, SystemPromptTab.RECENTS ->
selectedPrompt?.let { prompt ->
// Save the prompt to recent prompts database
modelLoadingViewModel.savePromptToRecents(prompt)
prompt.content
Mode.CONVERSATION -> {
val systemPrompt = if (useSystemPrompt) {
when (selectedTab) {
SystemPromptTab.PRESETS, SystemPromptTab.RECENTS ->
selectedPrompt?.let { prompt ->
// Save the prompt to recent prompts database
modelLoadingViewModel.savePromptToRecents(prompt)
prompt.content
}
SystemPromptTab.CUSTOM ->
customPromptText.takeIf { it.isNotBlank() }
?.also { promptText ->
// Save custom prompt to database
modelLoadingViewModel.saveCustomPromptToRecents(
promptText
)
}
}
} else null
SystemPromptTab.CUSTOM ->
customPromptText.takeIf { it.isNotBlank() }
?.also { promptText ->
// Save custom prompt to database
modelLoadingViewModel.saveCustomPromptToRecents(promptText)
}
}
} else null
handleConversationSelected(systemPrompt)
}
null -> { /* No mode selected */ }
handleConversationSelected(systemPrompt)
}
null -> { /* No mode selected */
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = selectedMode != null && !isLoading &&
(!useSystemPrompt || hasActiveSystemPrompt)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier
.height(24.dp)
.width(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (engineState) {
is State.LoadingModel -> "Loading model..."
is State.ProcessingSystemPrompt -> "Processing system prompt..."
is State.ModelReady -> "Preparing conversation..."
else -> "Processing..."
},
style = MaterialTheme.typography.titleMedium
)
} else {
Text(text = "Start", style = MaterialTheme.typography.titleMedium)
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = selectedMode != null && !isLoading &&
(!useSystemPrompt || hasActiveSystemPrompt)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier
.height(24.dp)
.width(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (engineState) {
is State.LoadingModel -> "Loading model..."
is State.ProcessingSystemPrompt -> "Processing system prompt..."
is State.ModelReady -> "Preparing conversation..."
else -> "Processing..."
},
style = MaterialTheme.typography.titleMedium
)
} else {
Text(text = "Start", style = MaterialTheme.typography.titleMedium)
}
}
}

View File

@ -29,7 +29,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.ui.components.ModelCard
import com.example.llama.revamp.ui.components.ModelCardActions
import com.example.llama.revamp.ui.components.PerformanceAppScaffold
import com.example.llama.revamp.viewmodel.ModelSelectionViewModel
@OptIn(ExperimentalMaterial3Api::class)
@ -37,7 +36,6 @@ import com.example.llama.revamp.viewmodel.ModelSelectionViewModel
fun ModelSelectionScreen(
onModelSelected: (ModelInfo) -> Unit,
onManageModelsClicked: () -> Unit,
onMenuClicked: () -> Unit,
viewModel: ModelSelectionViewModel = hiltViewModel(),
) {
val models by viewModel.availableModels.collectAsState()
@ -47,35 +45,28 @@ fun ModelSelectionScreen(
onModelSelected(model)
}
PerformanceAppScaffold(
title = "Models",
onMenuOpen = onMenuClicked,
showTemperature = false
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
if (models.isEmpty()) {
EmptyModelsView(onManageModelsClicked)
} else {
LazyColumn {
items(models) { model ->
ModelCard(
model = model,
onClick = { handleModelSelection(model) },
modifier = Modifier.padding(vertical = 4.dp),
isSelected = null, // Not in selection mode
actionButton = {
ModelCardActions.PlayButton {
handleModelSelection(model)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
if (models.isEmpty()) {
EmptyModelsView(onManageModelsClicked)
} else {
LazyColumn {
items(models) { model ->
ModelCard(
model = model,
onClick = { handleModelSelection(model) },
modifier = Modifier.padding(vertical = 4.dp),
isSelected = null, // Not in selection mode
actionButton = {
ModelCardActions.PlayButton {
handleModelSelection(model)
}
)
Spacer(modifier = Modifier.height(8.dp))
}
}
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View File

@ -1,8 +1,6 @@
package com.example.llama.revamp.ui.screens
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -16,63 +14,33 @@ 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.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.DeleteSweep
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.llama.R
import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.ui.components.ModelCard
import com.example.llama.revamp.ui.components.ModelCardActions
import com.example.llama.revamp.ui.components.StorageAppScaffold
import com.example.llama.revamp.ui.components.ScaffoldEvent
import com.example.llama.revamp.util.formatSize
import com.example.llama.revamp.viewmodel.ModelManagementState
import com.example.llama.revamp.viewmodel.ModelManagementState.Deletion
import com.example.llama.revamp.viewmodel.ModelManagementState.Importation
import com.example.llama.revamp.viewmodel.ModelSortOrder
import com.example.llama.revamp.viewmodel.ModelsManagementViewModel
import kotlinx.coroutines.launch
/**
* Screen for managing LLM models (view, download, delete)
@ -80,381 +48,150 @@ import kotlinx.coroutines.launch
@Composable
fun ModelsManagementScreen(
onBackPressed: () -> Unit,
viewModel: ModelsManagementViewModel = hiltViewModel()
onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsManagementViewModel,
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// ViewModel states
val storageMetrics by viewModel.storageMetrics.collectAsState()
val sortOrder by viewModel.sortOrder.collectAsState()
val sortedModels by viewModel.sortedModels.collectAsState()
val managementState by viewModel.managementState.collectAsState()
// UI state: sorting
var showSortMenu by remember { mutableStateOf(false) }
// UI state: importing
var showImportModelMenu by remember { mutableStateOf(false) }
val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { viewModel.localModelFileSelected(it) } }
// UI state: multi-selecting
var isMultiSelectionMode by remember { mutableStateOf(false) }
val selectedModels = remember { mutableStateMapOf<String, ModelInfo>() }
val exitSelectionMode = {
isMultiSelectionMode = false
selectedModels.clear()
}
// Selection state from ViewModel
val isMultiSelectionMode by viewModel.isMultiSelectionMode.collectAsState()
val selectedModels by viewModel.selectedModels.collectAsState()
BackHandler(
enabled = managementState is Importation.Importing || isMultiSelectionMode
enabled = isMultiSelectionMode
|| managementState is Importation.Importing
|| managementState is Deletion.Deleting
) {
if (isMultiSelectionMode) {
// Exit selection mode if in selection mode
exitSelectionMode()
viewModel.setMultiSelectionMode(false)
} else {
/* Ignore back press while processing model management requests */
}
}
StorageAppScaffold(
title = "Models Management",
storageUsed = storageMetrics?.usedGB ?: 0f,
storageTotal = storageMetrics?.totalGB ?: 0f,
onNavigateBack = onBackPressed,
snackbarHostState = snackbarHostState,
bottomBar = {
BottomAppBar(
actions = {
if (isMultiSelectionMode) {
// Multi-selection mode actions
IconButton(onClick = {
// Select all
selectedModels.putAll(sortedModels.map { it.id to it })
}) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
Box(modifier = Modifier.fillMaxSize()) {
// Model cards
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
items(items = sortedModels, key = { it.id }) { model ->
ModelCard(
model = model,
onClick = {
if (isMultiSelectionMode) {
viewModel.toggleModelSelection(model.id)
} else {
viewModel.viewModelDetails(model.id)
}
IconButton(onClick = {
// Deselect all
selectedModels.clear()
}) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(
onClick = {
if (selectedModels.isNotEmpty()) {
viewModel.batchDeletionClicked(selectedModels.toMap())
}
},
enabled = selectedModels.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete selected",
tint = if (selectedModels.isNotEmpty())
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
} else {
// Default mode actions
IconButton(onClick = { showSortMenu = true }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sort dropdown menu
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
DropdownMenuItem(
text = { Text("Name (A-Z)") },
trailingIcon = {
if (sortOrder == ModelSortOrder.NAME_ASC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by name in ascending order, selected"
)
},
onClick = {
viewModel.setSortOrder(ModelSortOrder.NAME_ASC)
showSortMenu = false
}
)
DropdownMenuItem(
text = { Text("Name (Z-A)") },
trailingIcon = {
if (sortOrder == ModelSortOrder.NAME_DESC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by name in descending order, selected"
)
},
onClick = {
viewModel.setSortOrder(ModelSortOrder.NAME_DESC)
showSortMenu = false
}
)
DropdownMenuItem(
text = { Text("Size (Smallest first)") },
trailingIcon = {
if (sortOrder == ModelSortOrder.SIZE_ASC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by size in ascending order, selected"
)
},
onClick = {
viewModel.setSortOrder(ModelSortOrder.SIZE_ASC)
showSortMenu = false
}
)
DropdownMenuItem(
text = { Text("Size (Largest first)") },
trailingIcon = {
if (sortOrder == ModelSortOrder.SIZE_DESC)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by size in descending order, selected"
)
},
onClick = {
viewModel.setSortOrder(ModelSortOrder.SIZE_DESC)
showSortMenu = false
}
)
DropdownMenuItem(
text = { Text("Last used") },
trailingIcon = {
if (sortOrder == ModelSortOrder.LAST_USED)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Sort by last used, selected"
)
},
onClick = {
viewModel.setSortOrder(ModelSortOrder.LAST_USED)
showSortMenu = false
}
)
}
IconButton(
onClick = {/* TODO-han.yin: implement filtering once metadata ready */ }
) {
Icon(
imageVector = Icons.Default.FilterAlt,
contentDescription = "Filter models"
)
}
IconButton(onClick = {
isMultiSelectionMode = true
}) {
Icon(
imageVector = Icons.Default.DeleteSweep,
contentDescription = "Delete models"
)
}
}
},
floatingActionButton = {
FloatingActionButton(
onClick = {
if (isMultiSelectionMode) {
exitSelectionMode()
} else {
showImportModelMenu = true
}
},
containerColor = MaterialTheme.colorScheme.primaryContainer
) {
Icon(
imageVector = if (isMultiSelectionMode) Icons.Default.Close else Icons.Default.Add,
contentDescription = if (isMultiSelectionMode) "Exit selection mode" else "Add model"
)
}
// Add model dropdown menu
DropdownMenu(
expanded = showImportModelMenu,
onDismissRequest = { showImportModelMenu = false }
) {
DropdownMenuItem(
text = { Text("Import local model") },
leadingIcon = {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = "Import a local model on the device"
},
modifier = Modifier.padding(bottom = 8.dp),
isSelected =
if (isMultiSelectionMode) selectedModels.contains(model.id) else null,
actionButton =
if (!isMultiSelectionMode) {
{
ModelCardActions.InfoButton(
onClick = { viewModel.viewModelDetails(model.id) }
)
},
onClick = {
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
showImportModelMenu = false
}
} else null
)
}
}
// Model import progress overlay
when (val state = managementState) {
is Importation.Confirming -> {
ImportProgressDialog(
fileName = state.fileName,
fileSize = state.fileSize,
isImporting = false,
progress = 0.0f,
onConfirm = {
viewModel.importLocalModelFile(state.uri, state.fileName, state.fileSize)
},
onCancel = { viewModel.resetManagementState() }
)
}
is Importation.Importing -> {
ImportProgressDialog(
fileName = state.fileName,
fileSize = state.fileSize,
isImporting = true,
isCancelling = state.isCancelling,
progress = state.progress,
onConfirm = {},
onCancel = { viewModel.cancelOngoingLocalModelImport() },
)
}
is Importation.Error -> {
ErrorDialog(
title = "Import Failed",
message = state.message,
onDismiss = { viewModel.resetManagementState() }
)
}
is Importation.Success -> {
LaunchedEffect(state) {
onScaffoldEvent(
ScaffoldEvent.ShowSnackbar(
message = "Imported model: ${state.model.name}"
)
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 = {
viewModel.importFromHuggingFace()
showImportModelMenu = false
}
)
}
)
viewModel.resetManagementState()
}
)
},
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
// Model cards
LazyColumn(
modifier = Modifier.padding(paddingValues).fillMaxSize().padding(16.dp)
) {
items(items = sortedModels, key = { it.id }) { model ->
ModelCard(
model = model,
onClick = {
if (isMultiSelectionMode) {
// Toggle selection
if (selectedModels.contains(model.id)) {
selectedModels.remove(model.id)
} else {
selectedModels.put(model.id, sortedModels.first { it.id == model.id } )
}
} else {
// View model details
viewModel.viewModelDetails(model.id)
}
},
modifier = Modifier.padding(bottom = 8.dp),
isSelected =
if (isMultiSelectionMode) selectedModels.contains(model.id) else null,
actionButton =
if (!isMultiSelectionMode) {
{
ModelCardActions.InfoButton(
onClick = { viewModel.viewModelDetails(model.id) }
)
}
} else null
}
is Deletion.Confirming -> {
BatchDeleteConfirmationDialog(
count = state.models.size,
onConfirm = { viewModel.deleteModels(state.models) },
onDismiss = { viewModel.resetManagementState() },
isDeleting = false
)
}
is Deletion.Deleting -> {
BatchDeleteConfirmationDialog(
count = state.models.size,
onConfirm = { /* No-op during processing */ },
onDismiss = { /* No-op during processing */ },
isDeleting = true
)
}
is Deletion.Error -> {
ErrorDialog(
title = "Deletion Failed",
message = state.message,
onDismiss = { viewModel.resetManagementState() }
)
}
is Deletion.Success -> {
LaunchedEffect(state) {
viewModel.setMultiSelectionMode(false)
val count = state.models.size
onScaffoldEvent(
ScaffoldEvent.ShowSnackbar(
message = "Deleted $count ${if (count > 1) "models" else "model"}.",
duration = SnackbarDuration.Long
)
)
}
}
// Model import progress overlay
when (val state = managementState) {
is Importation.Confirming -> {
ImportProgressDialog(
fileName = state.fileName,
fileSize = state.fileSize,
isImporting = false,
progress = 0.0f,
onConfirm = {
viewModel.importLocalModelFile(
state.uri, state.fileName, state.fileSize
)
},
onCancel = { viewModel.resetManagementState() }
)
}
is Importation.Importing -> {
ImportProgressDialog(
fileName = state.fileName,
fileSize = state.fileSize,
isImporting = true,
isCancelling = state.isCancelling,
progress = state.progress,
onConfirm = {},
onCancel = { viewModel.cancelOngoingLocalModelImport() },
)
}
is Importation.Error -> {
ErrorDialog(
title = "Import Failed",
message = state.message,
onDismiss = { viewModel.resetManagementState() }
)
}
is Importation.Success -> {
LaunchedEffect(state) {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = "Imported model: ${state.model.name}",
duration = SnackbarDuration.Short
)
}
viewModel.resetManagementState()
}
}
is Deletion.Confirming -> {
BatchDeleteConfirmationDialog(
count = state.models.size,
onConfirm = { viewModel.deleteModels(state.models) },
onDismiss = { viewModel.resetManagementState() },
isDeleting = false
)
}
is Deletion.Deleting -> {
BatchDeleteConfirmationDialog(
count = state.models.size,
onConfirm = { /* No-op during processing */ },
onDismiss = { /* No-op during processing */ },
isDeleting = true
)
}
is Deletion.Error -> {
ErrorDialog(
title = "Deletion Failed",
message = state.message,
onDismiss = { viewModel.resetManagementState() }
)
}
is Deletion.Success -> {
LaunchedEffect(state) {
exitSelectionMode()
coroutineScope.launch {
val count = state.models.size
snackbarHostState.showSnackbar(
message = "Deleted $count ${if (count > 1) "models" else "model"}.",
duration = SnackbarDuration.Long
)
}
}
}
is ModelManagementState.Idle -> { /* Idle state, nothing to show */ }
}
is ModelManagementState.Idle -> { /* Idle state, nothing to show */ }
}
}
}

View File

@ -21,7 +21,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.llama.revamp.ui.components.DefaultAppScaffold
import com.example.llama.revamp.viewmodel.PerformanceViewModel
/**
@ -31,76 +30,68 @@ import com.example.llama.revamp.viewmodel.PerformanceViewModel
fun SettingsGeneralScreen(
performanceViewModel: PerformanceViewModel = hiltViewModel(),
onBackPressed: () -> Unit,
onMenuClicked: () -> Unit
) {
// Collect state from ViewModel
val isMonitoringEnabled by performanceViewModel.isMonitoringEnabled.collectAsState()
val useFahrenheit by performanceViewModel.useFahrenheitUnit.collectAsState()
DefaultAppScaffold(
title = "Settings",
onNavigateBack = onBackPressed,
onMenuOpen = onMenuClicked
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
SettingsCategory(title = "Performance Monitoring") {
SettingsSwitch(
title = "Enable Monitoring",
description = "Display memory, battery and temperature information",
checked = isMonitoringEnabled,
onCheckedChange = { performanceViewModel.setMonitoringEnabled(it) }
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
SettingsCategory(title = "Performance Monitoring") {
SettingsSwitch(
title = "Enable Monitoring",
description = "Display memory, battery and temperature information",
checked = isMonitoringEnabled,
onCheckedChange = { performanceViewModel.setMonitoringEnabled(it) }
)
SettingsSwitch(
title = "Use Fahrenheit",
description = "Display temperature in Fahrenheit instead of Celsius",
checked = useFahrenheit,
onCheckedChange = { performanceViewModel.setUseFahrenheitUnit(it) }
)
}
SettingsSwitch(
title = "Use Fahrenheit",
description = "Display temperature in Fahrenheit instead of Celsius",
checked = useFahrenheit,
onCheckedChange = { performanceViewModel.setUseFahrenheitUnit(it) }
)
}
SettingsCategory(title = "Theme") {
SettingsSwitch(
title = "Dark Theme",
description = "Use dark theme throughout the app",
checked = true, // This would be connected to theme state in a real app
onCheckedChange = {
/* TODO-hyin: Implement theme switching between Auto, Light and Dark */
}
)
}
SettingsCategory(title = "Theme") {
SettingsSwitch(
title = "Dark Theme",
description = "Use dark theme throughout the app",
checked = true, // This would be connected to theme state in a real app
onCheckedChange = {
/* TODO-hyin: Implement theme switching between Auto, Light and Dark */
}
)
}
SettingsCategory(title = "About") {
Card(
modifier = Modifier.fillMaxWidth()
SettingsCategory(title = "About") {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Kleidi LLaMA",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Kleidi LLaMA",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Version 1.0.0",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Version 1.0.0",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Local inference for LLM models on your device.",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Local inference for LLM models on your device.",
style = MaterialTheme.typography.bodyMedium
)
}
}
}

View File

@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
import com.example.llama.revamp.data.model.ModelInfo
import com.example.llama.revamp.data.repository.InsufficientStorageException
import com.example.llama.revamp.data.repository.ModelRepository
import com.example.llama.revamp.data.repository.StorageMetrics
import com.example.llama.revamp.util.getFileNameFromUri
import com.example.llama.revamp.util.getFileSizeFromUri
import com.example.llama.revamp.viewmodel.ModelManagementState.Deletion
@ -32,13 +31,7 @@ class ModelsManagementViewModel @Inject constructor(
private val modelRepository: ModelRepository
) : ViewModel() {
val storageMetrics: StateFlow<StorageMetrics?> = modelRepository.getStorageMetrics()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = null
)
// Data: available models
private val _availableModels: StateFlow<List<ModelInfo>> = modelRepository.getModels()
.stateIn(
scope = viewModelScope,
@ -46,11 +39,68 @@ class ModelsManagementViewModel @Inject constructor(
initialValue = emptyList()
)
private val _sortedModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val sortedModels: StateFlow<List<ModelInfo>> = _sortedModels.asStateFlow()
// UI state: multi-selection mode
private val _isMultiSelectionMode = MutableStateFlow(false)
val isMultiSelectionMode: StateFlow<Boolean> = _isMultiSelectionMode.asStateFlow()
fun setMultiSelectionMode(enabled: Boolean) {
_isMultiSelectionMode.value = enabled
if (!enabled) {
clearSelectedModels()
}
}
// UI state: models selected in multi-selection
private val _selectedModels = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
val selectedModels: StateFlow<Map<String, ModelInfo>> = _selectedModels.asStateFlow()
fun toggleModelSelection(modelId: String) {
val current = _selectedModels.value.toMutableMap()
val model = _sortedModels.value.find { it.id == modelId }
if (model != null) {
if (current.containsKey(modelId)) {
current.remove(modelId)
} else {
current[modelId] = model
}
_selectedModels.value = current
}
}
fun selectAllModels() {
_selectedModels.value = _sortedModels.value.associateBy { it.id }
}
fun clearSelectedModels() {
_selectedModels.value = emptyMap()
}
// UI state: sort menu
private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC)
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow()
private val _sortedModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val sortedModels: StateFlow<List<ModelInfo>> = _sortedModels.asStateFlow()
fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order
}
private val _showSortMenu = MutableStateFlow(false)
val showSortMenu: StateFlow<Boolean> = _showSortMenu.asStateFlow()
fun toggleSortMenu(show: Boolean) {
_showSortMenu.value = show
}
// UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
fun toggleImportMenu(show: Boolean) {
_showImportModelMenu.value = show
}
init {
viewModelScope.launch {
@ -68,14 +118,7 @@ class ModelsManagementViewModel @Inject constructor(
ModelSortOrder.LAST_USED -> models.sortedByDescending { it.lastUsed ?: 0 }
}
fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order
}
fun viewModelDetails(modelId: String) {
// TODO-han.yin: Stub for now. Would navigate to model details screen or show dialog
}
// Internal state
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
@ -83,6 +126,10 @@ class ModelsManagementViewModel @Inject constructor(
_managementState.value = ModelManagementState.Idle
}
fun viewModelDetails(modelId: String) {
// TODO-han.yin: Stub for now. Would navigate to model details screen or show dialog
}
/**
* First show confirmation instead of starting import immediately
*/

View File

@ -1,9 +1,10 @@
package com.example.llama.revamp.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.llama.revamp.data.preferences.UserPreferences
import com.example.llama.revamp.data.repository.ModelRepository
import com.example.llama.revamp.data.repository.StorageMetrics
import com.example.llama.revamp.monitoring.BatteryMetrics
import com.example.llama.revamp.monitoring.MemoryMetrics
import com.example.llama.revamp.monitoring.PerformanceMonitor
@ -22,10 +23,15 @@ import javax.inject.Inject
*/
@HiltViewModel
class PerformanceViewModel @Inject constructor(
private val userPreferences: UserPreferences,
private val performanceMonitor: PerformanceMonitor,
private val userPreferences: UserPreferences
private val modelRepository: ModelRepository,
) : ViewModel() {
// Storage usage metrics
private val _storageMetrics = MutableStateFlow<StorageMetrics?>(null)
val storageMetrics: StateFlow<StorageMetrics?> = _storageMetrics.asStateFlow()
// Memory usage metrics
private val _memoryUsage = MutableStateFlow(MemoryMetrics(0, 0, 0, 0f, 0f))
val memoryUsage: StateFlow<MemoryMetrics> = _memoryUsage.asStateFlow()
@ -68,6 +74,12 @@ class PerformanceViewModel @Inject constructor(
private fun startMonitoring() {
val interval = _monitoringInterval.value
viewModelScope.launch {
modelRepository.getStorageMetrics().collect { metrics ->
_storageMetrics.value = metrics
}
}
viewModelScope.launch {
performanceMonitor.monitorMemoryUsage(interval).collect { metrics ->
_memoryUsage.value = metrics
@ -132,20 +144,4 @@ class PerformanceViewModel @Inject constructor(
private fun isMonitoringActive(): Boolean {
return _isMonitoringEnabled.value
}
/**
* Factory for creating PerformanceViewModel instances.
*/
class Factory(
private val performanceMonitor: PerformanceMonitor,
private val userPreferences: UserPreferences
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(PerformanceViewModel::class.java)) {
return PerformanceViewModel(performanceMonitor, userPreferences) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}