diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt index 3834b5085b..dfa9987816 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/MainActivity.kt @@ -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() + } +} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/preferences/UserPreferences.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/preferences/UserPreferences.kt index fc4eeed65c..1759326b5b 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/preferences/UserPreferences.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/preferences/UserPreferences.kt @@ -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 { return context.dataStore.data.map { preferences -> diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt index 24ee752658..017b016414 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/data/repository/ModelRepository.kt @@ -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 diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffold.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffold.kt new file mode 100644 index 0000000000..432e51dcc0 --- /dev/null +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffold.kt @@ -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 + ) +} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffolds.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffolds.kt deleted file mode 100644 index 7ffd6f7a5b..0000000000 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/AppScaffolds.kt +++ /dev/null @@ -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 - ) -} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/BottomAppBars.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/BottomAppBars.kt new file mode 100644 index 0000000000..636ccd4f55 --- /dev/null +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/BottomAppBars.kt @@ -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, + 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, + 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 + ) + } + } + ) +} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/TopAppBars.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/TopAppBars.kt index a20ca63627..e8ff9a9266 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/TopAppBars.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/components/TopAppBars.kt @@ -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?, + ) : 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 ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/BenchmarkScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/BenchmarkScreen.kt index d132b7e4fb..ba6a2f4ade 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/BenchmarkScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/BenchmarkScreen.kt @@ -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 + ) + } + } } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt index 897fd967b4..9e69551f55 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ConversationScreen.kt @@ -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) { diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelLoadingScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelLoadingScreen.kt index df076dcbb7..ca55df5e02 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelLoadingScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelLoadingScreen.kt @@ -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) } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt index c64f62bbda..5ee47da6d7 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelSelectionScreen.kt @@ -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)) } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt index eccdf85a41..581c5b1506 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/ModelsManagementScreen.kt @@ -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() } - 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 */ } } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/SettingsGeneralScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/SettingsGeneralScreen.kt index 76bb97ab05..de5e46ced5 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/SettingsGeneralScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/ui/screens/SettingsGeneralScreen.kt @@ -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 + ) } } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt index 9a6851c43b..1d98f4bcc7 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/ModelsManagementViewModel.kt @@ -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 = modelRepository.getStorageMetrics() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS), - initialValue = null - ) - + // Data: available models private val _availableModels: StateFlow> = modelRepository.getModels() .stateIn( scope = viewModelScope, @@ -46,11 +39,68 @@ class ModelsManagementViewModel @Inject constructor( initialValue = emptyList() ) + private val _sortedModels = MutableStateFlow>(emptyList()) + val sortedModels: StateFlow> = _sortedModels.asStateFlow() + + // UI state: multi-selection mode + private val _isMultiSelectionMode = MutableStateFlow(false) + val isMultiSelectionMode: StateFlow = _isMultiSelectionMode.asStateFlow() + + fun setMultiSelectionMode(enabled: Boolean) { + _isMultiSelectionMode.value = enabled + if (!enabled) { + clearSelectedModels() + } + } + + // UI state: models selected in multi-selection + private val _selectedModels = MutableStateFlow>(emptyMap()) + val selectedModels: StateFlow> = _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 = _sortOrder.asStateFlow() - private val _sortedModels = MutableStateFlow>(emptyList()) - val sortedModels: StateFlow> = _sortedModels.asStateFlow() + fun setSortOrder(order: ModelSortOrder) { + _sortOrder.value = order + } + + private val _showSortMenu = MutableStateFlow(false) + val showSortMenu: StateFlow = _showSortMenu.asStateFlow() + + fun toggleSortMenu(show: Boolean) { + _showSortMenu.value = show + } + + // UI state: import menu + private val _showImportModelMenu = MutableStateFlow(false) + val showImportModelMenu: StateFlow = _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.Idle) val managementState: StateFlow = _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 */ diff --git a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/PerformanceViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/PerformanceViewModel.kt index b0eb05f847..247358a358 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/PerformanceViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/revamp/viewmodel/PerformanceViewModel.kt @@ -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(null) + val storageMetrics: StateFlow = _storageMetrics.asStateFlow() + // Memory usage metrics private val _memoryUsage = MutableStateFlow(MemoryMetrics(0, 0, 0, 0f, 0f)) val memoryUsage: StateFlow = _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 create(modelClass: Class): T { - if (modelClass.isAssignableFrom(PerformanceViewModel::class.java)) { - return PerformanceViewModel(performanceMonitor, userPreferences) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } - } }