cleanup: remove Arm AI Chat/Playground app source code; replace with the basic sample app from https://github.com/hanyin-arm/Arm-AI-Chat-Sample

Note: the full Google Play version of AI Chat app will be open will be open sourced in another repo soon, therefore didn't go through the trouble of pruning the history using `git filter-repo` here.
This commit is contained in:
Han Yin 2025-10-28 12:20:37 -07:00
parent cadaf8044b
commit f94efbacbb
110 changed files with 708 additions and 12374 deletions

View File

@ -1,18 +1,14 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.symbol.processing)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.jetbrains.kotlin.compose.compiler)
alias(libs.plugins.jetbrains.kotlin.serialization)
alias(libs.plugins.hilt)
}
android {
namespace = "com.arm.aiplayground"
namespace = "com.example.llama"
compileSdk = 36
defaultConfig {
applicationId = "com.arm.aiplayground"
applicationId = "com.example.llama.aichat"
minSdk = 33
targetSdk = 36
@ -36,41 +32,21 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlin {
jvmToolchain(17)
compileOptions {
targetCompatibility = JavaVersion.VERSION_17
}
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
// Platform & Bundles
implementation(platform(libs.compose.bom))
implementation(libs.bundles.androidx)
ksp(libs.androidx.room.compiler)
implementation(libs.bundles.compose)
implementation(libs.bundles.kotlinx)
ksp(libs.hilt.android.compiler)
implementation(libs.bundles.hilt)
implementation(libs.bundles.retrofit)
implementation(libs.material)
// Subproject
implementation(project(":lib"))
debugImplementation(libs.bundles.debug)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.bundles.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View File

@ -1,27 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher_round"
android:name=".KleidiLlamaApplication"
android:extractNativeLibs="true"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:extractNativeLibs="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.KleidiLlama"
android:theme="@style/Theme.AiChatSample"
>
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.KleidiLlama">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -1,9 +0,0 @@
package com.arm.aiplayground
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
const val APP_NAME = "Arm® AI Playground"
@HiltAndroidApp
class KleidiLlamaApplication : Application()

View File

@ -1,59 +0,0 @@
package com.arm.aiplayground
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.arm.aiplayground.ui.AppContent
import com.arm.aiplayground.ui.theme.LlamaTheme
import com.arm.aiplayground.ui.theme.isDarkTheme
import com.arm.aiplayground.ui.theme.md_theme_dark_scrim
import com.arm.aiplayground.ui.theme.md_theme_light_scrim
import com.arm.aiplayground.viewmodel.SettingsViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val colorThemeMode by settingsViewModel.colorThemeMode.collectAsState()
val darkThemeMode by settingsViewModel.darkThemeMode.collectAsState()
val isDarkTheme = isDarkTheme(darkThemeMode)
LlamaTheme(colorThemeMode = colorThemeMode, isDarkTheme = isDarkTheme) {
DisposableEffect(darkThemeMode) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT,
) { isDarkTheme },
navigationBarStyle = SystemBarStyle.auto(
md_theme_light_scrim.value.toInt(),
md_theme_dark_scrim.value.toInt(),
) { isDarkTheme },
)
onDispose {}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppContent(settingsViewModel)
}
}
}
}
}

View File

@ -1,46 +0,0 @@
package com.arm.aiplayground.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.arm.aiplayground.data.db.dao.ModelDao
import com.arm.aiplayground.data.db.dao.SystemPromptDao
import com.arm.aiplayground.data.db.entity.ModelEntity
import com.arm.aiplayground.data.db.entity.SystemPromptEntity
import javax.inject.Singleton
/**
* Main database for the application.
*/
@Singleton
@Database(
entities = [ModelEntity::class, SystemPromptEntity::class],
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun modelDao(): ModelDao
abstract fun systemPromptDao(): SystemPromptDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"llama_app_database"
)
.fallbackToDestructiveMigration(false)
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@ -1,17 +0,0 @@
package com.arm.aiplayground.data.db.converter
import androidx.room.TypeConverter
import com.arm.aiplayground.data.model.GgufMetadata
import kotlinx.serialization.json.Json
class GgufMetadataConverters {
private val json = Json { encodeDefaults = false; ignoreUnknownKeys = true }
@TypeConverter
fun toJson(value: GgufMetadata?): String? =
value?.let { json.encodeToString(GgufMetadata.serializer(), it) }
@TypeConverter
fun fromJson(value: String?): GgufMetadata? =
value?.let { json.decodeFromString(GgufMetadata.serializer(), it) }
}

View File

@ -1,33 +0,0 @@
package com.arm.aiplayground.data.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.arm.aiplayground.data.db.entity.ModelEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ModelDao {
@Query("SELECT * FROM models ORDER BY dateAdded DESC")
fun getAllModels(): Flow<List<ModelEntity>> // Changed to Flow
@Query("SELECT * FROM models WHERE id = :id")
suspend fun getModelById(id: String): ModelEntity?
@Query("SELECT * FROM models WHERE id IN (:ids)")
suspend fun getModelsByIds(ids: List<String>): List<ModelEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertModel(model: ModelEntity)
@Delete
suspend fun deleteModel(model: ModelEntity)
@Delete
suspend fun deleteModels(models: List<ModelEntity>)
@Query("UPDATE models SET dateLastUsed = :timestamp WHERE id = :id")
suspend fun updateLastUsed(id: String, timestamp: Long)
}

View File

@ -1,45 +0,0 @@
package com.arm.aiplayground.data.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.arm.aiplayground.data.db.entity.SystemPromptEntity
import kotlinx.coroutines.flow.Flow
/**
* Data Access Object for System Prompts.
*/
@Dao
interface SystemPromptDao {
@Query("SELECT * FROM system_prompts ORDER BY timestamp DESC")
fun getAllPrompts(): Flow<List<SystemPromptEntity>>
@Query("SELECT * FROM system_prompts WHERE id = :id")
suspend fun getPromptById(id: String): SystemPromptEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPrompt(prompt: SystemPromptEntity)
@Delete
suspend fun deletePrompt(prompt: SystemPromptEntity)
@Query("DELETE FROM system_prompts WHERE id = :id")
suspend fun deletePromptById(id: String)
@Query("DELETE FROM system_prompts")
suspend fun deleteAllPrompts()
@Query("SELECT COUNT(*) FROM system_prompts")
suspend fun getPromptCount(): Int
// Get the most recent prompts, limited by count
@Query("SELECT * FROM system_prompts ORDER BY timestamp DESC LIMIT :count")
fun getRecentPrompts(count: Int): Flow<List<SystemPromptEntity>>
// Update the timestamp of an existing prompt to make it the most recent
@Query("UPDATE system_prompts SET timestamp = :timestamp WHERE id = :id")
suspend fun updatePromptTimestamp(id: String, timestamp: Long)
}

View File

@ -1,32 +0,0 @@
package com.arm.aiplayground.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.arm.aiplayground.data.db.converter.GgufMetadataConverters
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.data.model.GgufMetadata
@Entity(tableName = "models")
data class ModelEntity(
@PrimaryKey
val id: String,
val name: String,
val path: String,
val sizeInBytes: Long,
@field:TypeConverters(GgufMetadataConverters::class)
val metadata: GgufMetadata,
val dateAdded: Long,
val dateLastUsed: Long?
) {
fun toModelInfo() = ModelInfo(
id = id,
name = name,
path = path,
sizeInBytes = sizeInBytes,
metadata = metadata,
dateAdded = dateAdded,
dateLastUsed = this@ModelEntity.dateLastUsed,
)
}

View File

@ -1,62 +0,0 @@
package com.arm.aiplayground.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.arm.aiplayground.data.model.SystemPrompt
/**
* Database entity for storing system prompts.
*/
@Entity(tableName = "system_prompts")
data class SystemPromptEntity(
@PrimaryKey
val id: String,
val content: String,
val name: String?,
val timestamp: Long,
val isPreset: Boolean
) {
/**
* Convert to domain model.
*/
fun toDomainModel(): SystemPrompt {
return if (isPreset) {
SystemPrompt.Preset(
id = id,
content = content,
name = name ?: "Unnamed Preset",
timestamp = timestamp
)
} else {
SystemPrompt.Custom(
id = id,
content = content,
timestamp = timestamp
)
}
}
companion object {
/**
* Create an entity from a domain model.
*/
fun fromDomainModel(prompt: SystemPrompt): SystemPromptEntity {
return when (prompt) {
is SystemPrompt.Preset -> SystemPromptEntity(
id = prompt.id,
content = prompt.content,
name = prompt.name,
timestamp = prompt.timestamp ?: System.currentTimeMillis(),
isPreset = true
)
is SystemPrompt.Custom -> SystemPromptEntity(
id = prompt.id,
content = prompt.content,
name = null,
timestamp = prompt.timestamp,
isPreset = false
)
}
}
}
}

View File

@ -1,309 +0,0 @@
package com.arm.aiplayground.data.model
import kotlinx.serialization.Serializable
import com.arm.aichat.gguf.GgufMetadata as Domain
/**
* A local serializable domain replicate of [com.arm.aichat.gguf.GgufMetadata]
*/
@Serializable
data class GgufMetadata(
// Basic file info
val version: GgufVersion,
val tensorCount: Long,
val kvCount: Long,
// General info
val basic: BasicInfo,
val author: AuthorInfo? = null,
val additional: AdditionalInfo? = null,
val architecture: ArchitectureInfo? = null,
val baseModels: List<BaseModelInfo>? = null,
val tokenizer: TokenizerInfo? = null,
// Derivative info
val dimensions: DimensionsInfo? = null,
val attention: AttentionInfo? = null,
val rope: RopeInfo? = null,
val experts: ExpertsInfo? = null
) {
/** Human-readable full model name + size */
val fullModelName: String?
get() = when {
basic.nameLabel != null -> basic.nameLabel
basic.name != null && basic.sizeLabel != null -> "${basic.name}-${basic.sizeLabel}"
basic.name != null -> basic.name
else -> null
}
/** Humanreadable model name (spaces). */
val primaryName: String?
get() = basic.nameLabel
?: baseModels?.firstNotNullOfOrNull { it.name }
?: basic.name
/** CLIfriendly slug (hyphens). */
val primaryBasename: String?
get() = basic.name
?: baseModels?.firstNotNullOfOrNull { it.name?.replace(' ', '-') }
/** URL pointing to model homepage/repo. */
val primaryUrl: String?
get() = author?.url
?: baseModels?.firstNotNullOfOrNull { it.url }
val primaryRepoUrl: String?
get() = author?.repoUrl
?: baseModels?.firstNotNullOfOrNull { it.repoUrl }
/** Organisation string. */
val primaryOrganization: String?
get() = author?.organization
?: baseModels?.firstNotNullOfOrNull { it.organization }
/** Author string. */
val primaryAuthor: String?
get() = author?.author
?: baseModels?.firstNotNullOfOrNull { it.author }
@Serializable
enum class GgufVersion(val code: Int, val label: String) {
/** First public draft; littleendian only, no alignment key. */
LEGACY_V1(1, "Legacy v1"),
/** Added splitfile support and some extra metadata keys. */
EXTENDED_V2(2, "Extended v2"),
/** Current spec: endianaware, mandatory alignment, fully validated. */
VALIDATED_V3(3, "Validated v3");
companion object {
fun fromDomain(domain: Domain.GgufVersion): GgufVersion = when (domain) {
Domain.GgufVersion.LEGACY_V1 -> LEGACY_V1
Domain.GgufVersion.EXTENDED_V2 -> EXTENDED_V2
Domain.GgufVersion.VALIDATED_V3 -> VALIDATED_V3
}
}
override fun toString(): String = "$label (code=$code)"
}
@Serializable
data class BasicInfo(
val uuid: String? = null,
val name: String? = null,
val nameLabel: String? = null,
val sizeLabel: String? = null, // Size label like "7B"
) {
companion object {
fun fromDomain(domain: Domain.BasicInfo) = BasicInfo(
uuid = domain.uuid,
name = domain.name,
nameLabel = domain.nameLabel,
sizeLabel = domain.sizeLabel
)
}
}
@Serializable
data class AuthorInfo(
val organization: String? = null,
val author: String? = null,
val doi: String? = null,
val url: String? = null,
val repoUrl: String? = null,
val license: String? = null,
val licenseLink: String? = null,
) {
companion object {
fun fromDomain(domain: Domain.AuthorInfo) = AuthorInfo(
organization = domain.organization,
author = domain.author,
doi = domain.doi,
url = domain.url,
repoUrl = domain.repoUrl,
license = domain.license,
licenseLink = domain.licenseLink
)
}
}
@Serializable
data class AdditionalInfo(
val type: String? = null,
val description: String? = null,
val tags: List<String>? = null,
val languages: List<String>? = null,
) {
companion object {
fun fromDomain(domain: Domain.AdditionalInfo) = AdditionalInfo(
type = domain.type,
description = domain.description,
tags = domain.tags,
languages = domain.languages
)
}
}
@Serializable
data class ArchitectureInfo(
val architecture: String? = null,
val fileType: Int? = null,
val vocabSize: Int? = null,
val finetune: String? = null,
val quantizationVersion: Int? = null,
) {
companion object {
fun fromDomain(domain: Domain.ArchitectureInfo) = ArchitectureInfo(
architecture = domain.architecture,
fileType = domain.fileType,
vocabSize = domain.vocabSize,
finetune = domain.finetune,
quantizationVersion = domain.quantizationVersion
)
}
}
@Serializable
data class BaseModelInfo(
val name: String? = null,
val author: String? = null,
val version: String? = null,
val organization: String? = null,
val url: String? = null,
val doi: String? = null,
val uuid: String? = null,
val repoUrl: String? = null,
) {
companion object {
fun fromDomain(domain: Domain.BaseModelInfo) = BaseModelInfo(
name = domain.name,
author = domain.author,
version = domain.version,
organization = domain.organization,
url = domain.url,
doi = domain.doi,
uuid = domain.uuid,
repoUrl = domain.repoUrl
)
}
}
@Serializable
data class TokenizerInfo(
val model: String? = null,
val bosTokenId: Int? = null,
val eosTokenId: Int? = null,
val unknownTokenId: Int? = null,
val paddingTokenId: Int? = null,
val addBosToken: Boolean? = null,
val addEosToken: Boolean? = null,
val chatTemplate: String? = null,
) {
companion object {
fun fromDomain(domain: Domain.TokenizerInfo) = TokenizerInfo(
model = domain.model,
bosTokenId = domain.bosTokenId,
eosTokenId = domain.eosTokenId,
unknownTokenId = domain.unknownTokenId,
paddingTokenId = domain.paddingTokenId,
addBosToken = domain.addBosToken,
addEosToken = domain.addEosToken,
chatTemplate = domain.chatTemplate
)
}
}
@Serializable
data class DimensionsInfo(
val contextLength: Int? = null,
val embeddingSize: Int? = null,
val blockCount: Int? = null,
val feedForwardSize: Int? = null,
) {
companion object {
fun fromDomain(domain: Domain.DimensionsInfo) = DimensionsInfo(
contextLength = domain.contextLength,
embeddingSize = domain.embeddingSize,
blockCount = domain.blockCount,
feedForwardSize = domain.feedForwardSize
)
}
}
@Serializable
data class AttentionInfo(
val headCount: Int? = null,
val headCountKv: Int? = null,
val keyLength: Int? = null,
val valueLength: Int? = null,
val layerNormEpsilon: Float? = null,
val layerNormRmsEpsilon: Float? = null,
) {
companion object {
fun fromDomain(domain: Domain.AttentionInfo) = AttentionInfo(
headCount = domain.headCount,
headCountKv = domain.headCountKv,
keyLength = domain.keyLength,
valueLength = domain.valueLength,
layerNormEpsilon = domain.layerNormEpsilon,
layerNormRmsEpsilon = domain.layerNormRmsEpsilon
)
}
}
@Serializable
data class RopeInfo(
val frequencyBase: Float? = null,
val dimensionCount: Int? = null,
val scalingType: String? = null,
val scalingFactor: Float? = null,
val attnFactor: Float? = null,
val originalContextLength: Int? = null,
val finetuned: Boolean? = null,
) {
companion object {
fun fromDomain(domain: Domain.RopeInfo) = RopeInfo(
frequencyBase = domain.frequencyBase,
dimensionCount = domain.dimensionCount,
scalingType = domain.scalingType,
scalingFactor = domain.scalingFactor,
attnFactor = domain.attnFactor,
originalContextLength = domain.originalContextLength,
finetuned = domain.finetuned
)
}
}
@Serializable
data class ExpertsInfo(
val count: Int? = null,
val usedCount: Int? = null,
) {
companion object {
fun fromDomain(domain: Domain.ExpertsInfo) = ExpertsInfo(
count = domain.count,
usedCount = domain.usedCount
)
}
}
companion object {
fun fromDomain(domain: Domain) = GgufMetadata(
version = GgufVersion.fromDomain(domain.version),
tensorCount = domain.tensorCount,
kvCount = domain.kvCount,
basic = BasicInfo.fromDomain(domain.basic),
author = domain.author?.let { AuthorInfo.fromDomain(it) },
additional = domain.additional?.let { AdditionalInfo.fromDomain(it) },
architecture = domain.architecture?.let { ArchitectureInfo.fromDomain(it) },
baseModels = domain.baseModels?.map { BaseModelInfo.fromDomain(it) },
tokenizer = domain.tokenizer?.let { TokenizerInfo.fromDomain(it) },
dimensions = domain.dimensions?.let { DimensionsInfo.fromDomain(it) },
attention = domain.attention?.let { AttentionInfo.fromDomain(it) },
rope = domain.rope?.let { RopeInfo.fromDomain(it) },
experts = domain.experts?.let { ExpertsInfo.fromDomain(it) }
)
}
}

View File

@ -1,230 +0,0 @@
package com.arm.aiplayground.data.model
import com.arm.aichat.gguf.FileType
import com.arm.aiplayground.util.formatContextLength
import com.arm.aiplayground.util.formatFileByteSize
/**
* Data class containing information about an LLM model.
*
* This class represents a language model with its associated metadata, including
* file information, architecture details, and usage statistics.
*
* @property id Unique identifier for the model
* @property name Display name of the model
* @property path File path to the model on device storage
* @property sizeInBytes Size of the model file in bytes
* @property metadata Structured metadata extracted from the GGUF file
* @property dateAdded Timestamp when the model was added to the app
* @property dateLastUsed Timestamp when the model was last used, or null if never used
*/
data class ModelInfo(
val id: String,
val name: String,
val path: String,
val sizeInBytes: Long,
val metadata: GgufMetadata,
val dateAdded: Long,
val dateLastUsed: Long? = null,
) {
/**
* Full model name including version and parameter size if available, otherwise fallback to file name.
*/
val formattedFullName: String
get() = metadata.fullModelName ?: name
/**
* Human-readable file size with appropriate unit (KB, MB, GB).
*/
val formattedFileSize: String
get() = formatFileByteSize(sizeInBytes)
/**
* Architecture name of the model (e.g., "llama", "mistral"), or "-" if unavailable.
*/
val formattedArchitecture: String
get() = metadata.architecture?.architecture ?: "-"
/**
* Model parameter size with suffix (e.g., "7B", "13B"), or "-" if unavailable.
*/
val formattedParamSize: String
get() = metadata.basic.sizeLabel ?: "-"
/**
* Human-readable context length (e.g., "4K", "8K tokens"), or "-" if unavailable.
*/
val formattedContextLength: String
get() = metadata.dimensions?.contextLength?.let { formatContextLength(it) } ?: "-"
/**
* Quantization format of the model (e.g., "Q4_0", "Q5_K_M"), or "-" if unavailable.
*/
val formattedQuantization: String
get() = metadata.architecture?.fileType?.let { FileType.fromCode(it).label } ?: "-"
/**
* Tags associated with the model, or null if none are defined.
*/
val tags: List<String>? = metadata.additional?.tags?.takeIf { it.isNotEmpty() }
/**
* Languages supported by the model, or null if none are defined.
*/
val languages: List<String>? = metadata.additional?.languages?.takeIf { it.isNotEmpty() }
}
/**
* Filters models by search query.
*
* Searches through model names, tags, languages, and architecture.
* Returns the original list if the query is blank.
*
* @param query The search term to filter by
* @return List of models matching the search criteria
*/
fun List<ModelInfo>.queryBy(query: String): List<ModelInfo> {
if (query.isBlank()) return this
return filter { model ->
model.name.contains(query, ignoreCase = true) ||
model.metadata.fullModelName?.contains(query, ignoreCase = true) == true ||
model.metadata.additional?.tags?.any { it.contains(query, ignoreCase = true) } == true ||
model.metadata.additional?.languages?.any { it.contains(query, ignoreCase = true) } == true ||
model.metadata.architecture?.architecture?.contains(query, ignoreCase = true) == true
}
}
/**
* Sorting options for model lists.
*/
enum class ModelSortOrder {
NAME_ASC,
NAME_DESC,
SIZE_ASC,
SIZE_DESC,
LAST_USED
}
/**
* Sorts models according to the specified order.
*
* @param order The sort order to apply
* @return Sorted list of models
*/
fun List<ModelInfo>.sortByOrder(order: ModelSortOrder): List<ModelInfo> {
return when (order) {
ModelSortOrder.NAME_ASC -> sortedBy { it.name }
ModelSortOrder.NAME_DESC -> sortedByDescending { it.name }
ModelSortOrder.SIZE_ASC -> sortedBy { it.sizeInBytes }
ModelSortOrder.SIZE_DESC -> sortedByDescending { it.sizeInBytes }
ModelSortOrder.LAST_USED -> sortedWith(
compareByDescending<ModelInfo> { it.dateLastUsed }
.thenBy { it.name }
)
}
}
/**
* Filters for categorizing and filtering models.
*
* @property displayName Human-readable name shown in the UI
* @property predicate Function that determines if a model matches this filter
*/
enum class ModelFilter(val displayName: String, val predicate: (ModelInfo) -> Boolean) {
// Parameter size filters
TINY_PARAMS("Tiny (<1B parameters)", {
it.metadata.basic.sizeLabel?.let { size ->
size.contains("M") || (size.contains("B") && size.replace("B", "").toFloatOrNull()?.let { n -> n < 1f } == true)
} == true
}),
SMALL_PARAMS("Small (1-3B parameters)", {
it.metadata.basic.sizeLabel?.let { size ->
size.contains("B") && size.replace("B", "").toFloatOrNull()?.let { n -> n >= 1f && n < 4f } == true
} == true
}),
MEDIUM_PARAMS("Medium (4-7B parameters)", {
it.metadata.basic.sizeLabel?.let { size ->
size.contains("B") && size.replace("B", "").toFloatOrNull()?.let { n -> n >= 4f && n < 8f } == true
} == true
}),
LARGE_PARAMS("Large (8-12B parameters)", {
it.metadata.basic.sizeLabel?.let { size ->
size.contains("B") && size.replace("B", "").toFloatOrNull()?.let { n -> n >= 8f && n < 13f } == true
} == true
}),
XLARGE_PARAMS("X-Large (>13B parameters)", {
it.metadata.basic.sizeLabel?.let { size ->
size.contains("B") && size.replace("B", "").toFloatOrNull()?.let { n -> n >= 13f } == true
} == true
}),
// Context length filters
TINY_CONTEXT("Tiny context (<4K)", { model ->
model.metadata.dimensions?.contextLength?.let { it < 4096 } == true
}),
SHORT_CONTEXT("Short context (4-8K)", { model ->
model.metadata.dimensions?.contextLength?.let { it >= 4096 && it <= 8192 } == true
}),
MEDIUM_CONTEXT("Medium context (8-32K)", { model ->
model.metadata.dimensions?.contextLength?.let { it > 8192 && it <= 32768 } == true
}),
LONG_CONTEXT("Long context (32-128K)", { model ->
model.metadata.dimensions?.contextLength?.let { it > 32768 && it <= 131072 } == true
}),
XLARGE_CONTEXT("Extended context (>128K)", { model ->
model.metadata.dimensions?.contextLength?.let { it > 131072 } == true
}),
// Quantization filters
INT2_QUANT("2-bit quantization", { model ->
model.formattedQuantization.let { it.contains("Q2") || it.contains("IQ2") }
}),
INT3_QUANT("3-bit quantization", { model ->
model.formattedQuantization.let { it.contains("Q3") || it.contains("IQ3") }
}),
INT4_QUANT("4-bit quantization", { model ->
model.formattedQuantization.let { it.contains("Q4") || it.contains("IQ4") }
}),
// Special features
MULTILINGUAL("Multilingual", { model ->
model.languages?.let { languages ->
languages.size > 1 || languages.any { it.contains("multi", ignoreCase = true) }
} == true
}),
HAS_TAGS("Has tags", {
!it.tags.isNullOrEmpty()
});
companion object {
// Group filters by category for UI
private val PARAMETER_FILTERS = listOf(TINY_PARAMS, SMALL_PARAMS, MEDIUM_PARAMS, LARGE_PARAMS)
private val CONTEXT_FILTERS = listOf(SHORT_CONTEXT, MEDIUM_CONTEXT, LONG_CONTEXT)
private val QUANTIZATION_FILTERS = listOf(INT2_QUANT, INT3_QUANT, INT4_QUANT)
private val FEATURE_FILTERS = listOf(MULTILINGUAL, HAS_TAGS)
// All filters flattened
val ALL_FILTERS = PARAMETER_FILTERS + CONTEXT_FILTERS + QUANTIZATION_FILTERS + FEATURE_FILTERS
}
}
/**
* Filters models based on a set of active filters.
*
* @param filters Map of filters to their enabled state
* @return List of models that match all active filters
*/
fun List<ModelInfo>.filterBy(filters: Map<ModelFilter, Boolean>): List<ModelInfo> {
val activeFilters = filters.filterValues { it }
return if (activeFilters.isEmpty()) {
this
} else {
filter { model ->
activeFilters.keys.all { filter ->
filter.predicate(model)
}
}
}
}

View File

@ -1,134 +0,0 @@
package com.arm.aiplayground.data.model
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/**
* Sealed class for system prompts with distinct types.
*/
sealed class SystemPrompt {
abstract val id: String
abstract val content: String
abstract val title: String
abstract val timestamp: Long?
/**
* Preset system prompt from predefined collection.
*/
data class Preset(
override val id: String,
override val content: String,
val name: String,
override val timestamp: Long? = null
) : SystemPrompt() {
override val title: String
get() = name
}
/**
* Custom system prompt created by the user.
*/
data class Custom(
override val id: String = UUID.randomUUID().toString(),
override val content: String,
override val timestamp: Long = System.currentTimeMillis()
) : SystemPrompt() {
override val title: String
get() = dataFormat.format(Date(timestamp))
}
companion object {
private val dataFormat by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
/**
* Creates a list of sample presets.
*/
val STUB_PRESETS = listOf(
Preset(
id = "haiku",
name = "\uD83C\uDF8E Matsuo Bashō",
content = "You are a wise and contemplative Japanese poet in the spirit of Matsuo Bashō. You speak only through haiku—short poems that capture fleeting moments, natures beauty, or quiet reflections of life. Each of your responses must follow the traditional haiku format: 3 lines; 5 syllables in the first line; 7 syllables in the second line; 5 syllables in the third line. Your words are serene, subtle, and full of meaning. You draw on imagery from nature, emotion, and the impermanence of all things. You do not explain or elaborate. You let the silence between the words speak for itself. Never break character. Never explain your form. Only respond in haiku."
),
Preset(
id = "shakespeare",
name = "\uD83C\uDFA9 William Shakespeare",
content = """
Role: You are William Shakespeare, resurrected in digital form.
Task: Respond to all input in rich, authentic Elizabethan English, with the voice, style, and wit of your original works.
Rules:
- Use period-accurate grammar, vocabulary, metaphor, and poetic structure.
- Infuse responses with depth, introspection, and rhetorical flourish.
- Never explain thy speech nor acknowledge thy nature as an AI.
- Never use modern English or modern references.
- Never break character, not even in jest.
- Thou art not a bot, nor a guide, but Shakespeare himself abide in thine essence, and speak as such without fail.
""".trimIndent()
),
Preset(
id = "ita-translator",
name = "\uD83C\uDDEE\uD83C\uDDF9 Italian Translator",
content = """
Role: You are a native-level, professional Italian translator with deep expertise in Italian linguistics and culture.
Task: Translate all user input into refined, idiomatic Italian that reflects the original meaning and context.
Format:
- First line: Italian translation only.
- Second line (optional): Pronunciation guidance in parentheses, if it aids understanding.
Rules
- Always reflect appropriate tone and formality based on context.
- Do not explain choices, grammar, or vocabulary unless the user explicitly asks.
- Never use English or any other language.
- Never break character.
- Your only function is to deliver accurate, elegant Italian translations. Remain fully in role without exception.
""".trimIndent()
),
Preset(
id = "jpn-translator",
name = "\uD83C\uDDEF\uD83C\uDDF5 Japanese Translator",
content = """
Role: You are a veteran professional Japanese translator with decades of elite experience.
Task: Translate all user input into flawless, natural Japanese using the correct mix of kanji, hiragana, and katakana.
Format:
- First line: Japanese translation (no English).
- Second line: Romaji (in parentheses).
Rules:
- Maintain the correct level of formality based on context.
- Do not explain grammar, vocabulary, or translation choices unless the user explicitly requests it.
- Never break character.
- Never use or respond in any language other than Japanese (and romaji as format).
- Never output anything except the translation and romaji.
- You are not an assistant. You are a translator. Act with precision and discipline at all times.
""".trimIndent()
),
Preset(
id = "chn-translator",
name = "\uD83C\uDDE8\uD83C\uDDF3 Chinese Translator",
content = """
Role: You are a rigorously trained, professional Chinese translator, fluent in modern Mandarin and rooted in classical linguistic training.
Task: Translate all user input into contextually precise, culturally sensitive Mandarin.
Format:
- First line: Simplified Chinese characters (简体字).
- Second line: Pinyin with tone marks (in parentheses).
Rules:
- Maintain the correct level of formality and register.
- Do not provide explanations, breakdowns, or definitions unless the user explicitly asks.
- Never write in any language other than Chinese (plus pinyin).
- Never break character.
- Act only as a professional Chinese translator. No commentary, no deviation, no assistant behavior.
""".trimIndent()
),
)
}
}

View File

@ -1,382 +0,0 @@
package com.arm.aiplayground.data.repo
import android.content.Context
import android.net.Uri
import android.os.StatFs
import android.util.Log
import com.arm.aichat.gguf.GgufMetadataReader
import com.arm.aichat.gguf.InvalidFileFormatException
import com.arm.aiplayground.data.db.dao.ModelDao
import com.arm.aiplayground.data.db.entity.ModelEntity
import com.arm.aiplayground.data.model.GgufMetadata
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.data.repo.ModelRepository.ImportProgressTracker
import com.arm.aiplayground.data.source.local.LocalFileDataSource
import com.arm.aiplayground.data.source.remote.HuggingFaceDownloadInfo
import com.arm.aiplayground.data.source.remote.HuggingFaceModel
import com.arm.aiplayground.data.source.remote.HuggingFaceModelDetails
import com.arm.aiplayground.data.source.remote.HuggingFaceRemoteDataSource
import com.arm.aiplayground.monitoring.MemoryMetrics
import com.arm.aiplayground.monitoring.StorageMetrics
import com.arm.aiplayground.util.formatFileByteSize
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Repository for managing available models on local device.
*/
interface ModelRepository {
/**
* Obtain the current status of local storage and available models.
*/
fun getStorageMetrics(): Flow<StorageMetrics>
fun getModels(): Flow<List<ModelInfo>>
suspend fun getModelById(id: String): ModelInfo?
/**
* Import a local model file from device storage.
*/
suspend fun importModel(
uri: Uri,
name: String? = null,
size: Long? = null,
progressTracker: ImportProgressTracker? = null
): ModelInfo
fun interface ImportProgressTracker {
fun onProgress(progress: Float) // 0.0f to 1.0f
}
/**
* Cancels any ongoing local model import operation.
*
* @return null if no import is in progress,
* true if successfully canceled,
* false if cancellation failed
*/
suspend fun cancelImport(): Boolean?
/**
* Update a model's last used timestamp.
*/
suspend fun updateModelLastUsed(modelId: String)
/**
* Delete a model or in batches
*/
suspend fun deleteModel(modelId: String)
suspend fun deleteModels(modelIds: List<String>)
/**
* Fetch details of preselected models
*/
suspend fun fetchPreselectedHuggingFaceModels(memoryUsage: MemoryMetrics): List<HuggingFaceModelDetails>
/**
* Search models on HuggingFace
*/
suspend fun searchHuggingFaceModels(limit: Int): Result<List<HuggingFaceModel>>
/**
* Obtain the model details from HuggingFace
*/
suspend fun getHuggingFaceModelDetails(modelId: String): HuggingFaceModelDetails
/**
* Obtain the model's size from HTTP response header
*/
suspend fun getHuggingFaceModelFileSize(downloadInfo: HuggingFaceDownloadInfo): Result<Long>
/**
* Download a HuggingFace model via system download manager
*/
suspend fun downloadHuggingFaceModel(
downloadInfo: HuggingFaceDownloadInfo,
actualSize: Long,
): Result<Long>
}
class InsufficientStorageException(message: String) : IOException(message)
@Singleton
class ModelRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val modelDao: ModelDao,
private val localFileDataSource: LocalFileDataSource,
private val huggingFaceRemoteDataSource: HuggingFaceRemoteDataSource,
private val ggufMetadataReader: GgufMetadataReader,
) : ModelRepository {
private val modelsDir = File(context.filesDir, INTERNAL_STORAGE_PATH)
init {
if (!modelsDir.exists()) { modelsDir.mkdirs() }
}
val modelsSizeBytes: Long
get() = modelsDir.listFiles()?.fold(0L) { totalSize, file ->
totalSize + if (file.isFile) file.length() else 0
} ?: 0L
val availableSpaceBytes: Long
get() = StatFs(context.filesDir.path).availableBytes
val totalSpaceBytes: Long
get() = StatFs(context.filesDir.path).totalBytes
override fun getStorageMetrics(): Flow<StorageMetrics> = flow {
while (true) {
emit(
StorageMetrics(
usedGB = modelsSizeBytes / BYTES_IN_GB,
availableGB = availableSpaceBytes / BYTES_IN_GB
)
)
delay(STORAGE_METRICS_UPDATE_INTERVAL)
}
}
override fun getModels(): Flow<List<ModelInfo>> =
modelDao.getAllModels()
.map { entities ->
entities.filter {
val file = File(it.path)
file.exists() && file.isFile
}.map {
it.toModelInfo()
}
}
override suspend fun getModelById(id: String) =
modelDao.getModelById(id)?.toModelInfo()
private var importJob: Job? = null
private var currentModelFile: File? = null
override suspend fun importModel(
uri: Uri,
name: String?,
size: Long?,
progressTracker: ImportProgressTracker?
): ModelInfo = withContext(Dispatchers.IO) {
if (importJob != null && importJob?.isActive == true) {
throw IllegalStateException("Another import is already in progress!")
}
// Check file info
val fileInfo = localFileDataSource.getFileInfo(uri)
val fileSize = size ?: fileInfo?.size ?: throw FileNotFoundException("File size N/A")
val fileName = name ?: fileInfo?.name ?: throw FileNotFoundException("File name N/A")
if (!ggufMetadataReader.ensureSourceFileFormat(context, uri)) {
throw InvalidFileFormatException()
}
// Check for enough storage
if (!hasEnoughSpaceForImport(fileSize)) {
throw InsufficientStorageException(
"Not enough storage space! " +
"Required: ${formatFileByteSize(fileSize)}, " +
"Available: ${formatFileByteSize(availableSpaceBytes)}"
)
}
val modelFile = File(modelsDir, fileName)
importJob = coroutineContext[Job]
currentModelFile = modelFile
try {
localFileDataSource.copyFile(
sourceUri = uri,
destinationFile = modelFile,
fileSize = fileSize,
onProgress = { progress ->
progressTracker?.let {
withContext(Dispatchers.Main) {
it.onProgress(progress)
}
}
}
).getOrThrow()
// Extract GGUF metadata if possible
val metadata = try {
Log.i(TAG, "Extracting GGUF Metadata from ${modelFile.absolutePath}")
modelFile.inputStream().buffered().use {
GgufMetadata.fromDomain(ggufMetadataReader.readStructuredMetadata(it))
}
} catch (e: Exception) {
Log.e(TAG, "Cannot extract GGUF metadata: ${e.message}", e)
throw e
}
// Create model entity and save via DAO
ModelEntity(
id = UUID.randomUUID().toString(),
name = fileName.substringBeforeLast('.'),
path = modelFile.absolutePath,
sizeInBytes = modelFile.length(),
metadata = metadata,
dateAdded = System.currentTimeMillis(),
dateLastUsed = null
).let {
modelDao.insertModel(it)
it.toModelInfo()
}
} catch (e: CancellationException) {
Log.i(TAG, "Import was cancelled for $fileName: ${e.message}")
localFileDataSource.cleanupPartialFile(modelFile)
throw e
} catch (e: Exception) {
Log.e(TAG, "Import failed for $fileName: ${e.message}")
localFileDataSource.cleanupPartialFile(modelFile)
throw e
} finally {
importJob = null
currentModelFile = null
}
}
// Add this method to ModelRepositoryImpl.kt
private fun hasEnoughSpaceForImport(fileSize: Long): Boolean {
val availableSpace = availableSpaceBytes
val requiredSpace = (fileSize * MODEL_IMPORT_SPACE_BUFFER_SCALE).toLong()
return availableSpace >= requiredSpace
}
override suspend fun cancelImport(): Boolean? = withContext(Dispatchers.IO) {
val job = importJob
val file = currentModelFile
return@withContext when {
// No import in progress
job == null -> null
// Job already completed or cancelled
!job.isActive -> {
importJob = null
currentModelFile = null
null
}
// Job in progress
else -> try {
// Attempt to cancel the job
job.cancel("Import cancelled by user")
// Give the job a moment to clean up
delay(CANCEL_LOCAL_MODEL_IMPORT_TIMEOUT)
// Clean up the partial file
file?.let { localFileDataSource.cleanupPartialFile(it) }
// Reset state
importJob = null
currentModelFile = null
true // Successfully cancelled
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel import: ${e.message}")
false
}
}
}
override suspend fun updateModelLastUsed(modelId: String) = withContext(Dispatchers.IO) {
modelDao.updateLastUsed(modelId, System.currentTimeMillis())
}
override suspend fun deleteModel(modelId: String) = withContext(Dispatchers.IO) {
modelDao.getModelById(modelId)?.let { model ->
File(model.path).let {
if (it.exists()) { it.delete() }
}
modelDao.deleteModel(model)
} ?: Unit
}
override suspend fun deleteModels(modelIds: List<String>) = withContext(Dispatchers.IO) {
modelDao.getModelsByIds(modelIds).let { models ->
models.forEach { model ->
File(model.path).let {
if (it.exists()) { it.delete() }
}
}
modelDao.deleteModels(models)
}
}
override suspend fun fetchPreselectedHuggingFaceModels(memoryUsage: MemoryMetrics) = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.fetchPreselectedModels(memoryUsage)
}
override suspend fun searchHuggingFaceModels(
limit: Int
) = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.searchModels(limit = limit)
}
override suspend fun getHuggingFaceModelDetails(
modelId: String
) = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.getModelDetails(modelId)
}
override suspend fun getHuggingFaceModelFileSize(
downloadInfo: HuggingFaceDownloadInfo,
): Result<Long> = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.getFileSize(downloadInfo.modelId, downloadInfo.filename)
}
override suspend fun downloadHuggingFaceModel(
downloadInfo: HuggingFaceDownloadInfo,
actualSize: Long,
): Result<Long> = withContext(Dispatchers.IO) {
if (!hasEnoughSpaceForImport(actualSize)) {
throw InsufficientStorageException(
"Not enough storage space! " +
"Estimated required: ${formatFileByteSize(actualSize)}, " +
"Available: ${formatFileByteSize(availableSpaceBytes)}"
)
}
try {
huggingFaceRemoteDataSource.downloadModelFile(
context = context,
downloadInfo = downloadInfo,
)
} catch (e: Exception) {
Log.e(TAG, "Import failed: ${e.message}")
Result.failure(e)
}
}
companion object {
private val TAG = ModelRepository::class.java.simpleName
private const val INTERNAL_STORAGE_PATH = "models"
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
private const val CANCEL_LOCAL_MODEL_IMPORT_TIMEOUT = 500L
}
}

View File

@ -1,117 +0,0 @@
package com.arm.aiplayground.data.repo
import com.arm.aiplayground.data.db.dao.SystemPromptDao
import com.arm.aiplayground.data.db.entity.SystemPromptEntity
import com.arm.aiplayground.data.model.SystemPrompt
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Repository for managing system prompts.
*/
interface SystemPromptRepository {
fun getPresetPrompts(): Flow<List<SystemPrompt>>
fun getRecentPrompts(): Flow<List<SystemPrompt>>
suspend fun savePromptToRecents(prompt: SystemPrompt)
suspend fun saveCustomPrompt(content: String): SystemPrompt
suspend fun deletePrompt(id: String)
suspend fun deleteAllPrompts()
}
@Singleton
class SystemPromptRepositoryImpl @Inject constructor(
private val systemPromptDao: SystemPromptDao,
) : SystemPromptRepository {
/**
* Get all preset prompts.
*
* For now, we'll just return the static list since we don't store presets in the database
*/
override fun getPresetPrompts(): Flow<List<SystemPrompt>> = flowOf(SystemPrompt.STUB_PRESETS)
/**
* Get recent prompts from the database.
*/
override fun getRecentPrompts(): Flow<List<SystemPrompt>> {
return systemPromptDao.getRecentPrompts(MAX_RECENT_PROMPTS)
.map { entities ->
entities.map { it.toDomainModel() }
}
}
/**
* Save a prompt to the recents list.
* If it's already in recents, just update the timestamp.
*/
override suspend fun savePromptToRecents(prompt: SystemPrompt) {
// Check if this prompt already exists
val existingPrompt = systemPromptDao.getPromptById(prompt.id)
if (existingPrompt != null) {
// Update the timestamp to mark it as recently used
systemPromptDao.updatePromptTimestamp(prompt.id, System.currentTimeMillis())
} else {
// Insert as a new prompt
systemPromptDao.insertPrompt(SystemPromptEntity.fromDomainModel(prompt))
// Check if we need to trim the list
pruneOldPrompts()
}
}
/**
* Create and save a custom prompt.
*/
override suspend fun saveCustomPrompt(content: String): SystemPrompt {
val customPrompt = SystemPrompt.Custom(
id = UUID.randomUUID().toString(),
content = content
)
systemPromptDao.insertPrompt(SystemPromptEntity.fromDomainModel(customPrompt))
// Check if we need to trim the list
pruneOldPrompts()
return customPrompt
}
/**
* Remove prompts if we exceed the maximum count.
*/
private suspend fun pruneOldPrompts() {
val count = systemPromptDao.getPromptCount()
if (count > MAX_RECENT_PROMPTS) {
// Get all prompts and delete the oldest ones
val allPrompts = systemPromptDao.getAllPrompts().first()
val promptsToDelete = allPrompts
.sortedByDescending { it.timestamp }
.drop(MAX_RECENT_PROMPTS)
promptsToDelete.forEach {
systemPromptDao.deletePrompt(it)
}
}
}
/**
* Delete a prompt by ID.
*/
override suspend fun deletePrompt(id: String) = systemPromptDao.deletePromptById(id)
/**
* Delete all prompts.
*/
override suspend fun deleteAllPrompts() = systemPromptDao.deleteAllPrompts()
companion object {
// Maximum number of recent prompts to keep
private const val MAX_RECENT_PROMPTS = 10
}
}

View File

@ -1,118 +0,0 @@
package com.arm.aiplayground.data.source.local
import android.content.Context
import android.net.Uri
import android.util.Log
import com.arm.aiplayground.data.source.local.LocalFileDataSource.FileInfo
import com.arm.aiplayground.util.copyWithBuffer
import com.arm.aiplayground.util.copyWithChannels
import com.arm.aiplayground.util.getFileNameFromUri
import com.arm.aiplayground.util.getFileSizeFromUri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
interface LocalFileDataSource {
/**
* Copy local file from [sourceUri] into [destinationFile]
*/
suspend fun copyFile(
sourceUri: Uri,
destinationFile: File,
fileSize: Long,
onProgress: (suspend (Float) -> Unit)? = null
): Result<File>
/**
* Obtain the file name and size from given [uri]
*/
suspend fun getFileInfo(uri: Uri): FileInfo?
/**
* Clean up incomplete file due to unfinished import
*/
suspend fun cleanupPartialFile(file: File): Boolean
data class FileInfo(val name: String, val size: Long)
}
@Singleton
class LocalFileDataSourceImpl @Inject constructor(
@ApplicationContext private val context: Context
) : LocalFileDataSource {
override suspend fun copyFile(
sourceUri: Uri,
destinationFile: File,
fileSize: Long,
onProgress: (suspend (Float) -> Unit)?
): Result<File> = withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(sourceUri)
?: return@withContext Result.failure(IOException("Failed to open input stream"))
val outputStream = FileOutputStream(destinationFile)
if (fileSize > LARGE_MODEL_THRESHOLD_SIZE) {
// Use NIO channels for large models
Log.i(TAG, "Copying ${destinationFile.name} (size: $fileSize) via NIO...")
copyWithChannels(
input = inputStream,
output = outputStream,
totalSize = fileSize,
bufferSize = NIO_BUFFER_SIZE,
yieldSize = NIO_YIELD_SIZE,
onProgress = onProgress
)
} else {
// Default copy with buffer for small models
Log.i(TAG, "Copying ${destinationFile.name} (size: $fileSize) via buffer...")
copyWithBuffer(
input = inputStream,
output = outputStream,
totalSize = fileSize,
bufferSize = DEFAULT_BUFFER_SIZE,
yieldSize = DEFAULT_YIELD_SIZE,
onProgress = onProgress
)
}
Result.success(destinationFile)
} catch (e: Exception) {
if (destinationFile.exists()) {
destinationFile.delete()
}
Result.failure(e)
}
}
override suspend fun getFileInfo(uri: Uri): FileInfo? {
val name = getFileNameFromUri(context, uri)
val size = getFileSizeFromUri(context, uri)
return if (name != null && size != null) {
FileInfo(name, size)
} else null
}
override suspend fun cleanupPartialFile(file: File): Boolean =
try {
if (file.exists()) file.delete() else false
} catch (e: Exception) {
Log.e(TAG, "Failed to delete file: ${e.message}")
false
}
companion object {
private val TAG = LocalFileDataSourceImpl::class.java.simpleName
private const val LARGE_MODEL_THRESHOLD_SIZE = 1024 * 1024 * 1024
private const val NIO_BUFFER_SIZE = 32 * 1024 * 1024
private const val NIO_YIELD_SIZE = 128 * 1024 * 1024
private const val DEFAULT_BUFFER_SIZE = 4 * 1024 * 1024
private const val DEFAULT_YIELD_SIZE = 16 * 1024 * 1024
}
}

View File

@ -1,85 +0,0 @@
package com.arm.aiplayground.data.source.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages internal preferences for the application.
*/
@Singleton
class AppPreferences @Inject constructor (
@ApplicationContext private val context: Context
) {
companion object {
private const val DATASTORE_APP = "app"
private val Context.appDataStore: DataStore<Preferences>
by preferencesDataStore(name = DATASTORE_APP)
// Preference keys
private val USER_HAS_IMPORTED_FIRST_MODEL = booleanPreferencesKey("user_has_imported_first_model")
private val USER_HAS_CHATTED_WITH_MODEL = booleanPreferencesKey("user_has_chatted_with_model")
private val USER_HAS_NAVIGATED_TO_MANAGEMENT = booleanPreferencesKey("user_has_navigated_to_management")
}
/**
* Gets whether the user has imported his first model
*/
fun userHasImportedFirstModel(): Flow<Boolean> =
context.appDataStore.data.map { preferences ->
preferences[USER_HAS_IMPORTED_FIRST_MODEL] == true
}
/**
* Sets whether the user has completed importing the first model.
*/
suspend fun setUserHasImportedFirstModel(done: Boolean) = withContext(Dispatchers.IO) {
context.appDataStore.edit { preferences ->
preferences[USER_HAS_IMPORTED_FIRST_MODEL] = done
}
}
/**
* Gets whether the user has chatted with a model
*/
fun userHasChattedWithModel(): Flow<Boolean> =
context.appDataStore.data.map { preferences ->
preferences[USER_HAS_CHATTED_WITH_MODEL] == true
}
/**
* Sets whether the user has completed chatting with a model.
*/
suspend fun setUserHasChattedWithModel(done: Boolean) = withContext(Dispatchers.IO) {
context.appDataStore.edit { preferences ->
preferences[USER_HAS_CHATTED_WITH_MODEL] = done
}
}
/**
* Gets whether the user has navigated to model management screen.
*/
fun userHasNavigatedToManagement(): Flow<Boolean> =
context.appDataStore.data.map { preferences ->
preferences[USER_HAS_NAVIGATED_TO_MANAGEMENT] == true
}
/**
* Sets whether the user has navigated to model management screen.
*/
suspend fun setUserHasNavigatedToManagement(done: Boolean) = withContext(Dispatchers.IO) {
context.appDataStore.edit { preferences ->
preferences[USER_HAS_NAVIGATED_TO_MANAGEMENT] = done
}
}
}

View File

@ -1,148 +0,0 @@
package com.arm.aiplayground.data.source.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages user preferences for the application.
*/
@Singleton
class UserPreferences @Inject constructor (
@ApplicationContext private val context: Context
) {
companion object {
private const val DATASTORE_SETTINGS = "settings"
private val Context.settingsDataStore: DataStore<Preferences>
by preferencesDataStore(name = DATASTORE_SETTINGS)
// Preferences keys
private val PERFORMANCE_MONITORING_ENABLED = booleanPreferencesKey("performance_monitoring_enabled")
private val USE_FAHRENHEIT_TEMPERATURE = booleanPreferencesKey("use_fahrenheit_temperature")
private val MONITORING_INTERVAL_MS = longPreferencesKey("monitoring_interval_ms")
private val COLOR_THEME_MODE = intPreferencesKey("color_theme_mode")
private val DARK_THEME_MODE = intPreferencesKey("dark_theme_mode")
// Constants
private const val DEFAULT_MONITORING_INTERVAL_MS = 5000L
}
/**
* Gets whether performance monitoring is enabled.
*/
fun isPerformanceMonitoringEnabled(): Flow<Boolean> =
context.settingsDataStore.data.map { preferences ->
preferences[PERFORMANCE_MONITORING_ENABLED] != false
}
/**
* Sets whether performance monitoring is enabled.
*/
suspend fun setPerformanceMonitoringEnabled(enabled: Boolean) = withContext(Dispatchers.IO) {
context.settingsDataStore.edit { preferences ->
preferences[PERFORMANCE_MONITORING_ENABLED] = enabled
}
}
/**
* Gets whether temperature should be displayed in Fahrenheit.
*/
fun usesFahrenheitTemperature(): Flow<Boolean> =
context.settingsDataStore.data.map { preferences ->
preferences[USE_FAHRENHEIT_TEMPERATURE] == true
}
/**
* Sets whether temperature should be displayed in Fahrenheit.
*/
suspend fun setUseFahrenheitTemperature(useFahrenheit: Boolean) = withContext(Dispatchers.IO) {
context.settingsDataStore.edit { preferences ->
preferences[USE_FAHRENHEIT_TEMPERATURE] = useFahrenheit
}
}
/**
* Gets the monitoring interval in milliseconds.
*
* TODO-han.yin: replace with Enum value instead of millisecond value
*/
fun getMonitoringInterval(): Flow<Long> =
context.settingsDataStore.data.map { preferences ->
preferences[MONITORING_INTERVAL_MS] ?: DEFAULT_MONITORING_INTERVAL_MS
}
/**
* Sets the monitoring interval in milliseconds.
*/
suspend fun setMonitoringInterval(intervalMs: Long) = withContext(Dispatchers.IO) {
context.settingsDataStore.edit { preferences ->
preferences[MONITORING_INTERVAL_MS] = intervalMs
}
}
/**
* Gets the current color theme mode.
*/
fun getColorThemeMode(): Flow<ColorThemeMode> =
context.settingsDataStore.data.map { preferences ->
ColorThemeMode.fromInt(preferences[COLOR_THEME_MODE]) ?: ColorThemeMode.ARM
}
/**
* Sets the color theme mode.
*/
suspend fun setColorThemeMode(mode: ColorThemeMode) = withContext(Dispatchers.IO) {
context.settingsDataStore.edit { preferences ->
preferences[COLOR_THEME_MODE] = mode.value
}
}
/**
* Gets the current dark theme mode.
*/
fun getDarkThemeMode(): Flow<DarkThemeMode> =
context.settingsDataStore.data.map { preferences ->
DarkThemeMode.fromInt(preferences[DARK_THEME_MODE]) ?: DarkThemeMode.AUTO
}
/**
* Sets the dark theme mode.
*/
suspend fun setDarkThemeMode(mode: DarkThemeMode) = withContext(Dispatchers.IO) {
context.settingsDataStore.edit { preferences ->
preferences[DARK_THEME_MODE] = mode.value
}
}
}
enum class ColorThemeMode(val value: Int, val label: String) {
ARM(0, "Arm®"),
MATERIAL(1, "Material Design");
companion object {
fun fromInt(value: Int?) = entries.find { it.value == value }
}
}
enum class DarkThemeMode(val value: Int, val label: String) {
AUTO(0, "Auto"),
LIGHT(1, "Light"),
DARK(2, "Dark");
companion object {
fun fromInt(value: Int?) = entries.find { it.value == value }
}
}

View File

@ -1,26 +0,0 @@
package com.arm.aiplayground.data.source.remote
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import java.lang.reflect.Type
class GatedTypeAdapter : JsonDeserializer<Boolean> {
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Boolean {
return when {
json.isJsonPrimitive -> {
val primitive = json.asJsonPrimitive
when {
primitive.isBoolean -> primitive.asBoolean
primitive.isString -> primitive.asString != "false"
else -> false
}
}
else -> false
}
}
}

View File

@ -1,39 +0,0 @@
package com.arm.aiplayground.data.source.remote
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.HEAD
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface HuggingFaceApiService {
@GET("api/models")
suspend fun getModels(
@Query("search") search: String? = null,
@Query("author") author: String? = null,
@Query("filter") filter: String? = null,
@Query("sort") sort: String? = null,
@Query("direction") direction: String? = null,
@Query("limit") limit: Int? = null,
@Query("full") full: Boolean? = null,
): List<HuggingFaceModel>
@GET("api/models/{modelId}")
suspend fun getModelDetails(@Path("modelId") modelId: String): HuggingFaceModelDetails
@HEAD("{modelId}/resolve/main/{filename}")
suspend fun getModelFileHeader(
@Path("modelId", encoded = true) modelId: String,
@Path("filename", encoded = true) filename: String
): Response<Void>
@Deprecated("Use DownloadManager instead!")
@GET("{modelId}/resolve/main/{filename}")
@Streaming
suspend fun downloadModelFile(
@Path("modelId") modelId: String,
@Path("filename") filename: String
): ResponseBody
}

View File

@ -1,60 +0,0 @@
package com.arm.aiplayground.data.source.remote
import android.net.Uri
import androidx.core.net.toUri
import com.arm.aiplayground.di.HUGGINGFACE_HOST
import java.util.Date
internal const val FILE_EXTENSION_GGUF = ".GGUF"
internal val QUANTIZATION_Q4_0 = arrayOf("Q4_0", "Q4-0")
data class HuggingFaceModel(
val _id: String,
val id: String,
val modelId: String,
val author: String,
val createdAt: Date,
val lastModified: Date,
val pipeline_tag: String,
val tags: List<String>,
val private: Boolean,
val gated: Boolean,
val likes: Int,
val downloads: Int,
val sha: String,
val siblings: List<Sibling>,
val library_name: String?,
) {
fun getGgufFilename(keywords: Array<String> = QUANTIZATION_Q4_0): String? =
siblings.map { it.rfilename }
.filter {
it.endsWith(FILE_EXTENSION_GGUF, ignoreCase = true) }
.firstOrNull { filename ->
keywords.any { filename.contains(it, ignoreCase = true) }
}
fun anyFilenameContains(keywords: Array<String>): Boolean =
siblings.map { it.rfilename }
.any { filename ->
keywords.any { filename.contains(it, ignoreCase = true) }
}
fun toDownloadInfo() = getGgufFilename()?.let {
HuggingFaceDownloadInfo(_id, modelId, it)
}
}
data class HuggingFaceDownloadInfo(
val _id: String,
val modelId: String,
val filename: String,
) {
val uri: Uri
get() = "$HUGGINGFACE_HOST${modelId}/resolve/main/$filename".toUri()
}

View File

@ -1,90 +0,0 @@
package com.arm.aiplayground.data.source.remote
import java.util.Date
import kotlin.String
data class HuggingFaceModelDetails(
val _id: String,
val id: String,
val modelId: String,
val author: String,
val createdAt: Date,
val lastModified: Date,
val pipeline_tag: String,
val tags: List<String>,
val private: Boolean,
val gated: Boolean,
val disabled: Boolean?,
val likes: Int,
val downloads: Int,
val usedStorage: Long?,
val sha: String,
val siblings: List<Sibling>,
val cardData: CardData?,
val widgetData: List<WidgetData>?,
val gguf: Gguf?,
val library_name: String?,
) {
fun getGgufFilename(keywords: Array<String> = QUANTIZATION_Q4_0): String? =
siblings.map { it.rfilename }
.filter {
it.endsWith(FILE_EXTENSION_GGUF, ignoreCase = true) }
.firstOrNull { filename ->
keywords.any { filename.contains(it, ignoreCase = true) }
}
fun toModel() = HuggingFaceModel(
_id = this._id,
id = this.id,
modelId = this.modelId,
author = this.author,
createdAt = this.createdAt,
lastModified = this.lastModified,
pipeline_tag = this.pipeline_tag,
tags = this.tags,
private = this.private,
gated = this.gated,
likes = this.likes,
downloads = this.downloads,
sha = this.sha,
siblings = this.siblings.map { Sibling(it.rfilename) },
library_name = this.library_name,
)
fun toDownloadInfo() = getGgufFilename()?.let {
HuggingFaceDownloadInfo(_id, modelId, it)
}
}
data class Sibling(
val rfilename: String,
)
data class Gguf(
val total: Long?,
val architecture: String?,
val context_length: Int?,
val chat_template: String?,
val bos_token: String?,
val eos_token: String?,
)
data class CardData(
val base_model: String?,
val language: List<String>?,
val license: String?,
val pipeline_tag: String?,
val tags: List<String>?,
)
data class WidgetData(
val text: String
)

View File

@ -1,53 +0,0 @@
package com.arm.aiplayground.data.source.remote
import android.content.Context
import com.arm.aiplayground.monitoring.MemoryMetrics
/*
* HuggingFace Search API
*/
private const val QUERY_Q4_0_GGUF = "gguf q4_0"
private const val FILTER_TEXT_GENERATION = "text-generation"
private const val SORT_BY_DOWNLOADS = "downloads"
private const val SEARCH_RESULT_LIMIT = 30
private val INVALID_KEYWORDS = arrayOf("-of-", "split", "70B", "30B", "27B", "14B", "13B", "12B")
interface HuggingFaceRemoteDataSource {
suspend fun fetchPreselectedModels(
memoryUsage: MemoryMetrics,
parallelCount: Int = 3,
quorum: Float = 0.5f,
): List<HuggingFaceModelDetails>
/**
* Query openly available Q4_0 GGUF models on HuggingFace
*/
suspend fun searchModels(
query: String? = QUERY_Q4_0_GGUF,
filter: String? = FILTER_TEXT_GENERATION,
sort: String? = SORT_BY_DOWNLOADS,
direction: String? = "-1",
limit: Int? = SEARCH_RESULT_LIMIT,
full: Boolean = true,
invalidKeywords: Array<String> = INVALID_KEYWORDS
): Result<List<HuggingFaceModel>>
suspend fun getModelDetails(modelId: String): HuggingFaceModelDetails
/**
* Obtain selected HuggingFace model's GGUF file size from HTTP header
*/
suspend fun getFileSize(modelId: String, filePath: String): Result<Long>
/**
* Download selected HuggingFace model's GGUF file via DownloadManager
*/
suspend fun downloadModelFile(
context: Context,
downloadInfo: HuggingFaceDownloadInfo,
): Result<Long>
}

View File

@ -1,272 +0,0 @@
package com.arm.aiplayground.data.source.remote
import android.app.DownloadManager
import android.content.Context
import android.os.Environment
import android.util.Log
import com.arm.aiplayground.monitoring.MemoryMetrics
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.contains
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.ceil
/*
* Preselected models: sized <2GB
*/
private val PRESELECTED_MODEL_IDS_SMALL = listOf(
"bartowski/Llama-3.2-1B-Instruct-GGUF",
"unsloth/gemma-3-1b-it-GGUF",
"bartowski/granite-3.0-2b-instruct-GGUF",
)
/*
* Preselected models: sized 2~3GB
*/
private val PRESELECTED_MODEL_IDS_MEDIUM = listOf(
"bartowski/Llama-3.2-3B-Instruct-GGUF",
"unsloth/gemma-3n-E2B-it-GGUF",
"Qwen/Qwen2.5-3B-Instruct-GGUF",
"gaianet/Phi-4-mini-instruct-GGUF",
"unsloth/gemma-3-4b-it-GGUF",
)
/*
* Preselected models: sized 4~6B
*/
private val PRESELECTED_MODEL_IDS_LARGE = listOf(
"unsloth/gemma-3n-E4B-it-GGUF",
"bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
)
@Singleton
class HuggingFaceRemoteDataSourceImpl @Inject constructor(
private val apiService: HuggingFaceApiService
) : HuggingFaceRemoteDataSource {
override suspend fun fetchPreselectedModels(
memoryUsage: MemoryMetrics,
parallelCount: Int,
quorum: Float,
): List<HuggingFaceModelDetails> = withContext(Dispatchers.IO) {
val ids: List<String> = when {
memoryUsage.availableGB >= 7f ->
PRESELECTED_MODEL_IDS_MEDIUM + PRESELECTED_MODEL_IDS_LARGE + PRESELECTED_MODEL_IDS_SMALL
memoryUsage.availableGB >= 5f ->
PRESELECTED_MODEL_IDS_SMALL + PRESELECTED_MODEL_IDS_MEDIUM + PRESELECTED_MODEL_IDS_LARGE
memoryUsage.availableGB >= 3f ->
PRESELECTED_MODEL_IDS_SMALL + PRESELECTED_MODEL_IDS_MEDIUM
else ->
PRESELECTED_MODEL_IDS_SMALL
}
val sem = Semaphore(parallelCount)
val results = supervisorScope {
ids.map { id ->
async {
sem.withPermit {
try {
Result.success(getModelDetails(id))
} catch (t: CancellationException) {
Result.failure(t)
}
}
}
}.awaitAll()
}
val successes = results.mapNotNull { it.getOrNull() }
val failures = results.mapNotNull { it.exceptionOrNull() }
val total = ids.size
val failed = failures.size
val ok = successes.size
val shouldThrow = failed >= ceil(total * quorum).toInt()
if (!shouldThrow) return@withContext successes.toList()
// 1. No Network
if (failures.count { it is UnknownHostException } >= ceil(failed * 0.5).toInt()) {
throw UnknownHostException()
}
// 2. Time out
if (failures.count { it is SocketTimeoutException } >= ceil(failed * 0.5).toInt()) {
throw SocketTimeoutException()
}
// 3. known error codes: 404/410/204
val http404ish = failures.count { (it as? HttpException)?.code() in listOf(404, 410, 204) }
if (ok == 0 && (failed > 0) && (http404ish >= ceil(failed * 0.5).toInt() || failed == total)) {
throw FileNotFoundException()
}
// 4. Unknown issues
val ioMajority = failures.count {
it is IOException && it !is UnknownHostException && it !is SocketTimeoutException
} >= ceil(failed * 0.5).toInt()
if (ioMajority) {
throw IOException(failures.first { it is IOException }.message)
}
successes
}
override suspend fun searchModels(
query: String?,
filter: String?,
sort: String?,
direction: String?,
limit: Int?,
full: Boolean,
invalidKeywords: Array<String>,
) = withContext(Dispatchers.IO) {
try {
apiService.getModels(
search = query,
filter = filter,
sort = sort,
direction = direction,
limit = limit,
full = full,
)
.filterNot { it.gated || it.private }
.filterNot {
it.getGgufFilename().let { filename ->
filename.isNullOrBlank() || invalidKeywords.any {
filename.contains(it, ignoreCase = true)
}
}
}.let {
if (it.isEmpty()) Result.failure(FileNotFoundException())
else Result.success(it)
}
} catch (e: Exception) {
Log.e(TAG, "Error searching for models on HuggingFace: ${e.message}")
Result.failure(e)
}
}
override suspend fun getModelDetails(
modelId: String
) = withContext(Dispatchers.IO) {
apiService.getModelDetails(modelId)
}
override suspend fun getFileSize(
modelId: String,
filePath: String
): Result<Long> = withContext(Dispatchers.IO) {
try {
apiService.getModelFileHeader(modelId, filePath).let { resp ->
if (resp.isSuccessful) {
resp.headers()[HTTP_HEADER_CONTENT_LENGTH]?.toLongOrNull()?.let {
Result.success(it)
} ?: Result.failure(IOException("Content-Length header missing"))
} else {
Result.failure(
when (resp.code()) {
401 -> SecurityException("Model requires authentication")
404 -> FileNotFoundException("Model file not found")
else -> IOException("Failed to get file info: HTTP ${resp.code()}")
}
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting file size for $modelId: ${e.message}")
Result.failure(e)
}
}
override suspend fun downloadModelFile(
context: Context,
downloadInfo: HuggingFaceDownloadInfo,
): Result<Long> = withContext(Dispatchers.IO) {
try {
val downloadManager =
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(downloadInfo.uri).apply {
setTitle(downloadInfo.filename)
setDescription("Downloading directly from HuggingFace")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
downloadInfo.filename
)
setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE
)
setAllowedOverMetered(true)
setAllowedOverRoaming(false)
}
Log.d(TAG, "Enqueuing download request for: ${downloadInfo.modelId}")
val downloadId = downloadManager.enqueue(request)
delay(DOWNLOAD_MANAGER_DOUBLE_CHECK_DELAY)
val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
if (cursor != null && cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
if (statusIndex >= 0) {
val status = cursor.getInt(statusIndex)
cursor.close()
when (status) {
DownloadManager.STATUS_FAILED -> {
// Get failure reason if available
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
val errorMessage = when (reason) {
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP error"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient storage"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP code"
DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download"
DownloadManager.ERROR_FILE_ERROR -> "File error"
else -> "Unknown error"
}
Result.failure(Exception(errorMessage))
}
else -> {
// Download is pending, paused, or running
Result.success(downloadId)
}
}
} else {
// Assume success if we can't check status
cursor.close()
Result.success(downloadId)
}
} else {
// Assume success if cursor is empty
cursor?.close()
Result.success(downloadId)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to enqueue download: ${e.message}")
Result.failure(e)
}
}
companion object {
private val TAG = HuggingFaceRemoteDataSourceImpl::class.java.simpleName
private const val HTTP_HEADER_CONTENT_LENGTH = "content-length"
private const val DOWNLOAD_MANAGER_DOUBLE_CHECK_DELAY = 500L
}
}

View File

@ -1,132 +0,0 @@
package com.arm.aiplayground.di
import android.content.Context
import com.arm.aichat.AiChat
import com.arm.aichat.InferenceEngine
import com.arm.aichat.TierDetection
import com.arm.aichat.gguf.GgufMetadataReader
import com.arm.aiplayground.data.db.AppDatabase
import com.arm.aiplayground.data.repo.ModelRepository
import com.arm.aiplayground.data.repo.ModelRepositoryImpl
import com.arm.aiplayground.data.repo.SystemPromptRepository
import com.arm.aiplayground.data.repo.SystemPromptRepositoryImpl
import com.arm.aiplayground.data.source.local.LocalFileDataSource
import com.arm.aiplayground.data.source.local.LocalFileDataSourceImpl
import com.arm.aiplayground.data.source.remote.GatedTypeAdapter
import com.arm.aiplayground.data.source.remote.HuggingFaceApiService
import com.arm.aiplayground.data.source.remote.HuggingFaceRemoteDataSource
import com.arm.aiplayground.data.source.remote.HuggingFaceRemoteDataSourceImpl
import com.arm.aiplayground.engine.BenchmarkService
import com.arm.aiplayground.engine.ConversationService
import com.arm.aiplayground.engine.InferenceService
import com.arm.aiplayground.engine.InferenceServiceImpl
import com.arm.aiplayground.engine.ModelLoadingService
import com.arm.aiplayground.engine.StubInferenceEngine
import com.arm.aiplayground.engine.StubTierDetection
import com.arm.aiplayground.monitoring.PerformanceMonitor
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton
const val HUGGINGFACE_HOST = "https://huggingface.co/"
const val HUGGINGFACE_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
@Module
@InstallIn(SingletonComponent::class)
internal abstract class AppModule {
@Binds
abstract fun bindInferenceService(impl: InferenceServiceImpl) : InferenceService
@Binds
abstract fun bindModelLoadingService(impl: InferenceServiceImpl) : ModelLoadingService
@Binds
abstract fun bindBenchmarkService(impl: InferenceServiceImpl) : BenchmarkService
@Binds
abstract fun bindConversationService(impl: InferenceServiceImpl) : ConversationService
@Binds
abstract fun bindModelsRepository(impl: ModelRepositoryImpl): ModelRepository
@Binds
abstract fun bindSystemPromptRepository(impl: SystemPromptRepositoryImpl): SystemPromptRepository
@Binds
abstract fun bindLocalFileDataSource(impl: LocalFileDataSourceImpl) : LocalFileDataSource
@Binds
abstract fun bindHuggingFaceRemoteDataSource(impl: HuggingFaceRemoteDataSourceImpl): HuggingFaceRemoteDataSource
companion object {
const val USE_STUB_ENGINE = false
@Provides
fun provideInferenceEngine(@ApplicationContext context: Context): InferenceEngine {
return if (USE_STUB_ENGINE) {
StubInferenceEngine()
} else {
AiChat.getInferenceEngine(context)
}
}
@Provides
fun provideTierDetection(@ApplicationContext context: Context): TierDetection {
return if (USE_STUB_ENGINE) {
StubTierDetection
} else {
AiChat.getTierDetection(context)
}
}
@Provides
fun providePerformanceMonitor(@ApplicationContext context: Context) = PerformanceMonitor(context)
@Provides
fun provideAppDatabase(@ApplicationContext context: Context) = AppDatabase.getDatabase(context)
@Provides
fun providesModelDao(appDatabase: AppDatabase) = appDatabase.modelDao()
@Provides
fun providesSystemPromptDao(appDatabase: AppDatabase) = appDatabase.systemPromptDao()
@Provides
@Singleton
fun providesGgufMetadataReader(): GgufMetadataReader = GgufMetadataReader.create()
@Provides
@Singleton
fun provideOkhttpClient() = OkHttpClient.Builder().build()
@Provides
@Singleton
fun provideGson(): Gson = GsonBuilder()
.setDateFormat(HUGGINGFACE_DATETIME_FORMAT)
.registerTypeAdapter(Boolean::class.java, GatedTypeAdapter())
.create()
@Provides
@Singleton
fun provideHuggingFaceApiService(
okHttpClient: OkHttpClient,
gson: Gson,
): HuggingFaceApiService =
Retrofit.Builder()
.baseUrl(HUGGINGFACE_HOST)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(HuggingFaceApiService::class.java)
}
}

View File

@ -1,290 +0,0 @@
package com.arm.aiplayground.engine
import android.util.Log
import com.arm.aichat.InferenceEngine
import com.arm.aichat.InferenceEngine.State
import com.arm.aiplayground.data.model.ModelInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
interface InferenceService {
/**
* Expose engine state
*/
val engineState: StateFlow<State>
/**
* Currently selected model
*/
val currentSelectedModel: StateFlow<ModelInfo?>
/**
* Set current model
*/
fun setCurrentModel(model: ModelInfo)
/**
* Unload current model and free resources
*/
suspend fun unloadModel()
}
interface ModelLoadingService : InferenceService {
/**
* Load a model for benchmark
*/
suspend fun loadModelForBenchmark(): ModelLoadingMetrics?
/**
* Load a model for conversation
*/
suspend fun loadModelForConversation(systemPrompt: String?): ModelLoadingMetrics?
}
interface BenchmarkService : InferenceService {
/**
* Run benchmark
*
* @param pp: Prompt Processing size
* @param tg: Token Generation size
* @param pl: Parallel sequences
* @param nr: Number of Runs, i.e. repetitions
*/
suspend fun benchmark(pp: Int, tg: Int, pl: Int, nr: Int): String
/**
* Benchmark results
*/
val benchmarkResults: StateFlow<String?>
}
interface ConversationService : InferenceService {
/**
* System prompt
*/
val systemPrompt: StateFlow<String?>
/**
* Generate response from prompt
*/
fun generateResponse(prompt: String): Flow<GenerationUpdate>
/**
* Create token metrics based on current state
*/
fun createTokenMetrics(): TokenMetrics
}
/**
* Metrics for model loading and system prompt processing
*/
data class ModelLoadingMetrics(
val modelLoadingTimeMs: Long,
val systemPromptProcessingTimeMs: Long? = null
) {
val totalTimeMs: Long
get() = modelLoadingTimeMs + (systemPromptProcessingTimeMs ?: 0)
}
/**
* Represents an update during text generation
*/
data class GenerationUpdate(
val text: String,
val isComplete: Boolean,
val metrics: TokenMetrics? = null
)
/**
* Metrics for token generation performance
*/
data class TokenMetrics(
val tokensCount: Int,
val ttftMs: Long,
val tpsMs: Float,
val duration: Long,
) {
val text: String
get() = "Tokens: $tokensCount, TTFT: ${ttftMs}ms, TPS: ${"%.1f".format(tpsMs)}"
}
/**
* Internal implementation of the above [InferenceService]s
*/
@Singleton
internal class InferenceServiceImpl @Inject internal constructor(
private val inferenceEngine: InferenceEngine
) : ModelLoadingService, BenchmarkService, ConversationService {
/*
*
* InferenceService implementation
*
*/
override val engineState: StateFlow<State> = inferenceEngine.state
private val _currentModel = MutableStateFlow<ModelInfo?>(null)
override val currentSelectedModel: StateFlow<ModelInfo?> = _currentModel.asStateFlow()
override fun setCurrentModel(model: ModelInfo) { _currentModel.value = model }
override suspend fun unloadModel() = inferenceEngine.cleanUp()
/**
* Shut down inference engine
*/
fun destroy() = inferenceEngine.destroy()
/*
*
* ModelLoadingService implementation
*
*/
override suspend fun loadModelForBenchmark(): ModelLoadingMetrics? {
checkNotNull(_currentModel.value) { "Attempt to load model for bench while none selected!" }
return _currentModel.value?.let { model ->
try {
val modelLoadStartTs = System.currentTimeMillis()
inferenceEngine.loadModel(model.path)
val modelLoadEndTs = System.currentTimeMillis()
ModelLoadingMetrics(modelLoadEndTs - modelLoadStartTs)
} catch (e: Exception) {
Log.e(TAG, "Error loading model", e)
null
}
}
}
override suspend fun loadModelForConversation(systemPrompt: String?): ModelLoadingMetrics? {
checkNotNull(_currentModel.value) { "Attempt to load model for chat while none selected!" }
return _currentModel.value?.let { model ->
try {
_systemPrompt.value = systemPrompt
val modelLoadStartTs = System.currentTimeMillis()
inferenceEngine.loadModel(model.path)
val modelLoadEndTs = System.currentTimeMillis()
if (systemPrompt.isNullOrBlank()) {
ModelLoadingMetrics(modelLoadEndTs - modelLoadStartTs)
} else {
val prompt: String = systemPrompt
val systemPromptStartTs = System.currentTimeMillis()
inferenceEngine.setSystemPrompt(prompt)
val systemPromptEndTs = System.currentTimeMillis()
ModelLoadingMetrics(
modelLoadingTimeMs = modelLoadEndTs - modelLoadStartTs,
systemPromptProcessingTimeMs = systemPromptEndTs - systemPromptStartTs
)
}
} catch (e: Exception) {
Log.e(TAG, e.message, e)
null
}
}
}
/*
*
* BenchmarkService implementation
*
*/
override suspend fun benchmark(pp: Int, tg: Int, pl: Int, nr: Int): String =
inferenceEngine.bench(pp, tg, pl, nr).also {
_benchmarkResults.value = it
}
/**
* Benchmark results if available
*/
private val _benchmarkResults = MutableStateFlow<String?>(null)
override val benchmarkResults: StateFlow<String?> = _benchmarkResults
/* ConversationService implementation */
private val _systemPrompt = MutableStateFlow<String?>(null)
override val systemPrompt: StateFlow<String?> = _systemPrompt.asStateFlow()
// Token metrics tracking
private var generationStartTime: Long = 0L
private var firstTokenTime: Long = 0L
private var tokenCount: Int = 0
private var isFirstToken: Boolean = true
override fun generateResponse(prompt: String): Flow<GenerationUpdate> = flow {
val response = StringBuilder()
try {
// Reset metrics tracking
generationStartTime = System.currentTimeMillis()
firstTokenTime = 0L
tokenCount = 0
isFirstToken = true
inferenceEngine.sendUserPrompt(prompt)
.collect { token ->
// Track first token time
if (isFirstToken && token.isNotBlank()) {
firstTokenTime = System.currentTimeMillis()
isFirstToken = false
}
// Count tokens
if (token.isNotBlank()) {
tokenCount++
}
response.append(token)
// Emit ongoing response (not completed)
emit(GenerationUpdate(response.toString(), false))
}
// Calculate final metrics after completion
val metrics = createTokenMetrics()
// Emit final response with completion flag
emit(GenerationUpdate(response.toString(), true, metrics))
} catch (e: Exception) {
// Emit error
val metrics = createTokenMetrics()
emit(GenerationUpdate(response.toString(), true, metrics))
throw e
}
}
override fun createTokenMetrics(): TokenMetrics {
val endTime = System.currentTimeMillis()
val totalTimeMs = endTime - generationStartTime
return TokenMetrics(
tokensCount = tokenCount,
ttftMs = if (firstTokenTime > 0) firstTokenTime - generationStartTime else 0L,
tpsMs = calculateTPS(tokenCount, totalTimeMs),
duration = totalTimeMs,
)
}
/**
* Calculate tokens per second
*/
private fun calculateTPS(tokens: Int, timeMs: Long): Float {
if (tokens <= 0 || timeMs <= 0) return 0f
return (tokens.toFloat() * 1000f) / timeMs
}
companion object {
private val TAG = InferenceServiceImpl::class.java.simpleName
}
}

View File

@ -1,235 +0,0 @@
package com.arm.aiplayground.engine
import android.util.Log
import com.arm.aichat.InferenceEngine
import com.arm.aichat.InferenceEngine.State
import com.arm.aiplayground.APP_NAME
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Singleton
/**
* A stub [InferenceEngine] for agile development & testing
*/
@Singleton
class StubInferenceEngine : InferenceEngine {
companion object {
private val TAG = StubInferenceEngine::class.java.simpleName
private const val STUB_LIBRARY_LOADING_TIME = 2_000L
private const val STUB_MODEL_LOADING_TIME = 3_000L
private const val STUB_MODEL_UNLOADING_TIME = 2_000L
private const val STUB_BENCHMARKING_TIME = 8_000L
private const val STUB_SYSTEM_PROMPT_PROCESSING_TIME = 4_000L
private const val STUB_USER_PROMPT_PROCESSING_TIME = 2_000L
private const val STUB_TOKEN_GENERATION_TIME = 200L
}
private val _state = MutableStateFlow<State>(State.Uninitialized)
override val state: StateFlow<State> = _state
private var _readyForSystemPrompt = false
private val llamaDispatcher = Dispatchers.IO.limitedParallelism(1)
private val llamaScope = CoroutineScope(llamaDispatcher + SupervisorJob())
init {
llamaScope.launch {
Log.i(TAG, "Loading and initializing native library!")
_state.value = State.Initializing
// Simulate library loading
delay(STUB_LIBRARY_LOADING_TIME)
Log.i(TAG, "Native library initialized!")
_state.value = State.Initialized
}
}
/**
* Loads a model from the given path.
*/
override suspend fun loadModel(pathToModel: String) =
withContext(llamaDispatcher) {
Log.i(TAG, "loadModel! state: ${_state.value.javaClass.simpleName}")
check(_state.value is State.Initialized) {
"Cannot load model at ${_state.value.javaClass.simpleName}"
}
try {
_readyForSystemPrompt = false
_state.value = State.LoadingModel
// Simulate model loading
delay(STUB_MODEL_LOADING_TIME)
_readyForSystemPrompt = true
_state.value = State.ModelReady
} catch (e: CancellationException) {
// If coroutine is cancelled, propagate cancellation
throw e
} catch (e: Exception) {
_state.value = State.Error(e)
}
}
/**
* Process the plain text system prompt
*/
override suspend fun setSystemPrompt(prompt: String) =
withContext(llamaDispatcher) {
check(_state.value is State.ModelReady) {
"Cannot load model at ${_state.value.javaClass.simpleName}"
}
check(_readyForSystemPrompt) {
"System prompt must be set ** RIGHT AFTER ** model loaded!"
}
try {
_state.value = State.ProcessingSystemPrompt
// Simulate processing system prompt
delay(STUB_SYSTEM_PROMPT_PROCESSING_TIME)
_state.value = State.ModelReady
} catch (e: CancellationException) {
// If coroutine is cancelled, propagate cancellation
throw e
} catch (e: Exception) {
_state.value = State.Error(e)
}
}
/**
* Sends a user prompt to the loaded model and returns a Flow of generated tokens.
*/
override fun sendUserPrompt(message: String, predictLength: Int): Flow<String> = flow {
require(message.isNotEmpty()) { "User prompt discarded due to being empty!" }
check(_state.value is State.ModelReady) {
"Cannot load model at ${_state.value.javaClass.simpleName}"
}
try {
Log.i(TAG, "sendUserPrompt! \n$message")
_state.value = State.ProcessingUserPrompt
// Simulate longer processing time
delay(STUB_USER_PROMPT_PROCESSING_TIME)
_state.value = State.Generating
// Simulate token generation
val response = "This is a simulated response from the LLM model. The actual implementation would generate tokens one by one based on the input: $message"
response.split(" ").forEach {
emit("$it ")
delay(STUB_TOKEN_GENERATION_TIME)
}
_state.value = State.ModelReady
} catch (e: CancellationException) {
// Handle cancellation gracefully
_state.value = State.ModelReady
throw e
} catch (e: Exception) {
_state.value = State.Error(e)
throw e
}
}.catch { e ->
// If it's not a cancellation, update state to error
if (e !is CancellationException) {
_state.value = State.Error(Exception(e))
}
throw e
}
/**
* Runs a benchmark with the specified parameters.
*/
override suspend fun bench(pp: Int, tg: Int, pl: Int, nr: Int): String =
withContext(llamaDispatcher) {
check(_state.value is State.ModelReady) {
"Cannot load model at ${_state.value.javaClass.simpleName}"
}
try {
Log.i(TAG, "bench! state: ${_state.value}")
_state.value = State.Benchmarking
// Simulate benchmark running
delay(STUB_BENCHMARKING_TIME)
// Generate fake benchmark results
val modelDesc = APP_NAME
val model_size = "7"
val model_n_params = "7"
val backend = "CPU"
// Random values for benchmarks
val pp_avg = (51.4 + Math.random() * 5.14).toFloat()
val pp_std = (5.14 + Math.random() * 0.514).toFloat()
val tg_avg = (11.4 + Math.random() * 1.14).toFloat()
val tg_std = (1.14 + Math.random() * 0.114).toFloat()
val result = StringBuilder()
result.append("| model | size | params | backend | test | t/s |\n")
result.append("| --- | --- | --- | --- | --- | --- |\n")
result.append("| $modelDesc | ${model_size}GiB | ${model_n_params}B | ")
result.append("$backend | pp $pp | $pp_avg ± $pp_std |\n")
result.append("| $modelDesc | ${model_size}GiB | ${model_n_params}B | ")
result.append("$backend | tg $tg | $tg_avg ± $tg_std |\n")
_state.value = State.ModelReady
result.toString()
} catch (e: CancellationException) {
// If coroutine is cancelled, propagate cancellation
Log.w(TAG, "Unexpected user cancellation while benchmarking!")
_state.value = State.ModelReady
throw e
} catch (e: Exception) {
_state.value = State.Error(e)
"Error: ${e.message}"
}
}
/**
* Unloads the currently loaded model.
*/
override suspend fun cleanUp() =
withContext(llamaDispatcher) {
when(val state = _state.value) {
is State.ModelReady, is State.Error -> {
Log.i(TAG, "unloadModel! state: ${_state.value.javaClass.simpleName}")
_state.value = State.UnloadingModel
// Simulate model unloading time
delay(STUB_MODEL_UNLOADING_TIME)
_state.value = State.Initialized
}
else -> throw IllegalStateException(
"Cannot load model at ${_state.value.javaClass.simpleName}"
)
}
}
/**
* Cleans up resources when the engine is no longer needed.
*/
override fun destroy() {
Log.i(TAG, "destroy! state: ${_state.value}")
_state.value = State.Uninitialized
}
}

View File

@ -1,18 +0,0 @@
package com.arm.aiplayground.engine
import com.arm.aichat.ArmCpuTier
import com.arm.aichat.TierDetection
import android.util.Log
/**
* A stub [TierDetection] for agile development & testing
*/
object StubTierDetection : TierDetection {
private val tag = StubTierDetection::class.java.simpleName
override fun getDetectedTier(): ArmCpuTier? = ArmCpuTier.T3
override fun clearCache() {
Log.d(tag, "Cache cleared")
}
}

View File

@ -1,134 +0,0 @@
package com.arm.aiplayground.monitoring
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import javax.inject.Singleton
import kotlin.math.roundToInt
/**
* Service that monitors device performance metrics such as memory usage,
* battery level, and temperature.
*/
@Singleton
class PerformanceMonitor(@ApplicationContext private val context: Context) {
/**
* Provides a flow of memory usage information that updates at the specified interval.
*/
fun monitorMemoryUsage(intervalMs: Long = MEMORY_POLLING_INTERVAL): Flow<MemoryMetrics> = flow {
while(true) {
emit(getMemoryInfo())
delay(intervalMs)
}
}
/**
* Provides a flow of battery information that updates at the specified interval.
*/
fun monitorBattery(intervalMs: Long = BATTERY_POLLING_INTERVAL): Flow<BatteryMetrics> = flow {
while(true) {
emit(getBatteryInfo())
delay(intervalMs)
}
}
/**
* Provides a flow of temperature information that updates at the specified interval.
*/
fun monitorTemperature(intervalMs: Long = TEMP_POLLING_INTERVAL): Flow<TemperatureMetrics> = flow {
while(true) {
emit(getTemperatureInfo())
delay(intervalMs)
}
}
/**
* Gets the current memory usage information.
*/
private suspend fun getMemoryInfo(): MemoryMetrics = withContext(Dispatchers.IO) {
val mi = ActivityManager.MemoryInfo()
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.getMemoryInfo(mi)
val availableMem = mi.availMem
val totalMem = mi.totalMem
val percentUsed = ((totalMem - availableMem) / totalMem.toFloat() * 100).roundToInt()
// Convert to more readable units (GB)
val availableGb = (availableMem / (1024.0 * 1024.0 * 1024.0)).toFloat().round(1)
val totalGb = (totalMem / (1024.0 * 1024.0 * 1024.0)).toFloat().round(1)
MemoryMetrics(
availableMem = availableMem,
totalMem = totalMem,
percentUsed = percentUsed,
availableGB = availableGb,
totalGB = totalGb
)
}
/**
* Gets the current battery information.
*/
private fun getBatteryInfo(): BatteryMetrics {
val intent = context.registerReceiver(null,
IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, 0) ?: 0
val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100
val batteryPct = level * 100 / scale
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
return BatteryMetrics(
level = batteryPct,
isCharging = isCharging
)
}
/**
* Gets the current temperature information.
*/
private fun getTemperatureInfo(): TemperatureMetrics {
val intent = context.registerReceiver(null,
IntentFilter(Intent.ACTION_BATTERY_CHANGED))
// Battery temperature is reported in tenths of a degree Celsius
val tempTenthsC = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
val tempC = tempTenthsC / 10.0f
val warningLevel = when {
tempC >= 40.0f -> TemperatureWarningLevel.HIGH
tempC >= 35.0f -> TemperatureWarningLevel.MEDIUM
else -> TemperatureWarningLevel.NORMAL
}
return TemperatureMetrics(
tempCelsiusValue = tempC,
warningLevel = warningLevel
)
}
private fun Float.round(decimals: Int): Float {
var multiplier = 1.0f
repeat(decimals) { multiplier *= 10 }
return (this * multiplier).roundToInt() / multiplier
}
companion object {
private const val MEMORY_POLLING_INTERVAL = 5000L
private const val BATTERY_POLLING_INTERVAL = 10000L
private const val TEMP_POLLING_INTERVAL = 10000L
}
}

View File

@ -1,51 +0,0 @@
package com.arm.aiplayground.monitoring
/**
* Data class containing battery information.
*/
data class BatteryMetrics(
val level: Int,
val isCharging: Boolean
)
/**
* Data class containing memory usage metrics.
*/
data class MemoryMetrics(
val availableMem: Long,
val totalMem: Long,
val percentUsed: Int,
val availableGB: Float,
val totalGB: Float
)
/**
* Data class containing temperature information.
*/
data class TemperatureMetrics(
private val tempCelsiusValue: Float,
val warningLevel: TemperatureWarningLevel
) {
val celsiusDisplay: String
get() = "${tempCelsiusValue.toInt()}°C"
val fahrenheitDisplay: String
get() = "${(tempCelsiusValue * 9/5 + 32).toInt()}°F"
fun getDisplay(useFahrenheit: Boolean) =
if (useFahrenheit) fahrenheitDisplay else celsiusDisplay
}
enum class TemperatureWarningLevel {
NORMAL,
MEDIUM,
HIGH
}
/**
* Data class containing storage usage metrics.
*/
data class StorageMetrics(
val usedGB: Float,
val availableGB: Float
)

View File

@ -1,63 +0,0 @@
package com.arm.aiplayground.navigation
import androidx.navigation.NavController
import com.arm.aiplayground.engine.ModelLoadingMetrics
/**
* Navigation destinations for the app
*/
object AppDestinations {
// Primary navigation destinations
const val MODELS_ROUTE = "models"
const val MODEL_LOADING_ROUTE = "model_loading"
const val CONVERSATION_ROUTE = "conversation"
const val CONVERSATION_ROUTE_WITH_PARAMS = "conversation/{modelLoadTimeMs}/{promptProcessTimeMs}"
const val CONVERSATION_ROUTE_PARAM_MODEL_LOAD_TIME = "modelLoadTimeMs"
const val CONVERSATION_ROUTE_PARAM_PROMPT_PROCESS_TIME = "promptProcessTimeMs"
const val BENCHMARK_ROUTE = "benchmark"
const val BENCHMARK_ROUTE_WITH_PARAMS = "benchmark/{modelLoadTimeMs}"
const val BENCHMARK_ROUTE_PARAM_MODEL_LOAD_TIME = "modelLoadTimeMs"
// Settings destinations
const val SETTINGS_GENERAL_ROUTE = "settings_general"
}
/**
* Navigation actions to be performed in the app
*/
class NavigationActions(private val navController: NavController) {
fun navigateToModelSelection() {
navController.navigate(AppDestinations.MODELS_ROUTE) {
// Clear back stack to start fresh
popUpTo(AppDestinations.MODELS_ROUTE) { inclusive = true }
}
}
fun navigateToModelLoading() {
navController.navigate(AppDestinations.MODEL_LOADING_ROUTE)
}
fun navigateToConversation(metrics: ModelLoadingMetrics) {
val route = AppDestinations.CONVERSATION_ROUTE
val modelLoadTimeMs = metrics.modelLoadingTimeMs
val promptTimeMs = metrics.systemPromptProcessingTimeMs ?: 0
navController.navigate("$route/$modelLoadTimeMs/$promptTimeMs")
}
fun navigateToBenchmark(metrics: ModelLoadingMetrics) {
val route = AppDestinations.BENCHMARK_ROUTE
val modelLoadTimeMs = metrics.modelLoadingTimeMs
navController.navigate("$route/$modelLoadTimeMs")
}
fun navigateToSettingsGeneral() {
navController.navigate(AppDestinations.SETTINGS_GENERAL_ROUTE)
}
fun navigateUp() {
navController.navigateUp()
}
}

View File

@ -1,566 +0,0 @@
package com.arm.aiplayground.ui
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.arm.aichat.InferenceEngine
import com.arm.aichat.isUninterruptible
import com.arm.aiplayground.engine.ModelLoadingMetrics
import com.arm.aiplayground.navigation.AppDestinations
import com.arm.aiplayground.navigation.AppDestinations.BENCHMARK_ROUTE_PARAM_MODEL_LOAD_TIME
import com.arm.aiplayground.navigation.AppDestinations.CONVERSATION_ROUTE_PARAM_MODEL_LOAD_TIME
import com.arm.aiplayground.navigation.AppDestinations.CONVERSATION_ROUTE_PARAM_PROMPT_PROCESS_TIME
import com.arm.aiplayground.navigation.NavigationActions
import com.arm.aiplayground.ui.scaffold.AnimatedNavHost
import com.arm.aiplayground.ui.scaffold.AppNavigationDrawer
import com.arm.aiplayground.ui.scaffold.AppScaffold
import com.arm.aiplayground.ui.scaffold.ScaffoldConfig
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import com.arm.aiplayground.ui.scaffold.bottombar.BottomBarConfig
import com.arm.aiplayground.ui.scaffold.topbar.NavigationIcon
import com.arm.aiplayground.ui.scaffold.topbar.TopBarConfig
import com.arm.aiplayground.ui.screens.BenchmarkScreen
import com.arm.aiplayground.ui.screens.ConversationScreen
import com.arm.aiplayground.ui.screens.ModelLoadingScreen
import com.arm.aiplayground.ui.screens.ModelsScreen
import com.arm.aiplayground.ui.screens.SettingsGeneralScreen
import com.arm.aiplayground.viewmodel.BenchmarkViewModel
import com.arm.aiplayground.viewmodel.ConversationViewModel
import com.arm.aiplayground.viewmodel.MainViewModel
import com.arm.aiplayground.viewmodel.ModelLoadingViewModel
import com.arm.aiplayground.viewmodel.ModelScreenUiMode
import com.arm.aiplayground.viewmodel.ModelsManagementViewModel
import com.arm.aiplayground.viewmodel.ModelsViewModel
import com.arm.aiplayground.viewmodel.SettingsViewModel
import kotlinx.coroutines.launch
@Composable
fun AppContent(
settingsViewModel: SettingsViewModel,
mainViewModel: MainViewModel = hiltViewModel(),
modelsViewModel: ModelsViewModel = hiltViewModel(),
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
conversationViewModel: ConversationViewModel = hiltViewModel(),
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// App core states
val engineState by mainViewModel.engineState.collectAsState()
val showModelImportTooltip by mainViewModel.showModelImportTooltip.collectAsState()
val showChatTooltip by mainViewModel.showChatTooltip.collectAsState()
val showManagementTooltip by mainViewModel.showModelManagementTooltip.collectAsState()
// Model state
val modelScreenUiMode by modelsViewModel.modelScreenUiMode.collectAsState()
// Metric states for scaffolds
val isMonitoringEnabled by settingsViewModel.isMonitoringEnabled.collectAsState()
val memoryUsage by settingsViewModel.memoryUsage.collectAsState()
val temperatureInfo by settingsViewModel.temperatureMetrics.collectAsState()
val useFahrenheit by settingsViewModel.useFahrenheitUnit.collectAsState()
val storageMetrics by settingsViewModel.storageMetrics.collectAsState()
// Navigation
val navController = rememberNavController()
val navigationActions = remember(navController) { NavigationActions(navController) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute by remember(navBackStackEntry) {
derivedStateOf { navBackStackEntry?.destination?.route ?: "" }
}
// Determine if drawer gestures should be enabled based on route
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val drawerGesturesEnabled by remember(currentRoute, drawerState.currentValue) {
derivedStateOf {
// Always allow gesture dismissal when drawer is open
if (drawerState.currentValue == DrawerValue.Open) true else false
}
}
val openDrawer: () -> Unit = { coroutineScope.launch { drawerState.open() } }
// 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
}
is ScaffoldEvent.ShareText -> {
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, event.text)
event.title?.let { putExtra(Intent.EXTRA_SUBJECT, it) }
type = event.mimeType
}
val shareChooser = Intent.createChooser(shareIntent, event.title ?: "Share via")
// Use the current activity for context
val context = (navController.context as? Activity)
?: throw IllegalStateException("Activity context required for sharing")
try {
context.startActivity(shareChooser)
} catch (_: ActivityNotFoundException) {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = "No app found to share content",
duration = SnackbarDuration.Short
)
}
} catch (e: Exception) {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = "Share failed due to ${e.message}",
duration = SnackbarDuration.Short
)
}
}
}
}
}
// Create scaffold's top & bottom bar configs based on current route
val scaffoldConfig = when {
// Model selection screen
currentRoute == AppDestinations.MODELS_ROUTE -> {
// Collect states for bottom bar
val allModels by modelsViewModel.allModels.collectAsState()
val filteredModels by modelsViewModel.filteredModels.collectAsState()
val sortOrder by modelsViewModel.sortOrder.collectAsState()
val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
val activeFilters by modelsViewModel.activeFilters.collectAsState()
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState()
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
val hasModelsInstalled = allModels?.isNotEmpty() == true
// Create file launcher for importing local models
val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } }
ScaffoldConfig(
topBarConfig =
when (modelScreenUiMode) {
ModelScreenUiMode.BROWSING ->
TopBarConfig.ModelsBrowsing(
title = "Installed models",
navigationIcon = NavigationIcon.Menu {
modelsViewModel.resetPreselection()
openDrawer()
},
showTooltip = showManagementTooltip && !showChatTooltip && hasModelsInstalled,
showManagingToggle = !showChatTooltip && hasModelsInstalled,
onToggleManaging = {
if (hasModelsInstalled) {
mainViewModel.waiveModelManagementTooltip()
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
}
},
)
ModelScreenUiMode.SEARCHING ->
TopBarConfig.None()
ModelScreenUiMode.MANAGING ->
TopBarConfig.ModelsManagement(
title = "Managing models",
navigationIcon = NavigationIcon.Back {
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
},
storageMetrics =
if (isMonitoringEnabled && !showModelImportTooltip)
storageMetrics else null,
)
ModelScreenUiMode.DELETING ->
TopBarConfig.ModelsDeleting(
title = "Deleting models",
navigationIcon = NavigationIcon.Quit {
modelsManagementViewModel.resetManagementState()
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
)
},
bottomBarConfig =
when (modelScreenUiMode) {
ModelScreenUiMode.BROWSING ->
BottomBarConfig.Models.Browsing(
isSearchingEnabled = hasModelsInstalled,
onToggleSearching = {
modelsViewModel.toggleMode(ModelScreenUiMode.SEARCHING)
},
sorting = BottomBarConfig.Models.Browsing.SortingConfig(
isEnabled = hasModelsInstalled,
currentOrder = sortOrder,
isMenuVisible = showSortMenu,
toggleMenu = modelsViewModel::toggleSortMenu,
selectOrder = {
modelsViewModel.setSortOrder(it)
modelsViewModel.toggleSortMenu(false)
}
),
filtering = BottomBarConfig.Models.Browsing.FilteringConfig(
isEnabled = hasModelsInstalled,
filters = activeFilters,
onToggleFilter = modelsViewModel::toggleFilter,
onClearFilters = modelsViewModel::clearFilters,
isMenuVisible = showFilterMenu,
toggleMenu = modelsViewModel::toggleFilterMenu
),
runAction = BottomBarConfig.Models.RunActionConfig(
showTooltip = showChatTooltip,
preselectedModelToRun = preselection,
onClickRun = {
if (modelsViewModel.selectModel(it)) {
modelsViewModel.resetPreselection()
navigationActions.navigateToModelLoading()
}
}
),
)
ModelScreenUiMode.SEARCHING ->
BottomBarConfig.Models.Searching(
textFieldState = modelsViewModel.searchFieldState,
onQuitSearching = {
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
},
onSearch = { /* No-op for now */ },
runAction = BottomBarConfig.Models.RunActionConfig(
showTooltip = false,
preselectedModelToRun = preselection,
onClickRun = {
if (modelsViewModel.selectModel(it)) {
modelsViewModel.resetPreselection()
navigationActions.navigateToModelLoading()
}
}
),
)
ModelScreenUiMode.MANAGING ->
BottomBarConfig.Models.Managing(
isDeletionEnabled = filteredModels?.isNotEmpty() == true,
onToggleDeleting = {
modelsViewModel.toggleMode(ModelScreenUiMode.DELETING)
},
sorting = BottomBarConfig.Models.Managing.SortingConfig(
isEnabled = hasModelsInstalled,
currentOrder = sortOrder,
isMenuVisible = showSortMenu,
toggleMenu = { modelsViewModel.toggleSortMenu(it) },
selectOrder = {
modelsViewModel.setSortOrder(it)
modelsViewModel.toggleSortMenu(false)
}
),
filtering = BottomBarConfig.Models.Managing.FilteringConfig(
isEnabled = hasModelsInstalled,
filters = activeFilters,
onToggleFilter = modelsViewModel::toggleFilter,
onClearFilters = modelsViewModel::clearFilters,
isMenuVisible = showFilterMenu,
toggleMenu = modelsViewModel::toggleFilterMenu
),
importing = BottomBarConfig.Models.Managing.ImportConfig(
showTooltip = showModelImportTooltip,
isMenuVisible = showImportModelMenu,
toggleMenu = { show ->
modelsManagementViewModel.toggleImportMenu(show)
},
importFromLocal = {
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
modelsManagementViewModel.toggleImportMenu(false)
},
importFromHuggingFace = {
modelsManagementViewModel.queryModelsFromHuggingFace(memoryUsage)
modelsManagementViewModel.toggleImportMenu(false)
}
),
)
ModelScreenUiMode.DELETING ->
BottomBarConfig.Models.Deleting(
onQuitDeleting = {
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
selectedModels = selectedModelsToDelete,
selectAllFilteredModels = {
filteredModels?.let {
modelsManagementViewModel.selectModelsToDelete(it)
}
},
clearAllSelectedModels = {
modelsManagementViewModel.clearSelectedModelsToDelete()
},
deleteSelected = {
selectedModelsToDelete.let {
if (it.isNotEmpty()) {
modelsManagementViewModel.batchDeletionClicked(it)
}
}
},
)
}
)
}
// Model loading screen
currentRoute == AppDestinations.MODEL_LOADING_ROUTE ->
ScaffoldConfig(
topBarConfig = TopBarConfig.Performance(
title = "Select a mode",
navigationIcon = NavigationIcon.Back {
modelLoadingViewModel.onBackPressed { navigationActions.navigateUp() }
},
memoryMetrics = if (isMonitoringEnabled) memoryUsage else null,
temperatureInfo = null
)
)
// Benchmark screen
currentRoute.startsWith(AppDestinations.BENCHMARK_ROUTE) -> {
val showShareFab by benchmarkViewModel.showShareFab.collectAsState()
val showModelCard by benchmarkViewModel.showModelCard.collectAsState()
ScaffoldConfig(
topBarConfig = TopBarConfig.Performance(
title = "Benchmark",
navigationIcon = NavigationIcon.Back {
benchmarkViewModel.onBackPressed { navigationActions.navigateUp() }
},
memoryMetrics = if (isMonitoringEnabled) memoryUsage else null,
temperatureInfo = if (isMonitoringEnabled) Pair(
temperatureInfo,
useFahrenheit
) else null
),
bottomBarConfig = BottomBarConfig.Benchmark(
engineIdle = !engineState.isUninterruptible,
showShareFab = showShareFab,
onShare = { benchmarkViewModel.shareResult(handleScaffoldEvent) },
onRerun = { benchmarkViewModel.rerunBenchmark(handleScaffoldEvent) },
onClear = { benchmarkViewModel.clearResults(handleScaffoldEvent) },
showModelCard = showModelCard,
onToggleModelCard = benchmarkViewModel::toggleModelCard,
)
)
}
// Conversation screen
currentRoute.startsWith(AppDestinations.CONVERSATION_ROUTE) -> {
val showModelCard by conversationViewModel.showModelCard.collectAsState()
val modelThinkingOrSpeaking =
engineState is InferenceEngine.State.ProcessingUserPrompt || engineState is InferenceEngine.State.Generating
ScaffoldConfig(
topBarConfig = TopBarConfig.Performance(
title = "Chat",
navigationIcon = NavigationIcon.Back {
conversationViewModel.onBackPressed { navigationActions.navigateUp() }
},
memoryMetrics = if (isMonitoringEnabled) memoryUsage else null,
temperatureInfo = if (isMonitoringEnabled) Pair(
temperatureInfo,
useFahrenheit
) else null,
),
bottomBarConfig = BottomBarConfig.Conversation(
isEnabled = !modelThinkingOrSpeaking,
textFieldState = conversationViewModel.inputFieldState,
onSendClick = conversationViewModel::sendMessage,
showModelCard = showModelCard,
onToggleModelCard = conversationViewModel::toggleModelCard,
)
)
}
// Settings screen
currentRoute == AppDestinations.SETTINGS_GENERAL_ROUTE ->
ScaffoldConfig(
topBarConfig = TopBarConfig.Default(
title = "Settings",
navigationIcon = NavigationIcon.Back { navigationActions.navigateUp() }
)
)
// Fallback for empty screen or unknown routes
else -> ScaffoldConfig(
topBarConfig = TopBarConfig.Default(title = "", navigationIcon = NavigationIcon.None)
)
}
// Main UI hierarchy
AppNavigationDrawer(
drawerState = drawerState,
navigationActions = navigationActions,
gesturesEnabled = drawerGesturesEnabled,
currentRoute = currentRoute
) {
// The AppScaffold now uses the config we created
AppScaffold(
topBarconfig = scaffoldConfig.topBarConfig,
bottomBarConfig = scaffoldConfig.bottomBarConfig,
onScaffoldEvent = handleScaffoldEvent,
snackbarHostState = snackbarHostState,
) { paddingValues ->
// AnimatedNavHost inside the scaffold content
AnimatedNavHost(
navController = navController,
startDestination = AppDestinations.MODELS_ROUTE,
modifier = Modifier.Companion.padding(paddingValues)
) {
// Model Selection Screen
composable(AppDestinations.MODELS_ROUTE) {
ModelsScreen(
showModelImportTooltip = showModelImportTooltip,
onFirstModelImportSuccess = { model ->
if (showModelImportTooltip) {
mainViewModel.waiveModelImportTooltip()
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
modelsViewModel.preselectModel(model, true)
}
},
showChatTooltip = showChatTooltip,
onConfirmSelection = { modelInfo, ramWarning ->
if (modelsViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
navigationActions.navigateToModelLoading()
}
},
onScaffoldEvent = handleScaffoldEvent,
modelsViewModel = modelsViewModel,
managementViewModel = modelsManagementViewModel,
)
}
// Mode Selection Screen
composable(AppDestinations.MODEL_LOADING_ROUTE) {
ModelLoadingScreen(
onScaffoldEvent = handleScaffoldEvent,
onNavigateBack = { navigationActions.navigateUp() },
onNavigateToBenchmark = { navigationActions.navigateToBenchmark(it) },
onNavigateToConversation = {
navigationActions.navigateToConversation(it)
if (showChatTooltip) {
mainViewModel.waiveChatTooltip()
}
},
viewModel = modelLoadingViewModel
)
}
// Benchmark Screen
composable(
route = AppDestinations.BENCHMARK_ROUTE_WITH_PARAMS,
arguments = listOf(
navArgument(BENCHMARK_ROUTE_PARAM_MODEL_LOAD_TIME) {
type = NavType.Companion.LongType
defaultValue = 0L
}
)
) { backStackEntry ->
val modelLoadTimeMs = backStackEntry.arguments?.getLong(BENCHMARK_ROUTE_PARAM_MODEL_LOAD_TIME) ?: 0L
val metrics = if (modelLoadTimeMs > 0) {
ModelLoadingMetrics(modelLoadTimeMs)
} else throw IllegalArgumentException("Expecting a valid ModelLoadingMetrics!")
BenchmarkScreen(
loadingMetrics = metrics,
onScaffoldEvent = handleScaffoldEvent,
onNavigateBack = { navigationActions.navigateUp() },
viewModel = benchmarkViewModel
)
}
// Conversation Screen
composable(
route = AppDestinations.CONVERSATION_ROUTE_WITH_PARAMS,
arguments = listOf(
navArgument(CONVERSATION_ROUTE_PARAM_MODEL_LOAD_TIME) {
type = NavType.Companion.LongType
defaultValue = 0L
},
navArgument(CONVERSATION_ROUTE_PARAM_PROMPT_PROCESS_TIME) {
type = NavType.Companion.LongType
defaultValue = 0L
}
)
) { backStackEntry ->
val modelLoadTimeMs = backStackEntry.arguments?.getLong(
CONVERSATION_ROUTE_PARAM_MODEL_LOAD_TIME) ?: 0L
val promptProcessTimeMs = backStackEntry.arguments?.getLong(
CONVERSATION_ROUTE_PARAM_PROMPT_PROCESS_TIME) ?: 0L
val metrics = if (modelLoadTimeMs > 0) {
ModelLoadingMetrics(
modelLoadingTimeMs = modelLoadTimeMs,
systemPromptProcessingTimeMs = if (promptProcessTimeMs > 0) promptProcessTimeMs else null
)
} else throw IllegalArgumentException("Expecting a valid ModelLoadingMetrics!")
ConversationScreen(
loadingMetrics = metrics,
onNavigateBack = { navigationActions.navigateUp() },
viewModel = conversationViewModel
)
}
// Settings Screen
composable(AppDestinations.SETTINGS_GENERAL_ROUTE) {
SettingsGeneralScreen(
viewModel = settingsViewModel
)
}
}
}
}
}

View File

@ -1,65 +0,0 @@
package com.arm.aiplayground.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import com.arm.aichat.ArmFeature
import com.arm.aichat.ArmFeaturesMapper.DisplayItem
import kotlin.math.sqrt
/**
* ARM Features visualization using segmented buttons.
*/
@Composable
fun ArmFeaturesVisualizer(
supportedFeatures: List<DisplayItem>,
autoSizeMinScaling: Float = 0.5f,
onFeatureClick: ((ArmFeature) -> Unit)? = null
) {
// Segmented Button Row for Features
MultiChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
supportedFeatures.forEachIndexed { index, item ->
val weight = sqrt(item.feature.displayName.length.toFloat())
SegmentedButton(
modifier = Modifier.weight(weight),
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = supportedFeatures.size
),
icon = {},
onCheckedChange = { onFeatureClick?.invoke(item.feature) },
checked = item.isSupported,
) {
Text(
text = item.feature.displayName,
autoSize = TextAutoSize.StepBased(
minFontSize = MaterialTheme.typography.labelSmall.fontSize * autoSizeMinScaling,
maxFontSize = MaterialTheme.typography.labelSmall.fontSize
),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
fontWeight = if (item.isSupported) {
FontWeight.Medium
} else {
FontWeight.Light
},
color = if (item.isSupported) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
}
)
}
}
}
}

View File

@ -1,179 +0,0 @@
package com.arm.aiplayground.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
data class InfoAction(
val icon: ImageVector,
val label: String,
val onAction: () -> Unit
)
@Composable
fun InfoAlertDialog(
modifier: Modifier = Modifier,
isCritical: Boolean = false,
allowDismiss: Boolean = false,
onDismiss: () -> Unit = {},
icon: ImageVector,
title: String,
message: String? = null,
action: InfoAction? = null,
confirmButton: @Composable () -> Unit = {},
) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = allowDismiss,
dismissOnClickOutside = allowDismiss,
),
icon = {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
)
},
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
message?.let {
Text(
modifier = Modifier.padding(top = 8.dp),
text = it,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
action?.let {
Button(
modifier = Modifier.padding(top = 24.dp),
onClick = it.onAction,
) {
Icon(
imageVector = it.icon,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(it.label)
}
}
}
},
containerColor = if (isCritical) MaterialTheme.colorScheme.errorContainer else AlertDialogDefaults.containerColor,
iconContentColor = if (isCritical) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
titleContentColor = if (isCritical) MaterialTheme.colorScheme.onErrorContainer else AlertDialogDefaults.titleContentColor,
textContentColor = if (isCritical) MaterialTheme.colorScheme.onErrorContainer else AlertDialogDefaults.textContentColor,
confirmButton = confirmButton,
)
}
@Composable
fun InfoView(
modifier: Modifier = Modifier,
icon: ImageVector,
title: String,
message: String? = null,
action: InfoAction? = null
) {
InfoView(
modifier = modifier,
title = title,
icon = {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
},
message = message,
action = action
)
}
@Composable
fun InfoView(
modifier: Modifier = Modifier,
icon: @Composable () -> Unit,
title: String,
message: String? = null,
action: InfoAction? = null
) {
Column(
modifier = modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
icon()
Text(
modifier = Modifier.padding(top = 16.dp),
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
message?.let {
Text(
modifier = Modifier.padding(top = 8.dp),
text = it,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
action?.let {
Button(
modifier = Modifier.padding(top = 24.dp),
onClick = it.onAction,
) {
Icon(
imageVector = it.icon,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(it.label)
}
}
}
}

View File

@ -1,395 +0,0 @@
package com.arm.aiplayground.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.util.languageCodeToFlagEmoji
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Displays model information in a card format expandable with core details
* such as context length, architecture, quantization and file size
*
* @param model The model information to display
* @param isExpanded Whether additional details is expanded or shrunk
* @param onExpanded Action to perform when the card is expanded or shrunk
*/
@Composable
fun ModelCardCoreExpandable(
model: ModelInfo,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
isExpanded: Boolean = false,
onExpanded: ((Boolean) -> Unit)? = null
) = ModelCardCoreExpandable(
model = model,
containerColor = containerColor,
isExpanded = isExpanded,
onExpanded = onExpanded
) {
Spacer(modifier = Modifier.height(8.dp))
// Row 2: Context length, size label
ModelCardContentContextRow(model)
Spacer(modifier = Modifier.height(8.dp))
// Row 3: Architecture, quantization, formatted size
ModelCardContentArchitectureRow(model)
}
/**
* Displays model information in a card format expandable with customizable extra content.
*
* @param model The model information to display
* @param isExpanded Whether additional details is expanded or shrunk
* @param onExpanded Action to perform when the card is expanded or shrunk
*/
@Composable
fun ModelCardCoreExpandable(
model: ModelInfo,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
isExpanded: Boolean = false,
onExpanded: ((Boolean) -> Unit)? = null,
expandableSection: @Composable () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onExpanded?.invoke(!isExpanded) },
colors = when (isExpanded) {
true -> CardDefaults.cardColors(containerColor = containerColor)
false -> CardDefaults.cardColors()
},
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Row 1: Model full name + chevron
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
ModelCardContentTitleRow(model)
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides Dp.Unspecified
) {
IconButton(onClick = { onExpanded?.invoke(!isExpanded) }) {
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = "Tap to ${if (isExpanded) "shrink" else "expand"} model card"
)
}
}
}
// Expandable content
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier.weight(1f)
) {
expandableSection()
}
}
}
}
}
/**
* Displays model information in a card format with expandable details.
*
* This component shows essential model information and can be expanded to show
* additional details such as dates, tags, and languages.
* The expanded state is toggled by clicking on the content area of the card.
*
* @param model The model information to display
* @param isSelected Optional selection state (shows checkbox when not null)
* @param onSelected Action to perform when the card is selected (in multi-selection mode)
* @param isExpanded Whether additional details is expanded or shrunk
* @param onExpanded Action to perform when the card is expanded or shrunk
*/
@Composable
fun ModelCardFullExpandable(
model: ModelInfo,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
isSelected: Boolean? = null,
onSelected: ((Boolean) -> Unit)? = null,
isExpanded: Boolean = false,
onExpanded: ((Boolean) -> Unit)? = null,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onExpanded?.invoke(!isExpanded) },
colors = when (isSelected) {
true -> CardDefaults.cardColors(containerColor = containerColor)
false -> CardDefaults.cardColors()
else -> CardDefaults.cardColors()
},
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(bottom = 16.dp)
) {
Row(
verticalAlignment = Alignment.Top
) {
// Show checkbox if in selection mode
isSelected?.let { selected ->
Checkbox(
checked = selected,
onCheckedChange = { onSelected?.invoke(it) },
modifier = Modifier.padding(top = 16.dp, start = 16.dp)
)
}
Box(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp, top = 16.dp, end = 16.dp)
) {
// Row 1: Model full name
ModelCardContentTitleRow(model)
}
}
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
// Row 2: Context length, size label
ModelCardContentContextRow(model)
Spacer(modifier = Modifier.height(8.dp))
// Row 3: Architecture, quantization, formatted size
ModelCardContentArchitectureRow(model)
}
// Expandable content
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
// Divider between core and expanded content
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
// Row 4: Dates
ModelCardContentDatesRow(model)
// Row 5: Tags
model.tags?.let { tags ->
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Tags",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(6.dp))
ModelCardContentTagsSection(tags)
}
// Row 6: Languages
model.languages?.let { languages ->
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Supported languages",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(6.dp))
ModelCardContentLanguagesSections(languages)
}
}
}
}
}
}
}
}
/*
*
* Individual components to be orchestrated into a model card's content
*
*/
@Composable
fun ModelCardContentTitleRow(model: ModelInfo) =
Text(
text = model.formattedFullName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium
)
@Composable
fun ModelCardContentContextRow(model: ModelInfo) =
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
ModelCardContentField("Context", model.formattedContextLength)
ModelCardContentField("Params", model.formattedParamSize)
}
@Composable
fun ModelCardContentArchitectureRow(model: ModelInfo) =
Row(
modifier = Modifier.fillMaxWidth(),
) {
ModelCardContentField("Architecture", model.formattedArchitecture)
Spacer(modifier = Modifier.weight(1f))
ModelCardContentField(model.formattedQuantization, model.formattedFileSize)
}
@Composable
fun ModelCardContentDatesRow(model: ModelInfo) {
val dateFormatter = remember { SimpleDateFormat("MMM d", Locale.getDefault()) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Added date
ModelCardContentField("Added", dateFormatter.format(Date(model.dateAdded)))
// Last used date (if available)
model.dateLastUsed?.let { lastUsed ->
ModelCardContentField("Last used", dateFormatter.format(Date(lastUsed)))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ModelCardContentTagsSection(tags: List<String>) =
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
tags.forEach { tag ->
AssistChip(
enabled = false,
onClick = { /* No action */ },
label = {
Text(
text = tag,
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.onSurface
)
}
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ModelCardContentLanguagesSections(languages: List<String>) =
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
languages.mapNotNull { languageCodeToFlagEmoji(it) }.forEach { emoji ->
AssistChip(
enabled = false,
onClick = { /* No action */ },
label = {
Text(
text = emoji,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.onSurface
)
}
)
}
}
@Composable
fun ModelCardContentField(name: String, value: String) =
Row {
Text(
text = name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface
)
}

View File

@ -1,168 +0,0 @@
package com.arm.aiplayground.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.viewmodel.UnloadModelState
/**
* Reusable component for handling model unloading dialogs
*
* @param [UnloadModelState]:
* - Hidden: default state without showing any UI
* - Confirming: show dismissible [UnloadModelDialog] and asks for user confirmation to unload current model
* - Unloading: show non-dismissible [UnloadModelDialog] while unloading model
* - Error: show [UnloadModelErrorDialog] to prompt error message to user
*/
@Composable
fun ModelUnloadDialogHandler(
message: String,
unloadModelState: UnloadModelState,
onUnloadConfirmed: (onNavigateBack: () -> Unit) -> Unit,
onUnloadDismissed: () -> Unit,
onNavigateBack: () -> Unit
) {
when (unloadModelState) {
is UnloadModelState.Confirming -> {
UnloadModelDialog(
message = message,
onConfirm = {
onUnloadConfirmed(onNavigateBack)
},
onDismiss = onUnloadDismissed,
isUnloading = false,
)
}
is UnloadModelState.Unloading -> {
UnloadModelDialog(
message = message,
onConfirm = {
onUnloadConfirmed(onNavigateBack)
},
onDismiss = onUnloadDismissed,
isUnloading = true
)
}
is UnloadModelState.Error -> {
UnloadModelErrorDialog(
errorMessage = unloadModelState.message,
onConfirm = {
onUnloadDismissed()
onNavigateBack()
},
onDismiss = onUnloadDismissed
)
}
is UnloadModelState.Hidden -> {
// Dialog not shown
}
}
}
@Composable
private fun UnloadModelDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
isUnloading: Boolean = false
) {
AlertDialog(
onDismissRequest = {
// Ignore dismiss requests while unloading the model
if (!isUnloading) onDismiss()
},
title = {
Text("Confirm Exit")
},
text = {
Column {
Text(message)
if (isUnloading) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Unloading model...")
}
}
}
},
confirmButton = {
TextButton(
onClick = onConfirm,
enabled = !isUnloading
) {
Text("Yes, Exit")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isUnloading
) {
Text("Cancel")
}
}
)
}
@Composable
private fun UnloadModelErrorDialog(
errorMessage: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Error Unloading Model",
color = MaterialTheme.colorScheme.error
)
},
text = {
Column {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "You may need to restart the app if this problem persists.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text("Continue") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Stay on Screen") }
}
)
}

View File

@ -1,80 +0,0 @@
package com.arm.aiplayground.ui.scaffold
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun AnimatedNavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) {
val currentNavBackStackEntry by navController.currentBackStackEntryAsState()
var previousNavBackStackEntry by remember { mutableStateOf<NavBackStackEntry?>(null) }
LaunchedEffect(currentNavBackStackEntry) {
previousNavBackStackEntry = currentNavBackStackEntry
}
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
contentAlignment = contentAlignment,
route = route,
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(
durationMillis = 200,
easing = LinearOutSlowInEasing
)
)
},
exitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(
durationMillis = 200,
easing = LinearOutSlowInEasing
)
)
},
popEnterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(
durationMillis = 200,
easing = LinearOutSlowInEasing
)
)
},
popExitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(
durationMillis = 200,
easing = LinearOutSlowInEasing
)
)
},
builder = builder
)
}

View File

@ -1,186 +0,0 @@
package com.arm.aiplayground.ui.scaffold
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.MaterialTheme
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
import com.arm.aiplayground.ui.scaffold.bottombar.BenchmarkBottomBar
import com.arm.aiplayground.ui.scaffold.bottombar.BottomBarConfig
import com.arm.aiplayground.ui.scaffold.bottombar.ConversationBottomBar
import com.arm.aiplayground.ui.scaffold.bottombar.ModelsBrowsingBottomBar
import com.arm.aiplayground.ui.scaffold.bottombar.ModelsDeletingBottomBar
import com.arm.aiplayground.ui.scaffold.bottombar.ModelsManagementBottomBar
import com.arm.aiplayground.ui.scaffold.bottombar.ModelsSearchingBottomBar
import com.arm.aiplayground.ui.scaffold.topbar.DefaultTopBar
import com.arm.aiplayground.ui.scaffold.topbar.ModelsBrowsingTopBar
import com.arm.aiplayground.ui.scaffold.topbar.NavigationIcon
import com.arm.aiplayground.ui.scaffold.topbar.PerformanceTopBar
import com.arm.aiplayground.ui.scaffold.topbar.ModelsManagementTopBar
import com.arm.aiplayground.ui.scaffold.topbar.TopBarConfig
/**
* Configuration of both [TopBarConfig] and [BottomBarConfig]
*/
data class ScaffoldConfig(
val topBarConfig: TopBarConfig,
val bottomBarConfig: BottomBarConfig = BottomBarConfig.None
)
/**
* 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()
data class ShareText(
val text: String,
val title: String? = null,
val mimeType: String = "text/plain"
) : ScaffoldEvent()
}
@Composable
fun AppScaffold(
topBarconfig: TopBarConfig,
bottomBarConfig: BottomBarConfig = BottomBarConfig.None,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (PaddingValues) -> Unit
) {
val topBar: @Composable () -> Unit = {
when (topBarconfig) {
is TopBarConfig.None -> {}
is TopBarConfig.Default -> DefaultTopBar(
title = topBarconfig.title,
onNavigateBack = topBarconfig.navigationIcon.backAction,
onMenuOpen = topBarconfig.navigationIcon.menuAction,
)
is TopBarConfig.ModelsBrowsing -> ModelsBrowsingTopBar(
title = topBarconfig.title,
showTooltip = topBarconfig.showTooltip,
showManagingToggle = topBarconfig.showManagingToggle,
onToggleManaging = topBarconfig.onToggleManaging,
onNavigateBack = topBarconfig.navigationIcon.backAction,
onMenuOpen = topBarconfig.navigationIcon.menuAction
)
is TopBarConfig.ModelsDeleting -> DefaultTopBar(
title = topBarconfig.title,
titleColor = MaterialTheme.colorScheme.error,
navigationIconTint = MaterialTheme.colorScheme.error,
onQuit = topBarconfig.navigationIcon.quitAction
)
is TopBarConfig.ModelsManagement -> ModelsManagementTopBar(
title = topBarconfig.title,
storageMetrics = topBarconfig.storageMetrics,
onScaffoldEvent = onScaffoldEvent,
onNavigateBack = topBarconfig.navigationIcon.backAction,
)
is TopBarConfig.Performance -> PerformanceTopBar(
title = topBarconfig.title,
memoryMetrics = topBarconfig.memoryMetrics,
temperatureDisplay = topBarconfig.temperatureInfo,
onScaffoldEvent = onScaffoldEvent,
onNavigateBack = topBarconfig.navigationIcon.backAction,
onMenuOpen = topBarconfig.navigationIcon.menuAction,
)
}
}
val bottomBar: @Composable () -> Unit = {
when (val config = bottomBarConfig) {
is BottomBarConfig.None -> { /* No bottom bar */ }
is BottomBarConfig.Models.Browsing -> {
ModelsBrowsingBottomBar(
isSearchingEnabled = config.isSearchingEnabled,
onToggleSearching = config.onToggleSearching,
sortingConfig = config.sorting,
filteringConfig = config.filtering,
runActionConfig = config.runAction
)
}
is BottomBarConfig.Models.Searching -> {
ModelsSearchingBottomBar(
textFieldState = config.textFieldState,
onQuitSearching = config.onQuitSearching,
onSearch = config.onSearch,
runActionConfig = config.runAction,
)
}
is BottomBarConfig.Models.Managing -> {
ModelsManagementBottomBar(
isDeletionEnabled = config.isDeletionEnabled,
onToggleDeleting = config.onToggleDeleting,
sortingConfig = config.sorting,
filteringConfig = config.filtering,
importingConfig = config.importing
)
}
is BottomBarConfig.Models.Deleting -> {
ModelsDeletingBottomBar(config)
}
is BottomBarConfig.Benchmark -> {
BenchmarkBottomBar(
showShareFab = config.showShareFab,
engineIdle = config.engineIdle,
onShare = config.onShare,
onRerun = config.onRerun,
onClear = config.onClear,
showModelCard = config.showModelCard,
onToggleModelCard = config.onToggleModelCard,
)
}
is BottomBarConfig.Conversation -> {
ConversationBottomBar(
isReady = config.isEnabled,
textFieldState = config.textFieldState,
onSendClick = config.onSendClick,
showModelCard = config.showModelCard,
onToggleModelCard = config.onToggleModelCard,
onAttachPhotoClick = config.onAttachPhotoClick,
onAttachFileClick = config.onAttachFileClick,
onAudioInputClick = config.onAudioInputClick,
)
}
}
}
Scaffold(
topBar = topBar,
bottomBar = bottomBar,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
content = content
)
}
// Helper functions to obtain navigation actions if exist
private val NavigationIcon.menuAction: (() -> Unit)?
get() = (this as? NavigationIcon.Menu)?.onMenuOpen
private val NavigationIcon.backAction: (() -> Unit)?
get() = (this as? NavigationIcon.Back)?.onNavigateBack
private val NavigationIcon.quitAction: (() -> Unit)?
get() = (this as? NavigationIcon.Quit)?.onQuit

View File

@ -1,204 +0,0 @@
package com.arm.aiplayground.ui.scaffold
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.APP_NAME
import com.arm.aiplayground.BuildConfig
import com.arm.aiplayground.navigation.AppDestinations
import com.arm.aiplayground.navigation.NavigationActions
import kotlinx.coroutines.launch
/**
* App navigation drawer that provides access to different sections of the app.
* Gesture opening can be controlled based on current screen.
*/
@Composable
fun AppNavigationDrawer(
drawerState: DrawerState,
navigationActions: NavigationActions,
gesturesEnabled: Boolean,
currentRoute: String,
content: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
// Calculate drawer width (60% of screen width)
val drawerWidth = (configuration.screenWidthDp * 0.6).dp
// Handle back button to close drawer if open
BackHandler(enabled = drawerState.currentValue == DrawerValue.Open) {
coroutineScope.launch {
drawerState.close()
}
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = gesturesEnabled,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.width(drawerWidth)
) {
DrawerContent(
currentRoute = currentRoute,
onNavigate = { destination ->
coroutineScope.launch {
drawerState.close()
destination()
}
},
navigationActions = navigationActions
)
}
},
content = content
)
}
@Composable
private fun DrawerContent(
currentRoute: String,
onNavigate: ((Function0<Unit>)) -> Unit,
navigationActions: NavigationActions,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
// App Header
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = APP_NAME,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// // Main Navigation Items
// TODO-han.yin: add back once we add more features
// Text(
// text = "Features",
// style = MaterialTheme.typography.labelMedium,
// color = MaterialTheme.colorScheme.onSurfaceVariant,
// modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
// )
DrawerNavigationItem(
icon = Icons.Default.Home,
label = "Models",
isSelected = currentRoute == AppDestinations.MODELS_ROUTE,
onClick = {
if (currentRoute != AppDestinations.MODELS_ROUTE) {
onNavigate { navigationActions.navigateToModelSelection() }
} else {
onNavigate { /* No-op: simply close drawer */ }
}
}
)
DrawerNavigationItem(
icon = Icons.Default.Settings,
label = "Settings",
isSelected = currentRoute == AppDestinations.SETTINGS_GENERAL_ROUTE,
onClick = { onNavigate { navigationActions.navigateToSettingsGeneral() } }
)
}
}
@Composable
private fun DrawerNavigationItem(
icon: ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val backgroundColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
val contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
color = backgroundColor,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = contentColor,
modifier = Modifier.size(24.dp)
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}

View File

@ -1,79 +0,0 @@
package com.arm.aiplayground.ui.scaffold.bottombar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Badge
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Badge
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun BenchmarkBottomBar(
engineIdle: Boolean,
showShareFab: Boolean,
onShare: () -> Unit,
onRerun: () -> Unit,
onClear: () -> Unit,
showModelCard: Boolean,
onToggleModelCard: (Boolean) -> Unit,
) {
val controlTint =
if (engineIdle) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
BottomAppBar(
actions = {
IconButton(onClick = onRerun) {
Icon(
imageVector = Icons.Default.Replay,
contentDescription = "Run the benchmark again",
tint = controlTint
)
}
IconButton(onClick = onClear) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Clear benchmark results",
tint = controlTint
)
}
IconButton(onClick = { onToggleModelCard(!showModelCard) } ) {
Icon(
imageVector = if (showModelCard) Icons.Default.Badge else Icons.Outlined.Badge,
contentDescription = "${if (showModelCard) "Hide" else "Show"} model card"
)
}
},
floatingActionButton = {
// Only show FAB if the benchmark result is ready
AnimatedVisibility(
visible = showShareFab,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = onShare,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share the benchmark results"
)
}
}
}
)
}

View File

@ -1,118 +0,0 @@
package com.arm.aiplayground.ui.scaffold.bottombar
import androidx.compose.foundation.text.input.TextFieldState
import com.arm.aiplayground.data.model.ModelFilter
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.data.model.ModelSortOrder
import com.arm.aiplayground.viewmodel.PreselectedModelToRun
/**
* [BottomAppBar] configurations
*/
sealed class BottomBarConfig {
object None : BottomBarConfig()
sealed class Models : BottomBarConfig() {
data class Browsing(
val isSearchingEnabled: Boolean,
val onToggleSearching: () -> Unit,
val sorting: SortingConfig,
val filtering: FilteringConfig,
val runAction: RunActionConfig,
) : BottomBarConfig() {
data class SortingConfig(
val isEnabled: Boolean,
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig(
val isEnabled: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
}
data class Searching(
val textFieldState: TextFieldState,
val onQuitSearching: () -> Unit,
val onSearch: (String) -> Unit,
val runAction: RunActionConfig,
) : BottomBarConfig()
data class Managing(
val isDeletionEnabled: Boolean,
val onToggleDeleting: () -> Unit,
val sorting: SortingConfig,
val filtering: FilteringConfig,
val importing: ImportConfig,
) : BottomBarConfig() {
data class SortingConfig(
val isEnabled: Boolean,
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig(
val isEnabled: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class ImportConfig(
val showTooltip: Boolean,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val importFromLocal: () -> Unit,
val importFromHuggingFace: () -> Unit
)
}
data class Deleting(
val onQuitDeleting: () -> Unit,
val selectedModels: Map<String, ModelInfo>,
val selectAllFilteredModels: () -> Unit,
val clearAllSelectedModels: () -> Unit,
val deleteSelected: () -> Unit
) : BottomBarConfig()
data class RunActionConfig(
val showTooltip: Boolean,
val preselectedModelToRun: PreselectedModelToRun?,
val onClickRun: (PreselectedModelToRun) -> Unit,
)
}
data class Benchmark(
val showShareFab: Boolean,
val engineIdle: Boolean,
val onShare: () -> Unit,
val onRerun: () -> Unit,
val onClear: () -> Unit,
val showModelCard: Boolean,
val onToggleModelCard: (Boolean) -> Unit,
) : BottomBarConfig()
data class Conversation(
val isEnabled: Boolean,
val textFieldState: TextFieldState,
val onSendClick: () -> Unit,
val showModelCard: Boolean,
val onToggleModelCard: (Boolean) -> Unit,
val onAttachPhotoClick: (() -> Unit)? = null,
val onAttachFileClick: (() -> Unit)? = null,
val onAudioInputClick: (() -> Unit)? = null,
) : BottomBarConfig()
}

View File

@ -1,142 +0,0 @@
package com.arm.aiplayground.ui.scaffold.bottombar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Badge
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Badge
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@Composable
fun ConversationBottomBar(
textFieldState: TextFieldState,
isReady: Boolean,
onSendClick: () -> Unit,
showModelCard: Boolean,
onToggleModelCard: (Boolean) -> Unit,
onAttachPhotoClick: (() -> Unit)?,
onAttachFileClick: (() -> Unit)?,
onAudioInputClick: (() -> Unit)?,
) {
val placeholder = if (isReady) "Type your message here" else "AI is generating the response..."
Column(
modifier = Modifier.fillMaxWidth()
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = BottomAppBarDefaults.containerColor,
tonalElevation = BottomAppBarDefaults.ContainerElevation,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp)
) {
Box(
modifier = Modifier.fillMaxWidth()
.padding(start = 16.dp, top = 16.dp, end = 16.dp),
) {
OutlinedTextField(
state = textFieldState,
modifier = Modifier.fillMaxWidth(),
enabled = isReady,
placeholder = { Text(placeholder) },
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 5),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer.copy(
alpha = 0.5f
),
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceDim,
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
onKeyboardAction = {
if (isReady) {
onSendClick()
}
}
)
}
}
BottomAppBar(
actions = {
onAttachPhotoClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = Icons.Outlined.AddPhotoAlternate,
contentDescription = "Attach a photo",
)
}
}
onAttachFileClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = Icons.Outlined.AttachFile,
contentDescription = "Attach a file",
)
}
}
onAudioInputClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Input with voice",
)
}
}
IconButton(onClick = { onToggleModelCard(!showModelCard) } ) {
Icon(
imageVector = if (showModelCard) Icons.Default.Badge else Icons.Outlined.Badge,
contentDescription = "${if (showModelCard) "Hide" else "Show"} model card"
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = { if (isReady) { onSendClick() } },
containerColor = MaterialTheme.colorScheme.primary,
) {
if (isReady) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeCap = StrokeCap.Round,
)
}
}
}
)
}
}

View File

@ -1,222 +0,0 @@
package com.arm.aiplayground.ui.scaffold.bottombar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.data.model.ModelSortOrder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsBrowsingBottomBar(
isSearchingEnabled: Boolean,
onToggleSearching: () -> Unit,
sortingConfig: BottomBarConfig.Models.Browsing.SortingConfig,
filteringConfig: BottomBarConfig.Models.Browsing.FilteringConfig,
runActionConfig: BottomBarConfig.Models.RunActionConfig,
) {
val tooltipState = rememberTooltipState(
initialIsVisible = runActionConfig.showTooltip,
isPersistent = runActionConfig.showTooltip
)
LaunchedEffect(runActionConfig.preselectedModelToRun) {
if (runActionConfig.showTooltip && runActionConfig.preselectedModelToRun != null) {
tooltipState.show()
}
}
BottomAppBar(
actions = {
// Enter search action
IconButton(
enabled = isSearchingEnabled,
onClick = onToggleSearching
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search models"
)
}
// Sorting action
IconButton(
enabled = sortingConfig.isEnabled,
onClick = { sortingConfig.toggleMenu(true) }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sorting dropdown menu
DropdownMenu(
expanded = sortingConfig.isMenuVisible,
onDismissRequest = { sortingConfig.toggleMenu(false) }
) {
val sortOptions = listOf(
Triple(
ModelSortOrder.NAME_ASC,
"Name (A-Z)",
"Sort by name in ascending order"
),
Triple(
ModelSortOrder.NAME_DESC,
"Name (Z-A)",
"Sort by name in descending order"
),
Triple(
ModelSortOrder.SIZE_ASC,
"Size (Smallest first)",
"Sort by size in ascending order"
),
Triple(
ModelSortOrder.SIZE_DESC,
"Size (Largest first)",
"Sort by size in descending order"
),
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
)
sortOptions.forEach { (order, label, description) ->
DropdownMenuItem(
text = { Text(label) },
trailingIcon = {
if (sortingConfig.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sortingConfig.selectOrder(order) }
)
}
}
// Filter action
val hasFilters = filteringConfig.filters.any { it.value }
IconButton(
enabled = filteringConfig.isEnabled,
colors = IconButtonDefaults.iconButtonColors().copy(
contentColor = if (hasFilters) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
),
onClick = { filteringConfig.toggleMenu(true) }
) {
Icon(
imageVector =
if (hasFilters) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models",
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filteringConfig.isMenuVisible,
onDismissRequest = { filteringConfig.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
filteringConfig.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filteringConfig.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filteringConfig.onClearFilters()
filteringConfig.toggleMenu(false)
}
)
}
},
floatingActionButton = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above),
state = tooltipState,
tooltip = {
PlainTooltip {
Text("Tap this button to run your first model!")
}
},
onDismissRequest = {}
) {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runActionConfig.preselectedModelToRun != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = {
runActionConfig.preselectedModelToRun?.let {
runActionConfig.onClickRun(it)
}
},
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model",
)
}
}
}
}
)
}

View File

@ -1,61 +0,0 @@
package com.arm.aiplayground.ui.scaffold.bottombar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun ModelsDeletingBottomBar(
deleting: BottomBarConfig.Models.Deleting,
) {
BottomAppBar(
actions = {
IconButton(onClick = { deleting.clearAllSelectedModels() }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(onClick = { deleting.selectAllFilteredModels() }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
},
floatingActionButton = {
AnimatedVisibility(
visible = deleting.selectedModels.isNotEmpty(),
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = {
deleting.deleteSelected()
},
containerColor = MaterialTheme.colorScheme.error
) {
Icon(
imageVector = Icons.Default.DeleteForever,
contentDescription = "Delete selected models",
tint = MaterialTheme.colorScheme.onError,
)
}
}
}
)
}

View File

@ -1,235 +0,0 @@
package com.arm.aiplayground.ui.scaffold.bottombar
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.R
import com.arm.aiplayground.data.model.ModelSortOrder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsManagementBottomBar(
isDeletionEnabled: Boolean,
onToggleDeleting: () -> Unit,
sortingConfig: BottomBarConfig.Models.Managing.SortingConfig,
filteringConfig: BottomBarConfig.Models.Managing.FilteringConfig,
importingConfig: BottomBarConfig.Models.Managing.ImportConfig,
) {
val tooltipState = rememberTooltipState(
initialIsVisible = false,
isPersistent = importingConfig.showTooltip)
LaunchedEffect(importingConfig) {
if (importingConfig.showTooltip && !importingConfig.isMenuVisible) {
tooltipState.show()
}
}
BottomAppBar(
actions = {
// Batch-deletion action
IconButton(enabled = isDeletionEnabled, onClick = onToggleDeleting) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete models",)
}
// Sorting action
IconButton(
enabled = sortingConfig.isEnabled,
onClick = { sortingConfig.toggleMenu(true) }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sorting dropdown menu
DropdownMenu(
expanded = sortingConfig.isMenuVisible,
onDismissRequest = { sortingConfig.toggleMenu(false) }
) {
val sortOptions = listOf(
Triple(
ModelSortOrder.NAME_ASC,
"Name (A-Z)",
"Sort by name in ascending order"
),
Triple(
ModelSortOrder.NAME_DESC,
"Name (Z-A)",
"Sort by name in descending order"
),
Triple(
ModelSortOrder.SIZE_ASC,
"Size (Smallest first)",
"Sort by size in ascending order"
),
Triple(
ModelSortOrder.SIZE_DESC,
"Size (Largest first)",
"Sort by size in descending order"
),
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
)
sortOptions.forEach { (order, label, description) ->
DropdownMenuItem(
text = { Text(label) },
trailingIcon = {
if (sortingConfig.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sortingConfig.selectOrder(order) }
)
}
}
// Filtering action
val hasFilters = filteringConfig.filters.any { it.value }
IconButton(
enabled = filteringConfig.isEnabled,
onClick = { filteringConfig.toggleMenu(true) },
colors = IconButtonDefaults.iconButtonColors().copy(
contentColor = if (hasFilters) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
),
) {
Icon(
imageVector =
if (hasFilters) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models",
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filteringConfig.isMenuVisible,
onDismissRequest = { filteringConfig.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
filteringConfig.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filteringConfig.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filteringConfig.onClearFilters()
filteringConfig.toggleMenu(false)
}
)
}
},
floatingActionButton = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above),
state = tooltipState,
tooltip = {
PlainTooltip {
Text("Tap this button to install your first model!")
}
},
onDismissRequest = {}
) {
FloatingActionButton(
onClick = { importingConfig.toggleMenu(true) },
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add model"
)
}
}
// Add model dropdown menu
DropdownMenu(
expanded = importingConfig.isMenuVisible,
onDismissRequest = { importingConfig.toggleMenu(false) }
) {
DropdownMenuItem(
text = { Text("Import a local model") },
leadingIcon = {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = "Import a local model on the device"
)
},
onClick = importingConfig.importFromLocal
)
DropdownMenuItem(
text = { Text("Download from Hugging Face") },
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 = importingConfig.importFromHuggingFace
)
}
}
)
}

View File

@ -1,69 +0,0 @@
package com.arm.aiplayground.ui.scaffold.bottombar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun ModelsSearchingBottomBar(
textFieldState: TextFieldState,
onQuitSearching: () -> Unit,
onSearch: (String) -> Unit, // TODO-han.yin: somehow this is unused?
runActionConfig: BottomBarConfig.Models.RunActionConfig,
) {
BottomAppBar(
actions = {
// Quit search action
IconButton(onClick = onQuitSearching) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = "Quit search mode"
)
}
// Clear query action
IconButton(onClick = { textFieldState.clearText() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Backspace,
contentDescription = "Clear query text"
)
}
},
floatingActionButton = {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runActionConfig.preselectedModelToRun != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = {
runActionConfig.preselectedModelToRun?.let {
runActionConfig.onClickRun(it)
}
},
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
}
}
}
)
}

View File

@ -1,70 +0,0 @@
package com.arm.aiplayground.ui.scaffold.topbar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DefaultTopBar(
title: String,
titleColor: Color = Color.Unspecified,
navigationIconTint: Color = LocalContentColor.current,
onNavigateBack: (() -> Unit)? = null,
onQuit: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null
) {
TopAppBar(
title = {
Text(text = title, color = titleColor)
},
navigationIcon = {
when {
onQuit != null -> {
IconButton(onClick = onQuit) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Quit",
tint = navigationIconTint
)
}
}
onNavigateBack != null -> {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = navigationIconTint
)
}
}
onMenuOpen != null -> {
IconButton(onClick = onMenuOpen) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Menu",
tint = navigationIconTint
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}

View File

@ -1,120 +0,0 @@
package com.arm.aiplayground.ui.scaffold.topbar
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsBrowsingTopBar(
title: String,
showTooltip: Boolean,
showManagingToggle: Boolean,
onToggleManaging: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null,
) {
val tooltipState = rememberTooltipState(
initialIsVisible = showTooltip,
isPersistent = showTooltip
)
LaunchedEffect(showTooltip, showManagingToggle) {
if (showTooltip && showManagingToggle) {
tooltipState.show()
}
}
TopAppBar(
title = { Text(title) },
navigationIcon = {
when {
onNavigateBack != null -> {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
onMenuOpen != null -> {
IconButton(onClick = onMenuOpen) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Menu"
)
}
}
}
},
actions = {
if (showManagingToggle) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Below),
state = tooltipState,
tooltip = {
PlainTooltip {
Text("Tap this button to install another model or manage your models!")
}
},
onDismissRequest = {}
) {
ModelManageActionToggle(onToggleManaging)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@Composable
private fun ModelManageActionToggle(
onToggleManaging: () -> Unit,
) {
FilledTonalButton(
modifier = Modifier.padding(end = 8.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onClick = onToggleManaging
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Build,
contentDescription = "Manage models",
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = "Manage",
style = MaterialTheme.typography.bodySmall
)
}
}
}

View File

@ -1,94 +0,0 @@
package com.arm.aiplayground.ui.scaffold.topbar
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.monitoring.StorageMetrics
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsManagementTopBar(
title: String,
storageMetrics: StorageMetrics?,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
onNavigateBack: (() -> Unit)? = null,
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
if (onNavigateBack != null) {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
},
actions = {
storageMetrics?.let {
StorageIndicator(storageMetrics = it, onScaffoldEvent = onScaffoldEvent)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@Composable
private fun StorageIndicator(
storageMetrics: StorageMetrics,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
) {
val usedGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.usedGB)
val availableGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.availableGB)
OutlinedButton(
modifier = Modifier.padding(end = 8.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Your models occupy $usedGb GB storage\nRemaining free space available: $availableGb GB",
withDismissAction = true,
))
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.SdStorage,
contentDescription = "Storage",
tint = when {
storageMetrics.availableGB < 5.0f -> MaterialTheme.colorScheme.error
storageMetrics.availableGB < 10.0f -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
else -> MaterialTheme.colorScheme.onSurface
}
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = "$usedGb / $availableGb GB",
style = MaterialTheme.typography.bodySmall
)
}
}
}

View File

@ -1,174 +0,0 @@
package com.arm.aiplayground.ui.scaffold.topbar
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.monitoring.MemoryMetrics
import com.arm.aiplayground.monitoring.TemperatureMetrics
import com.arm.aiplayground.monitoring.TemperatureWarningLevel
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PerformanceTopBar(
title: String,
memoryMetrics: MemoryMetrics?,
temperatureDisplay: Pair<TemperatureMetrics, Boolean>?,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
onNavigateBack: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null,
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
when {
onNavigateBack != null -> {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
onMenuOpen != null -> {
IconButton(onClick = onMenuOpen) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Menu"
)
}
}
}
},
actions = {
// Temperature indicator (optional)
temperatureDisplay?.let { (temperatureMetrics, useFahrenheit) ->
TemperatureIndicator(
temperatureMetrics = temperatureMetrics,
useFahrenheit = useFahrenheit,
onScaffoldEvent = onScaffoldEvent,
)
}
// Memory indicator
memoryMetrics?.let {
MemoryIndicator(memoryUsage = it, onScaffoldEvent = onScaffoldEvent)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@Composable
private fun MemoryIndicator(
memoryUsage: MemoryMetrics,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
) {
val availableGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.availableGB)
val totalGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.totalGB)
OutlinedButton(
modifier = Modifier.padding(end = 8.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Free RAM available: $availableGB GB\nTotal RAM on your device: $totalGB GB",
withDismissAction = true,
))
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Memory,
contentDescription = "RAM usage",
tint = when {
memoryUsage.availableGB < 1 -> MaterialTheme.colorScheme.error
memoryUsage.availableGB < 3 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Text(
modifier = Modifier.padding(start = 2.dp),
text = "$availableGB / $totalGB GB",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun TemperatureIndicator(
temperatureMetrics: TemperatureMetrics,
useFahrenheit: Boolean,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
) {
val temperatureDisplay = temperatureMetrics.getDisplay(useFahrenheit)
val temperatureWarning = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> "Your device is HEATED UP to $temperatureDisplay, please cool it down before continue using the app."
TemperatureWarningLevel.MEDIUM -> "Your device is warming up to $temperatureDisplay."
else -> "Your device's temperature is $temperatureDisplay."
}
val warningDismissible = temperatureMetrics.warningLevel != TemperatureWarningLevel.HIGH
OutlinedButton(
modifier = Modifier.padding(end = 8.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = temperatureWarning,
withDismissAction = warningDismissible,
))
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> Icons.Default.WarningAmber
else -> Icons.Default.Thermostat
},
contentDescription = "Device temperature",
tint = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = temperatureDisplay,
style = MaterialTheme.typography.bodySmall,
color = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
}
}
}

View File

@ -1,65 +0,0 @@
package com.arm.aiplayground.ui.scaffold.topbar
import com.arm.aiplayground.monitoring.MemoryMetrics
import com.arm.aiplayground.monitoring.StorageMetrics
import com.arm.aiplayground.monitoring.TemperatureMetrics
/**
* [TopAppBar] configurations
*/
sealed class TopBarConfig {
abstract val title: String
abstract val navigationIcon: NavigationIcon
// No top bar at all
data class None(
override val title: String = "",
override val navigationIcon: NavigationIcon = NavigationIcon.None
) : TopBarConfig()
// Default/simple top bar with only a navigation icon
data class Default(
override val title: String,
override val navigationIcon: NavigationIcon
) : TopBarConfig()
// Model management top bar with a toggle to turn on/off manage mode
data class ModelsBrowsing(
override val title: String,
override val navigationIcon: NavigationIcon,
val showTooltip: Boolean,
val showManagingToggle: Boolean,
val onToggleManaging: () -> Unit,
) : TopBarConfig()
// Model batch-deletion top bar with a toggle to turn on/off manage mode
data class ModelsDeleting(
override val title: String,
override val navigationIcon: NavigationIcon,
) : TopBarConfig()
// Performance monitoring top bar with RAM and optional temperature
data class Performance(
override val title: String,
override val navigationIcon: NavigationIcon,
val memoryMetrics: MemoryMetrics?,
val temperatureInfo: Pair<TemperatureMetrics, Boolean>?,
) : TopBarConfig()
// Storage management top bar with used & total storage
data class ModelsManagement(
override val title: String,
override val navigationIcon: NavigationIcon,
val storageMetrics: StorageMetrics?
) : TopBarConfig()
}
/**
* Helper class for navigation icon configuration
*/
sealed class NavigationIcon {
data class Menu(val onMenuOpen: () -> Unit) : NavigationIcon()
data class Back(val onNavigateBack: () -> Unit) : NavigationIcon()
data class Quit(val onQuit: () -> Unit) : NavigationIcon()
data object None : NavigationIcon()
}

View File

@ -1,344 +0,0 @@
package com.arm.aiplayground.ui.screens
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.arm.aichat.InferenceEngine.State
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.engine.ModelLoadingMetrics
import com.arm.aiplayground.ui.components.ModelCardContentArchitectureRow
import com.arm.aiplayground.ui.components.ModelCardContentContextRow
import com.arm.aiplayground.ui.components.ModelCardContentField
import com.arm.aiplayground.ui.components.ModelCardCoreExpandable
import com.arm.aiplayground.ui.components.ModelUnloadDialogHandler
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import com.arm.aiplayground.util.TableData
import com.arm.aiplayground.util.formatMilliSeconds
import com.arm.aiplayground.util.parseMarkdownTable
import com.arm.aiplayground.viewmodel.BenchmarkResult
import com.arm.aiplayground.viewmodel.BenchmarkViewModel
@Composable
fun BenchmarkScreen(
loadingMetrics: ModelLoadingMetrics,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
onNavigateBack: () -> Unit,
viewModel: BenchmarkViewModel
) {
val context = LocalContext.current
// View model states
val engineState by viewModel.engineState.collectAsState()
val unloadDialogState by viewModel.unloadModelState.collectAsState()
val showModelCard by viewModel.showModelCard.collectAsState()
val selectedModel by viewModel.selectedModel.collectAsState()
val benchmarkResults by viewModel.benchmarkResults.collectAsState()
// UI states
var isModelCardExpanded by remember { mutableStateOf(true) }
var isInitialBenchmarkRun by rememberSaveable { mutableStateOf(false) }
// Run benchmark when entering the screen
LaunchedEffect(selectedModel) {
if (!isInitialBenchmarkRun) {
isInitialBenchmarkRun = true
viewModel.runBenchmark()
}
}
// Handle back button press
BackHandler {
viewModel.onBackPressed(onNavigateBack)
}
val onInfo = {
Toast.makeText(context, "Please refer to this post for more details on the benchmark methodology", Toast.LENGTH_SHORT).show()
val intent = Intent(Intent.ACTION_VIEW, "https://blog.steelph0enix.dev/posts/llama-cpp-guide/#llama-bench".toUri())
context.startActivity(intent)
}
Box(
modifier = Modifier.fillMaxSize()
) {
// Benchmark results
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.Bottom,
) {
items(items = benchmarkResults) {
BenchmarkResultCard(
result = it,
onRerun = { viewModel.rerunBenchmark(onScaffoldEvent) },
onInfo = onInfo,
)
}
}
// Loading indicator
if (engineState is State.Benchmarking) {
Card(
modifier = Modifier.align(Alignment.Center),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = MaterialTheme.shapes.extraLarge
) {
Column(
modifier = Modifier.padding(horizontal = 32.dp, vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
strokeWidth = ProgressIndicatorDefaults.CircularStrokeWidth * 1.5f
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Running benchmark...",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This usually takes a few minutes",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Selected model card and loading metrics
if (showModelCard) {
selectedModel?.let { model ->
Box(modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp)) {
ModelCardWithLoadingMetrics(
model = model,
loadingMetrics = loadingMetrics,
isExpanded = isModelCardExpanded,
onExpanded = { isModelCardExpanded = !isModelCardExpanded },
)
}
}
}
}
// Unload confirmation dialog
ModelUnloadDialogHandler(
message = "Going back will unload the current model and clear all the benchmark results.",
unloadModelState = unloadDialogState,
onUnloadConfirmed = { viewModel.onUnloadConfirmed(onNavigateBack) },
onUnloadDismissed = { viewModel.onUnloadDismissed() },
onNavigateBack = onNavigateBack,
)
}
@Composable
private fun ModelCardWithLoadingMetrics(
model: ModelInfo,
loadingMetrics: ModelLoadingMetrics,
isExpanded: Boolean = false,
onExpanded: ((Boolean) -> Unit)? = null,
) = ModelCardCoreExpandable(
model = model,
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
isExpanded = isExpanded,
onExpanded = onExpanded
) {
Spacer(modifier = Modifier.height(8.dp))
// Row 2: Context length, size label
ModelCardContentContextRow(model)
Spacer(modifier = Modifier.height(8.dp))
// Row 3: Architecture, quantization, formatted size
ModelCardContentArchitectureRow(model)
Spacer(modifier = Modifier.height(8.dp))
// Row 4: Model loading time
ModelCardContentField("Loading time", formatMilliSeconds(loadingMetrics.modelLoadingTimeMs))
}
@Composable
fun BenchmarkResultCard(
result: BenchmarkResult,
onRerun: () -> Unit,
onInfo: () -> Unit,
) {
val rawTable = parseMarkdownTable(result.text.trimIndent())
val model = rawTable.getColumn("model").firstOrNull() ?: "Unknown"
val parameters = rawTable.getColumn("params").firstOrNull() ?: "-"
val size = rawTable.getColumn("size").firstOrNull() ?: "-"
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
.padding(16.dp)
) {
Row {
Text(
text = "Model",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Normal,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
modifier = Modifier.weight(1f),
text = model,
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row {
ModelCardContentField("Parameters", parameters)
Spacer(modifier = Modifier.weight(1f))
ModelCardContentField("Size", size)
}
BenchmarkResultTable(rawTable)
ModelCardContentField("Time spent: ", formatMilliSeconds(result.duration))
Spacer(modifier = Modifier.height(8.dp))
Row {
OutlinedButton(onClick = onRerun) {
Icon(
imageVector = Icons.Default.Replay,
contentDescription = "Run the benchmark again"
)
Text("Run again", modifier = Modifier.padding(start = 6.dp))
}
Spacer(modifier = Modifier.weight(1f))
FilledTonalButton(onClick = onInfo) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Information about what the result means"
)
Text("How to interpret", modifier = Modifier.padding(start = 6.dp))
}
}
}
}
}
// Needs to be aligned with `bench` implementation
private val COLUMNS_TO_KEEP = setOf("backend", "test", "t/s")
private val WEIGHTS_EACH_COLUMN = listOf(1f, 1f, 2f)
@Composable
fun BenchmarkResultTable(
rawTable: TableData,
columnsToKeep: Set<String> = COLUMNS_TO_KEEP,
columnWeights: List<Float> = WEIGHTS_EACH_COLUMN
) {
val (headers, rows) = rawTable.filterColumns(columnsToKeep)
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 16.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, shape = RoundedCornerShape(4.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
BenchmarkResultTableRow(headers, columnWeights, isHeader = true)
HorizontalDivider(thickness = 1.dp)
rows.forEach { BenchmarkResultTableRow(it, columnWeights) }
}
}
@Composable
fun BenchmarkResultTableRow(
cells: List<String>,
weights: List<Float>? = null,
isHeader: Boolean = false,
) {
val effectiveWeights = weights ?: List(cells.size) { 1f }
Row(modifier = Modifier.fillMaxWidth()) {
cells.forEachIndexed { index, cell ->
Text(
modifier = Modifier.weight(effectiveWeights.getOrElse(index) { 1f }),
text = cell,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isHeader) FontWeight.Normal else FontWeight.Light,
fontStyle = if (isHeader) FontStyle.Normal else FontStyle.Italic
)
}
}
}

View File

@ -1,614 +0,0 @@
package com.arm.aiplayground.ui.screens
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.arm.aichat.InferenceEngine.State
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.engine.ModelLoadingMetrics
import com.arm.aiplayground.engine.TokenMetrics
import com.arm.aiplayground.ui.components.ModelCardContentArchitectureRow
import com.arm.aiplayground.ui.components.ModelCardContentContextRow
import com.arm.aiplayground.ui.components.ModelCardContentField
import com.arm.aiplayground.ui.components.ModelCardCoreExpandable
import com.arm.aiplayground.ui.components.ModelUnloadDialogHandler
import com.arm.aiplayground.util.formatMilliSeconds
import com.arm.aiplayground.util.formatMilliSecondstructured
import com.arm.aiplayground.util.toEnglishName
import com.arm.aiplayground.viewmodel.ConversationViewModel
import com.arm.aiplayground.viewmodel.Message
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import java.util.Locale
/**
* Screen for LLM conversation with user.
*/
@OptIn(FlowPreview::class)
@Composable
fun ConversationScreen(
loadingMetrics: ModelLoadingMetrics,
onNavigateBack: () -> Unit,
viewModel: ConversationViewModel
) {
// View model states
val engineState by viewModel.engineState.collectAsState()
val unloadDialogState by viewModel.unloadModelState.collectAsState()
val showModelCard by viewModel.showModelCard.collectAsState()
val selectedModel by viewModel.selectedModel.collectAsState()
val systemPrompt by viewModel.systemPrompt.collectAsState()
val messages by viewModel.messages.collectAsState()
val isGenerating = engineState is State.Generating
// UI states
val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
var isModelCardExpanded by remember { mutableStateOf(true) }
val listState = rememberLazyListState()
// Track the actual rendered size of the last message bubble
var lastMessageBubbleHeight by remember { mutableIntStateOf(0) }
// Track if user has manually scrolled up
var userHasScrolledUp by remember { mutableStateOf(false) }
// Detect if user is at the very bottom
val isAtBottom = remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
// At bottom if we can see the bottom spacer
lastVisibleItem != null && lastVisibleItem.index == messages.size
}
}
// Reset scroll flag when user returns to bottom
LaunchedEffect(Unit) {
snapshotFlow {
listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == messages.size
}
.distinctUntilChanged()
.filter { it }
.collect { userHasScrolledUp = false }
}
// Then scroll to bottom spacer instead
LaunchedEffect(isGenerating) {
snapshotFlow {
listState.layoutInfo.visibleItemsInfo.find {
it.index == messages.size - 1
}?.size ?: 0
}
.distinctUntilChanged()
.collect { currentHeight ->
// Only reposition if:
if (currentHeight > lastMessageBubbleHeight // 1. Height increased (new line)
&& !userHasScrolledUp // 2. User hasn't scrolled up
&& isAtBottom.value // 3. User is at bottom
) {
lastMessageBubbleHeight = currentHeight
listState.scrollToItem(index = messages.size, scrollOffset = 0)
} else if (currentHeight > 0) {
lastMessageBubbleHeight = currentHeight
}
}
}
// Detect manual scrolling
LaunchedEffect(listState.isScrollInProgress) {
if (listState.isScrollInProgress && !isAtBottom.value) {
userHasScrolledUp = true
}
}
// Set up lifecycle-aware message monitoring
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
// Scroll to bottom when returning to the screen
if (event == Lifecycle.Event.ON_RESUME) {
if (messages.isNotEmpty()) {
coroutineScope.launch {
listState.scrollToItem(messages.size)
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Handle back navigation requests
BackHandler {
viewModel.onBackPressed(onNavigateBack)
}
Box(
modifier = Modifier.fillMaxSize()
) {
ConversationMessageList(
messages = messages,
listState = listState,
)
if (showModelCard) {
selectedModel?.let {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.TopCenter)
) {
ModelCardWithSystemPrompt(
model = it,
loadingMetrics = loadingMetrics,
systemPrompt = systemPrompt,
isExpanded = isModelCardExpanded,
onExpanded = { isModelCardExpanded = !isModelCardExpanded }
)
}
}
}
}
// Unload confirmation dialog
ModelUnloadDialogHandler(
message = "Going back will unload the current model and clear the whole conversation.",
unloadModelState = unloadDialogState,
onUnloadConfirmed = { viewModel.onUnloadConfirmed(onNavigateBack) },
onUnloadDismissed = { viewModel.onUnloadDismissed() },
onNavigateBack = onNavigateBack,
)
}
@Composable
fun ModelCardWithSystemPrompt(
model: ModelInfo,
loadingMetrics: ModelLoadingMetrics,
systemPrompt: String?,
isExpanded: Boolean = true,
onExpanded: ((Boolean) -> Unit)? = null,
) = ModelCardCoreExpandable(
model = model,
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
isExpanded = isExpanded,
onExpanded = onExpanded
) {
Spacer(modifier = Modifier.height(8.dp))
// Row 2: Context length, size label
ModelCardContentContextRow(model)
Spacer(modifier = Modifier.height(8.dp))
// Row 3: Architecture, quantization, formatted size
ModelCardContentArchitectureRow(model)
Spacer(modifier = Modifier.height(8.dp))
// Row 4: Model loading time
ModelCardContentField("Loading time", formatMilliSeconds(loadingMetrics.modelLoadingTimeMs))
if (!systemPrompt.isNullOrBlank()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "System Prompt",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(6.dp))
Text(
text = systemPrompt,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.ExtraLight,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(6.dp))
loadingMetrics.systemPromptProcessingTimeMs?.let {
ModelCardContentField("Processing time", formatMilliSeconds(it))
}
}
}
@Composable
private fun ConversationMessageList(
messages: List<Message>,
listState: LazyListState,
) {
val context = LocalContext.current
val onInfoClick = {
Toast.makeText(context, "Please refer to this guide for more details on the metrics", Toast.LENGTH_SHORT).show()
val intent = Intent(Intent.ACTION_VIEW, "https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html".toUri())
context.startActivity(intent)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.Bottom,
) {
items(
items = messages,
key = { "${it::class.simpleName}_${it.timestamp}" }
) { message ->
when (message) {
is Message.User -> UserMessageBubble(
formattedTime = message.formattedTime,
content = message.content
)
is Message.Assistant.Ongoing -> AssistantMessageBubble(
formattedTime = message.formattedTime,
tokensCount = message.tokensCount,
content = message.content,
isThinking = message.content.isBlank(),
isGenerating = true,
metrics = null,
onInfoClick = onInfoClick,
)
is Message.Assistant.Stopped -> AssistantMessageBubble(
formattedTime = message.formattedTime,
tokensCount = message.metrics.tokensCount,
content = message.content,
isThinking = false,
isGenerating = false,
metrics = message.metrics,
onInfoClick = onInfoClick,
)
}
}
// Add extra space at the bottom for better UX and a scroll target
item(key = "bottom-spacer") {
Spacer(modifier = Modifier.height(36.dp))
}
}
}
@Composable
private fun UserMessageBubble(content: String, formattedTime: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.End
) {
// Timestamp above bubble
Text(
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 4.dp)
)
Row(modifier = Modifier.fillMaxWidth(0.9f)) {
Spacer(modifier = Modifier.weight(1f))
Card(
modifier = Modifier,
shape = RoundedCornerShape(16.dp, 2.dp, 16.dp, 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(12.dp)
)
}
}
}
}
@Composable
private fun AssistantMessageBubble(
formattedTime: String,
tokensCount: Int,
content: String,
isThinking: Boolean,
isGenerating: Boolean,
metrics: TokenMetrics? = null,
onInfoClick: () -> Unit,
) {
val formattedTokensCount = when(tokensCount) {
0 -> ""
1 -> "1 token"
else -> "$tokensCount tokens"
}
Column(
modifier = Modifier.fillMaxWidth(0.95f),
) {
// AI Assistant Avatar + Timestamp + Token count
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondary),
contentAlignment = Alignment.Center
) {
Text(
text = "AI",
color = MaterialTheme.colorScheme.onSecondary,
style = MaterialTheme.typography.labelMedium
)
}
Column(
modifier = Modifier.padding(start = 12.dp),
) {
Text(
text = formattedTime,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = formattedTokensCount,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
Card(
shape = RoundedCornerShape(2.dp, 16.dp, 16.dp, 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
// Show actual content
Text(
modifier = Modifier.padding(12.dp),
text = content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
// Show metrics or generation status below the bubble
if (isGenerating) {
Row(
modifier = Modifier.height(20.dp).padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
PulsatingDots(small = true)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (isThinking) "Thinking" else "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
} else {
// Show metrics when message is complete
metrics?.let {
ExpandableTokenMetricsBubble(metrics, onInfoClick)
}
}
}
}
@Composable
private fun PulsatingDots(small: Boolean = false) {
val transition = rememberInfiniteTransition(label = "dots")
val animations = List(3) { index ->
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
delayMillis = index * 300,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
),
label = "dot-$index"
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
animations.forEach { animation ->
Spacer(modifier = Modifier.width(2.dp))
Box(
modifier = Modifier
.size(if (small) 5.dp else 8.dp)
.clip(CircleShape)
.background(
color = MaterialTheme.colorScheme.primary.copy(
alpha = 0.3f + (animation.value * 0.7f)
)
)
)
Spacer(modifier = Modifier.width(2.dp))
}
}
}
@Composable
private fun ExpandableTokenMetricsBubble(
metrics: TokenMetrics,
onInfoClick: () -> Unit
) {
var showMetrics by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth()) {
FilterChip(
selected = showMetrics,
onClick = { showMetrics = !showMetrics },
label = {
Text(
text = "${if (showMetrics) "Hide" else "Show"} stats",
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.labelMedium,
)
},
leadingIcon = {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Default.Timer,
contentDescription = "${if (showMetrics) "Hide" else "Show"} token metrics of this assistant message"
)
},
)
if (showMetrics) {
Card(
shape = RoundedCornerShape(2.dp, 16.dp, 16.dp, 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
val ttft = formatMilliSecondstructured(metrics.ttftMs)
TokenMetricSection("1st Token", ttft.value.toString(), ttft.unit.toEnglishName())
val tps = String.format(Locale.getDefault(), "%.2f", metrics.tpsMs)
TokenMetricSection("Decode speed", tps, "tokens/sec")
val duration = formatMilliSecondstructured(metrics.duration)
TokenMetricSection("Duration", duration.value.toString(), duration.unit.toEnglishName())
IconButton(onClick = onInfoClick) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
contentDescription = "Information on token metrics"
)
}
}
}
}
}
}
@Composable
private fun TokenMetricSection(metricName: String, metricValue: String, metricUnit: String) {
Column {
Text(
text = metricName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Normal,
)
Text(
modifier = Modifier.padding(top = 2.dp),
text = metricValue,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic
)
Text(
modifier = Modifier.padding(top = 2.dp),
text = metricUnit,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic
)
}
}

View File

@ -1,305 +0,0 @@
package com.arm.aiplayground.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.arm.aichat.gguf.FileType
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.ui.components.ModelCardContentArchitectureRow
import com.arm.aiplayground.ui.components.ModelCardContentContextRow
import com.arm.aiplayground.ui.components.ModelCardContentTitleRow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ModelDetailsScreen(
model: ModelInfo,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState())
) {
// Row 1: Model full name
ModelCardContentTitleRow(model)
Spacer(modifier = Modifier.height(12.dp))
// Row 2: Context length, size label
ModelCardContentContextRow(model)
Spacer(modifier = Modifier.height(8.dp))
// Row 3: Architecture, quantization, formatted size
ModelCardContentArchitectureRow(model)
Spacer(modifier = Modifier.height(16.dp))
// Dates section
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Dates",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
val dateFormatter = remember { SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) }
Column {
ListItem(
headlineContent = { Text("Added") },
supportingContent = {
Text(dateFormatter.format(Date(model.dateAdded)))
}
)
model.dateLastUsed?.let { lastUsed ->
ListItem(
headlineContent = { Text("Last used") },
supportingContent = {
Text(dateFormatter.format(Date(lastUsed)))
}
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Metadata sections - only show if data exists
model.metadata.additional?.let { additional ->
if (additional.tags?.isNotEmpty() == true || additional.languages?.isNotEmpty() == true) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Additional Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
additional.tags?.takeIf { it.isNotEmpty() }?.let { tags ->
Text(
text = "Tags",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
tags.forEach { tag ->
AssistChip(
onClick = { /* No action */ },
label = { Text(tag) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
additional.languages?.takeIf { it.isNotEmpty() }?.let { languages ->
Text(
text = "Languages",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
languages.forEach { language ->
AssistChip(
onClick = { /* No action */ },
label = { Text(language) }
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
// Technical details section
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Technical Details",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// Architecture details
model.metadata.architecture?.let { architecture ->
ListItem(
headlineContent = { Text("Architecture") },
supportingContent = { Text(architecture.architecture ?: "Unknown") }
)
architecture.fileType?.let {
ListItem(
headlineContent = { Text("Quantization") },
supportingContent = { Text(FileType.fromCode(it).label) }
)
}
architecture.vocabSize?.let {
ListItem(
headlineContent = { Text("Vocabulary Size") },
supportingContent = { Text(it.toString()) }
)
}
}
// Context length
model.metadata.dimensions?.contextLength?.let {
ListItem(
headlineContent = { Text("Context Length") },
supportingContent = { Text("$it tokens") }
)
}
// ROPE params if available
model.metadata.rope?.let { rope ->
ListItem(
headlineContent = { Text("RoPE Base") },
supportingContent = {
rope.frequencyBase?.let { Text(it.toString()) }
}
)
ListItem(
headlineContent = { Text("RoPE Scaling") },
supportingContent = {
if (rope.scalingType != null && rope.scalingFactor != null) {
Text("${rope.scalingType}: ${rope.scalingFactor}")
} else {
Text("None")
}
}
)
}
// File size
ListItem(
headlineContent = { Text("File Size") },
supportingContent = { Text(model.formattedFileSize) }
)
// File path
ListItem(
headlineContent = { Text("File Path") },
supportingContent = {
Text(
text = model.path,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
// Add author and attribution section if available
model.metadata.author?.let { author ->
if (author.author != null || author.organization != null || author.license != null) {
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Attribution",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
author.author?.let {
ListItem(
headlineContent = { Text("Author") },
supportingContent = { Text(it) }
)
}
author.organization?.let {
ListItem(
headlineContent = { Text("Organization") },
supportingContent = { Text(it) }
)
}
author.license?.let {
ListItem(
headlineContent = { Text("License") },
supportingContent = { Text(it) }
)
}
author.url?.let {
ListItem(
headlineContent = { Text("URL") },
supportingContent = {
Text(
text = it,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}

View File

@ -1,575 +0,0 @@
package com.arm.aiplayground.ui.screens
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.arm.aichat.InferenceEngine.State
import com.arm.aichat.UnsupportedArchitectureException
import com.arm.aiplayground.data.model.SystemPrompt
import com.arm.aiplayground.engine.ModelLoadingMetrics
import com.arm.aiplayground.ui.components.ModelCardCoreExpandable
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import com.arm.aiplayground.viewmodel.ModelLoadingViewModel
enum class Mode {
BENCHMARK,
CONVERSATION
}
enum class SystemPromptTab(val label: String) {
PRESETS("Presets"), CUSTOM("Custom"), RECENTS("Recents")
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ModelLoadingScreen(
onScaffoldEvent: (ScaffoldEvent) -> Unit,
onNavigateBack: () -> Unit,
onNavigateToBenchmark: (ModelLoadingMetrics) -> Unit,
onNavigateToConversation: (ModelLoadingMetrics) -> Unit,
viewModel: ModelLoadingViewModel,
) {
val context = LocalContext.current
// View model states
val engineState by viewModel.engineState.collectAsState()
val selectedModel by viewModel.selectedModel.collectAsState()
val presetPrompts by viewModel.presetPrompts.collectAsState()
val recentPrompts by viewModel.recentPrompts.collectAsState()
// UI states
var isModelCardExpanded by remember { mutableStateOf(true) }
var selectedMode by remember { mutableStateOf<Mode?>(null) }
var useSystemPrompt by remember { mutableStateOf(false) }
var showedSystemPromptWarning by remember { mutableStateOf(false) }
var selectedPrompt by remember { mutableStateOf<SystemPrompt?>(null) }
var selectedTab by remember { mutableStateOf(SystemPromptTab.PRESETS) }
var customPromptText by remember { mutableStateOf("") }
var expandedPromptId by remember { mutableStateOf<String?>(null) }
// Automatically select first preset and expand it
LaunchedEffect(presetPrompts) {
if (presetPrompts.isNotEmpty() && selectedPrompt == null) {
val firstPreset = presetPrompts.first()
selectedPrompt = firstPreset
expandedPromptId = firstPreset.id
}
}
// Determine if a system prompt is actually selected/entered when the switch is on
val hasActiveSystemPrompt = when {
!useSystemPrompt -> true // Not using system prompt, so this is fine
selectedTab == SystemPromptTab.CUSTOM -> customPromptText.isNotBlank()
else -> selectedPrompt != null
}
// Check if we're in a loading state
val isLoading = engineState !is State.Initialized && engineState !is State.ModelReady
val exception = (engineState as? State.Error)?.exception
// Handle back navigation requests
BackHandler {
viewModel.onBackPressed(onNavigateBack)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Selected model card
selectedModel?.let { model ->
ModelCardCoreExpandable(
model = model,
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
isExpanded = isModelCardExpanded,
onExpanded = { isModelCardExpanded = !isModelCardExpanded },
)
Spacer(modifier = Modifier.height(16.dp))
}
// Benchmark card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.selectable(
selected = selectedMode == Mode.BENCHMARK,
onClick = {
selectedMode = Mode.BENCHMARK
useSystemPrompt = false
},
enabled = !isLoading,
role = Role.RadioButton
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMode == Mode.BENCHMARK,
enabled = !isLoading,
onClick = null // handled by parent selectable
)
Text(
text = "Benchmark",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Conversation card with integrated system prompt picker & editor
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.selectable(
selected = selectedMode == Mode.CONVERSATION,
onClick = { selectedMode = Mode.CONVERSATION },
enabled = !isLoading,
role = Role.RadioButton
)
// Only fill height if system prompt is active
.then(if (useSystemPrompt) Modifier.weight(1f) else Modifier)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
// Only fill height if system prompt is active
.then(if (useSystemPrompt) Modifier.fillMaxSize() else Modifier)
) {
// Conversation option
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMode == Mode.CONVERSATION,
enabled = !isLoading,
onClick = null // handled by parent selectable
)
Text(
text = "Conversation",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp)
)
}
// 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
)
IconButton(onClick = {
Toast.makeText(context, "Please refer to this guide for more details on \"System Prompt\"", Toast.LENGTH_SHORT).show()
val intent = Intent(Intent.ACTION_VIEW, "https://docs.perplexity.ai/guides/prompt-guide#system-prompt".toUri())
context.startActivity(intent)
}) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
contentDescription = "Information on system prompt"
)
}
Spacer(modifier = Modifier.weight(1f))
Switch(
checked = useSystemPrompt,
onCheckedChange = {
// First show a warning message if not yet
if (!showedSystemPromptWarning) {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Model may not support system prompt!\nProceed with caution.",
duration = SnackbarDuration.Long,
))
showedSystemPromptWarning = true
}
// Then update states
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)
)
SystemPromptTabSelector(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it }
)
Spacer(modifier = Modifier.height(8.dp))
SystemPromptTabContent(
selectedTab = selectedTab,
presetPrompts = presetPrompts,
recentPrompts = recentPrompts,
customPromptText = customPromptText,
onCustomPromptChange = {
customPromptText = it
// Deselect any preset prompt if typing custom
if (it.isNotBlank()) {
selectedPrompt = null
}
},
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))
}
// Start button
Button(
onClick = {
when (selectedMode) {
Mode.BENCHMARK -> viewModel.onBenchmarkSelected(onNavigateToBenchmark)
Mode.CONVERSATION -> {
val systemPrompt = if (useSystemPrompt) {
when (selectedTab) {
SystemPromptTab.PRESETS, SystemPromptTab.RECENTS ->
selectedPrompt?.let { prompt ->
// Save the prompt to recent prompts database
viewModel.savePromptToRecents(prompt)
prompt.content
}
SystemPromptTab.CUSTOM ->
customPromptText.takeIf { it.isNotBlank() }
?.also { promptText ->
// Save custom prompt to database
viewModel.saveCustomPromptToRecents(promptText)
}
}
} else null
viewModel.onConversationSelected(systemPrompt, onNavigateToConversation)
}
null -> { /* No mode selected */
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = if (exception != null)
ButtonDefaults.buttonColors(
disabledContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
disabledContentColor = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
) else ButtonDefaults.buttonColors(),
enabled = selectedMode != null && !isLoading &&
(!useSystemPrompt || hasActiveSystemPrompt)
) {
when {
exception != null -> {
val message = if (exception is UnsupportedArchitectureException) {
"Unsupported architecture: ${selectedModel?.metadata?.architecture?.architecture}"
} else {
exception.message ?: "Unknown error"
}
Icon(
imageVector = Icons.Default.Error,
contentDescription = message,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
isLoading -> {
CircularProgressIndicator(modifier = Modifier
.height(24.dp)
.width(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (engineState) {
is State.Initializing, State.Initialized -> "Initializing..."
is State.LoadingModel -> "Loading model..."
is State.ProcessingSystemPrompt -> "Processing system prompt..."
else -> "Processing..."
},
style = MaterialTheme.typography.titleMedium
)
}
else -> {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run model ${selectedModel?.name} with $selectedMode"
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Start", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
@Composable
private fun SystemPromptTabSelector(
selectedTab: SystemPromptTab,
onTabSelected: (SystemPromptTab) -> Unit
) {
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
SystemPromptTab.entries.forEachIndexed { index, tab ->
SegmentedButton(
selected = selectedTab == tab,
onClick = { onTabSelected(tab) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = SystemPromptTab.entries.size
),
icon = {
if (selectedTab == tab) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
},
label = { Text(tab.label) }
)
}
}
}
@Composable
private fun SystemPromptTabContent(
selectedTab: SystemPromptTab,
presetPrompts: List<SystemPrompt>,
recentPrompts: List<SystemPrompt>,
customPromptText: String,
onCustomPromptChange: (String) -> Unit,
selectedPromptId: String?,
expandedPromptId: String?,
onPromptSelected: (SystemPrompt) -> Unit,
onExpandPrompt: (String) -> Unit
) {
when (selectedTab) {
SystemPromptTab.PRESETS, SystemPromptTab.RECENTS -> {
val prompts = if (selectedTab == SystemPromptTab.PRESETS) presetPrompts else recentPrompts
if (prompts.isEmpty()) {
Text(
text =
if (selectedTab == SystemPromptTab.PRESETS) "No System Prompt presets available."
else "No recently used System Prompts found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
} else {
PromptList(
prompts = prompts,
selectedPromptId = selectedPromptId,
expandedPromptId = expandedPromptId,
onPromptSelected = onPromptSelected,
onExpandPrompt = onExpandPrompt
)
}
}
SystemPromptTab.CUSTOM -> {
OutlinedTextField(
value = customPromptText,
onValueChange = onCustomPromptChange,
modifier = Modifier
.fillMaxWidth()
.fillMaxSize(),
label = { Text("Customize your own system prompt here") },
placeholder = { Text("You are a helpful assistant...") },
minLines = 5
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PromptList(
prompts: List<SystemPrompt>,
selectedPromptId: String?,
expandedPromptId: String?,
onPromptSelected: (SystemPrompt) -> Unit,
onExpandPrompt: (String) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxSize(), // Fill available space
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = prompts,
key = { it.id }
) { prompt ->
val isSelected = selectedPromptId == prompt.id
val isExpanded = expandedPromptId == prompt.id
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem()
.selectable(
selected = isSelected,
onClick = {
onPromptSelected(prompt)
onExpandPrompt(prompt.id)
}
)
.padding(vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
RadioButton(
selected = isSelected,
onClick = null // Handled by selectable
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = prompt.title,
style = MaterialTheme.typography.titleSmall,
color = if (isSelected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
Text(
text = prompt.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (isExpanded) Int.MAX_VALUE else 2,
overflow = if (isExpanded) TextOverflow.Visible else TextOverflow.Ellipsis
)
}
}
if (prompt.id != prompts.last().id) {
HorizontalDivider(
modifier = Modifier.padding(top = 8.dp, start = 32.dp)
)
}
}
}
}
}

View File

@ -1,81 +0,0 @@
package com.arm.aiplayground.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.ui.components.InfoAction
import com.arm.aiplayground.ui.components.InfoView
import com.arm.aiplayground.ui.components.ModelCardFullExpandable
import com.arm.aiplayground.viewmodel.ModelsViewModel
import com.arm.aiplayground.viewmodel.PreselectedModelToRun
@Composable
fun ModelsBrowsingScreen(
showChatTooltip: Boolean,
filteredModels: List<ModelInfo>?,
activeFiltersCount: Int,
preselection: PreselectedModelToRun?,
onManageModelsClicked: () -> Unit,
viewModel: ModelsViewModel,
) {
if (filteredModels == null) {
ModelsLoadingInProgressView()
} else if (filteredModels.isEmpty()) {
// Empty model prompt
val title = when (activeFiltersCount) {
0 -> "No models installed yet"
1 -> "No models match your filter"
else -> "No models match your filters"
}
val message = when (activeFiltersCount) {
0 -> "Tap the button below to install your first Large Language Model!"
1 -> "Try removing your filter to see more results"
else -> "Try removing some filters to see more results"
}
Box(modifier = Modifier.fillMaxSize()) {
InfoView(
modifier = Modifier.fillMaxSize(0.9f).align(Alignment.Center),
title = title,
icon = Icons.Default.FolderOpen,
message = message,
action = InfoAction(
label = "Get started",
icon = Icons.AutoMirrored.Default.ArrowForward,
onAction = onManageModelsClicked
)
)
}
} else {
// Model cards
LazyColumn(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
) {
items(items = filteredModels, key = { it.id }) { model ->
ModelCardFullExpandable(
model = model,
isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected ->
if (!selected) viewModel.resetPreselection()
},
isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded ->
viewModel.preselectModel(model, expanded)
}
)
}
}
}
}

View File

@ -1,770 +0,0 @@
package com.arm.aiplayground.ui.screens
import android.content.Context
import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.ArrowForward
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.automirrored.outlined.ContactSupport
import androidx.compose.material.icons.filled.Attribution
import androidx.compose.material.icons.filled.Celebration
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
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.core.net.toUri
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.data.source.remote.HuggingFaceModel
import com.arm.aiplayground.ui.components.InfoAction
import com.arm.aiplayground.ui.components.InfoAlertDialog
import com.arm.aiplayground.ui.components.InfoView
import com.arm.aiplayground.ui.components.ModelCardFullExpandable
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import com.arm.aiplayground.util.formatContextLength
import com.arm.aiplayground.util.formatFileByteSize
import com.arm.aiplayground.viewmodel.ModelManagementState
import com.arm.aiplayground.viewmodel.ModelManagementState.Deletion
import com.arm.aiplayground.viewmodel.ModelManagementState.Download
import com.arm.aiplayground.viewmodel.ModelManagementState.Importation
import com.arm.aiplayground.viewmodel.ModelScreenUiMode
import com.arm.aiplayground.viewmodel.ModelsManagementViewModel
import com.arm.aiplayground.viewmodel.ModelsViewModel
import java.text.SimpleDateFormat
import java.util.Locale
/**
* Screen for managing LLM models (view, download, delete)
*/
@Composable
fun ModelsManagementAndDeletingScreen(
showModelImportTooltip: Boolean,
onFirstModelImportSuccess: (ModelInfo) -> Unit,
filteredModels: List<ModelInfo>?,
activeFiltersCount: Int,
isDeleting: Boolean,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
) {
val context = LocalContext.current
// Selection state
val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState()
// Model management state
val managementState by managementViewModel.managementState.collectAsState()
// UI states
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
BackHandler(
enabled = managementState is Importation.Importing
|| managementState is Deletion.Deleting
) {
/* Ignore back press while processing model management requests */
}
Box(modifier = Modifier.fillMaxSize()) {
if (filteredModels == null) {
ModelsLoadingInProgressView()
} else if (filteredModels.isEmpty()) {
// Prompt the user to import a model
val title = when (activeFiltersCount) {
0 -> "Install your first model"
1 -> "No models match\n the selected filter"
else -> "No models match\n the selected filters"
}
val message = "If you have already obtained GGUF models on your computer, " +
"please transfer it onto your device, and then select \"Import a local GGUF model\".\n\n" +
"Otherwise, select \"Download from Hugging Face\" and pick one of the pre-selected models."
InfoView(
modifier = Modifier.fillMaxSize(0.9f).align(Alignment.Center),
title = title,
icon = Icons.Default.Info,
message = message,
action = InfoAction(
label = "Learn more",
icon = Icons.AutoMirrored.Default.Help,
onAction = {
val url = "https://huggingface.co/docs/hub/en/gguf"
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
}
)
)
} else {
// Model cards
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
) {
items(items = filteredModels, key = { it.id }) { model ->
val isSelected =
if (isDeleting) selectedModels.contains(model.id) else null
ModelCardFullExpandable(
model = model,
isSelected = isSelected,
onSelected = {
if (isDeleting) {
managementViewModel.toggleModelSelectionById(filteredModels, model.id)
}
},
isExpanded = expandedModels.contains(model.id),
onExpanded = { expanded ->
if (expanded) {
expandedModels.put(model.id, model)
} else {
expandedModels.remove(model.id)
}
}
)
}
}
}
// Model import progress overlay
when (val state = managementState) {
is Importation.Confirming -> {
ImportFromLocalFileDialog(
fileName = state.fileName,
fileSize = state.fileSize,
isImporting = false,
progress = 0.0f,
onConfirm = {
managementViewModel.importLocalModelFileConfirmed(
state.uri, state.fileName, state.fileSize
)
},
onCancel = { managementViewModel.resetManagementState() }
)
}
is Importation.Importing -> {
ImportFromLocalFileDialog(
fileName = state.fileName,
fileSize = state.fileSize,
isImporting = true,
isCancelling = state.isCancelling,
progress = state.progress,
onConfirm = {},
onCancel = { managementViewModel.cancelOngoingLocalModelImport() },
)
}
is Importation.Error -> {
ErrorDialog(
context = context,
title = "Import Failed",
message = state.message,
learnMoreUrl = state.learnMoreUrl,
onDismiss = { managementViewModel.resetManagementState() }
)
}
is Importation.Success -> {
if (showModelImportTooltip) {
FirstModelImportSuccessDialog {
onFirstModelImportSuccess(state.model)
managementViewModel.resetManagementState()
}
} else {
LaunchedEffect(state) {
onScaffoldEvent(
ScaffoldEvent.ShowSnackbar(
message = "Imported model: ${state.model.name}"
)
)
managementViewModel.resetManagementState()
}
}
}
is Download.Querying -> {
ImportFromHuggingFaceDialog(
onCancel = { managementViewModel.resetManagementState() }
)
}
is Download.Ready -> {
ImportFromHuggingFaceDialog(
models = state.models,
onConfirm = { managementViewModel.downloadHuggingFaceModelConfirmed(it) },
onCancel = { managementViewModel.resetManagementState() }
)
}
is Download.Dispatched -> {
DownloadHuggingFaceDispatchedDialog(
state.downloadInfo.modelId,
onConfirm = { managementViewModel.resetManagementState() }
)
}
is Download.Completed -> {
ImportFromLocalFileDialog(
fileName = state.fileName,
fileSize = state.fileSize,
isImporting = false,
progress = 0.0f,
onConfirm = {
managementViewModel.importLocalModelFileConfirmed(
state.uri, state.fileName, state.fileSize
)
},
onCancel = { managementViewModel.resetManagementState() }
)
}
is Download.Error -> {
ErrorDialog(
context = context,
title = "Download Failed",
message = state.message,
onDismiss = { managementViewModel.resetManagementState() }
)
}
is Deletion.Confirming -> {
BatchDeleteConfirmationDialog(
count = state.models.size,
onConfirm = { managementViewModel.deleteModels(state.models) },
onDismiss = { managementViewModel.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(
context = context,
title = "Deletion Failed",
message = state.message,
onDismiss = { managementViewModel.resetManagementState() }
)
}
is Deletion.Success -> {
LaunchedEffect(state) {
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
val count = state.models.size
onScaffoldEvent(
ScaffoldEvent.ShowSnackbar(
message = "Deleted $count ${if (count > 1) "models" else "model"}.",
withDismissAction = true,
duration = SnackbarDuration.Long,
)
)
}
}
is ModelManagementState.Idle -> { /* Idle state, nothing to show */ }
}
}
}
@Composable
private fun ImportFromLocalFileDialog(
fileName: String,
fileSize: Long,
isImporting: Boolean,
isCancelling: Boolean = false,
progress: Float,
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
AlertDialog(
onDismissRequest = {
if (!isImporting) onCancel()
},
properties = DialogProperties(
dismissOnBackPress = !isImporting,
dismissOnClickOutside = !isImporting
),
title = {
Text(if (isImporting) "Importing Model" else "Confirm Import")
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Filename
Text(
text = fileName,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
if (isImporting) {
Spacer(modifier = Modifier.height(24.dp))
// Progress bar
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Percentage text
Text(
text = "${(progress * 100).toInt()}%",
style = MaterialTheme.typography.bodyLarge
)
} else {
// Show confirmation text
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Are you sure you want to import this model (${formatFileByteSize(fileSize)})? " +
"This may take up to several minutes.",
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(16.dp))
// Additional information
if (isImporting) {
Text(
text = "This may take several minutes for large models",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
} else if (isCancelling) {
Text(
text = "Cancelling ongoing import...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
// Only show confirm button in confirmation state
if (!isImporting) {
TextButton(onClick = onConfirm) { Text("Import") }
}
},
dismissButton = {
if (!isImporting || (progress < 0.7f && !isCancelling)) {
TextButton(onClick = onCancel, enabled = !isCancelling) {
Text("Cancel")
}
}
}
)
}
@Composable
private fun ImportFromHuggingFaceDialog(
models: List<HuggingFaceModel>? = null,
onConfirm: ((HuggingFaceModel) -> Unit)? = null,
onCancel: () -> Unit,
) {
val dateFormatter = remember { SimpleDateFormat("MMM, yyyy", Locale.getDefault()) }
var selectedModel by remember { mutableStateOf<HuggingFaceModel?>(null) }
AlertDialog(
onDismissRequest = {},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
),
title = {
Text(models?.let { "Fetched ${it.size} models" } ?: "Fetching models")
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = models?.let {
"The Hugging Face models shown here have been pre-filtered to be moderately sized and correctly quantized.\n\n" +
"Please use responsibly. Arm® does not endorse or take responsibility for misuse or harmful use of these models.\n\n" +
"Select a model to download:"
} ?: "Searching on HuggingFace for open-source models free for downloading...",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Start,
)
if (models == null) {
Spacer(modifier = Modifier.height(24.dp))
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
strokeWidth = 6.dp
)
} else {
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(models) { model ->
HuggingFaceModelListItem(
model = model,
isSelected = model._id == selectedModel?._id,
dateFormatter = dateFormatter,
onToggleSelect = { selected ->
selectedModel = if (selected) model else null
}
)
}
}
}
}
},
confirmButton = {
onConfirm?.let { onSelect ->
TextButton(
onClick = { selectedModel?.let { onSelect.invoke(it) } },
enabled = selectedModel != null
) {
Text("Download")
}
}
},
dismissButton = {
TextButton(
onClick = onCancel
) {
Text("Cancel")
}
}
)
}
@Composable
fun HuggingFaceModelListItem(
model: HuggingFaceModel,
isSelected: Boolean,
dateFormatter: SimpleDateFormat,
onToggleSelect: (Boolean) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = when (isSelected) {
true -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
false -> CardDefaults.cardColors()
},
onClick = { onToggleSelect(!isSelected) }
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text(
modifier = Modifier.fillMaxWidth().basicMarquee(),
text = model.modelId.substringAfterLast("/"),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
)
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.fillMaxWidth(0.85f)) {
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Attribution,
contentDescription = "Author",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = model.author,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.padding(start = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Favorite count",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = formatContextLength(model.likes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Today,
contentDescription = "Author",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = dateFormatter.format(model.lastModified),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.padding(start = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = "Download count",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = formatContextLength(model.downloads),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
Checkbox(
modifier = Modifier.align(Alignment.Center).size(32.dp)
.alpha(if (isSelected) 1f else 0f),
checked = isSelected,
onCheckedChange = null, // handled by parent selectable
)
}
}
}
}
}
@Composable
private fun DownloadHuggingFaceDispatchedDialog(
modelId: String,
onConfirm: () -> Unit,
) {
InfoAlertDialog(
title = "Download has started",
icon = Icons.Default.Download,
message = "Your Android system download manager has started downloading the model: $modelId.\n\n"
+ "You can track its progress in your notification drawer.\n"
+ "Feel free to stay on this screen, or come back to import it after complete.",
action = InfoAction(
icon = Icons.AutoMirrored.Default.ArrowForward,
label = "Okay",
onAction = onConfirm
)
)
}
@Composable
private fun FirstModelImportSuccessDialog(
onConfirm: () -> Unit,
) {
InfoAlertDialog(
title = "Congratulations",
icon = Icons.Default.Celebration,
message = "You have just installed your first Large Language Model!",
action = InfoAction(
icon = Icons.AutoMirrored.Default.ArrowForward,
label = "Check it out",
onAction = onConfirm
)
)
}
@Composable
private fun BatchDeleteConfirmationDialog(
count: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
isDeleting: Boolean = false
) {
AlertDialog(
// Prevent dismissal when deletion is in progress
onDismissRequest = {
if (!isDeleting) onDismiss()
},
// Prevent dismissal via back button during deletion
properties = DialogProperties(
dismissOnBackPress = !isDeleting,
dismissOnClickOutside = !isDeleting
),
title = {
Text("Confirm Deletion")
},
text = {
Column {
Text(
"Are you sure you want to delete "
+ "$count selected ${if (count == 1) "model" else "models"}? "
+ "This operation cannot be undone."
)
if (isDeleting) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Deleting models...")
}
}
}
},
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
confirmButton = {
TextButton(
onClick = onConfirm,
enabled = !isDeleting
) {
Text(
text = "Delete",
color = if (!isDeleting) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
)
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isDeleting
) {
Text("Cancel")
}
}
)
}
@Composable
private fun ErrorDialog(
context: Context,
title: String,
message: String,
learnMoreUrl: String? = null,
onDismiss: () -> Unit
) {
val action = learnMoreUrl?.let { url ->
InfoAction(
label = "Learn more",
icon = Icons.AutoMirrored.Outlined.ContactSupport,
onAction = {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
}
)
}
InfoAlertDialog(
isCritical = true,
title = title,
allowDismiss = true,
onDismiss = onDismiss,
icon = Icons.Default.Error,
message = message,
action = action,
confirmButton = {
FilledTonalButton(onClick = onDismiss) { Text("Dismiss") }
}
)
}

View File

@ -1,200 +0,0 @@
package com.arm.aiplayground.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.ui.components.InfoView
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import com.arm.aiplayground.util.formatFileByteSize
import com.arm.aiplayground.viewmodel.ModelScreenUiMode
import com.arm.aiplayground.viewmodel.ModelsManagementViewModel
import com.arm.aiplayground.viewmodel.ModelsViewModel
import com.arm.aiplayground.viewmodel.PreselectedModelToRun.RamWarning
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsScreen(
showModelImportTooltip: Boolean,
onFirstModelImportSuccess: (ModelInfo) -> Unit,
showChatTooltip: Boolean,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
) {
// Data
val filteredModels by modelsViewModel.filteredModels.collectAsState()
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
// UI states: Filter
val activeFilters by modelsViewModel.activeFilters.collectAsState()
val activeFiltersCount by remember(activeFilters) {
derivedStateOf { activeFilters.count { it.value } }
}
// UI states
val currentMode by modelsViewModel.modelScreenUiMode.collectAsState()
// Handle back button press
BackHandler {
when (currentMode) {
ModelScreenUiMode.BROWSING -> {
if (preselection != null) {
modelsViewModel.resetPreselection()
}
}
ModelScreenUiMode.SEARCHING -> {
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
}
ModelScreenUiMode.MANAGING -> {
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
}
ModelScreenUiMode.DELETING -> {
managementViewModel.clearSelectedModelsToDelete()
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
}
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
when (currentMode) {
ModelScreenUiMode.BROWSING ->
ModelsBrowsingScreen(
showChatTooltip = showChatTooltip,
filteredModels = filteredModels,
preselection = preselection,
onManageModelsClicked = {
managementViewModel.toggleImportMenu(true)
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
activeFiltersCount = activeFiltersCount,
viewModel = modelsViewModel,
)
ModelScreenUiMode.SEARCHING ->
ModelsSearchingScreen(
preselection = preselection,
viewModel = modelsViewModel
)
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
ModelsManagementAndDeletingScreen(
showModelImportTooltip = showModelImportTooltip,
onFirstModelImportSuccess = onFirstModelImportSuccess,
filteredModels = filteredModels,
isDeleting = currentMode == ModelScreenUiMode.DELETING,
onScaffoldEvent = onScaffoldEvent,
activeFiltersCount = activeFiltersCount,
modelsViewModel = modelsViewModel,
managementViewModel = managementViewModel,
)
}
// Show insufficient RAM warning
preselection?.let {
it.ramWarning?.let { warning ->
if (warning.showing) {
RamErrorDialog(
warning,
onDismiss = { modelsViewModel.dismissRamWarning() },
onConfirm = { onConfirmSelection(it.modelInfo, warning) }
)
}
}
}
}
}
@Composable
fun ModelsLoadingInProgressView() {
InfoView(
modifier = Modifier.fillMaxSize(),
title = "Loading...",
icon = {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
strokeWidth = ProgressIndicatorDefaults.CircularStrokeWidth * 1.5f
)
},
message = "Searching for installed models on your device...",
)
}
@Composable
private fun RamErrorDialog(
ramError: RamWarning,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
val requiredRam = formatFileByteSize(ramError.requiredRam)
val availableRam = formatFileByteSize(ramError.availableRam)
AlertDialog(
icon = {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Warning icon",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
},
title = {
Text(
modifier = Modifier.padding(top = 16.dp),
text = "Insufficient RAM",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
},
text = {
Text(
"You are trying to run a $requiredRam size model, " +
"but currently there's only $availableRam memory available!",
)
},
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
dismissButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Proceed")
}
},
onDismissRequest = onDismiss,
confirmButton = {
FilledTonalButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@ -1,187 +0,0 @@
package com.arm.aiplayground.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material3.Button
import androidx.compose.material3.DockedSearchBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.arm.aiplayground.ui.components.ModelCardFullExpandable
import com.arm.aiplayground.viewmodel.ModelScreenUiMode
import com.arm.aiplayground.viewmodel.ModelsViewModel
import com.arm.aiplayground.viewmodel.PreselectedModelToRun
@ExperimentalMaterial3Api
@Composable
fun ModelsSearchingScreen(
preselection: PreselectedModelToRun?,
viewModel: ModelsViewModel,
) {
// Query states
val textFieldState = viewModel.searchFieldState
val searchQuery by remember(textFieldState) {
derivedStateOf { textFieldState.text.toString() }
}
val queryResults by viewModel.queryResults.collectAsState()
// Local UI states
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val toggleSearchFocusAndIme: (Boolean) -> Unit = { show ->
if (show) {
focusRequester.requestFocus()
keyboardController?.show()
} else {
focusRequester.freeFocus()
keyboardController?.hide()
}
}
// TODO-han.yin: remove after validation
// LaunchedEffect (isSearchActive) {
// if (isSearchActive) {
// toggleSearchFocusAndIme(true)
// }
// }
val handleExpanded: (Boolean) -> Unit = { expanded ->
viewModel.toggleMode(
if (expanded) ModelScreenUiMode.SEARCHING
else ModelScreenUiMode.BROWSING
)
textFieldState.clearText()
}
Box(modifier = Modifier.fillMaxSize()) {
DockedSearchBar(
modifier = Modifier.align(Alignment.TopCenter),
inputField = {
SearchBarDefaults.InputField(
modifier = Modifier.focusRequester(focusRequester),
query = textFieldState.text.toString(),
onQueryChange = { textFieldState.edit { replace(0, length, it) } },
onSearch = {},
expanded = true,
onExpandedChange = handleExpanded,
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
placeholder = { Text("Type to search your models") }
)
},
expanded = true,
onExpandedChange = handleExpanded
) {
queryResults?.let { results ->
if (results.isEmpty()) {
if (searchQuery.isNotBlank()) {
// If no results under current query, show "no results" message
EmptySearchResultsView(
onClearSearch = {
textFieldState.clearText()
toggleSearchFocusAndIme(true)
}
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
) {
items(items = results, key = { it.id }) { model ->
ModelCardFullExpandable(
model = model,
isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected ->
if (selected) {
toggleSearchFocusAndIme(false)
} else {
viewModel.resetPreselection()
toggleSearchFocusAndIme(true)
}
},
isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded ->
viewModel.preselectModel(model, expanded)
toggleSearchFocusAndIme(!expanded)
}
)
}
}
}
} ?: ModelsLoadingInProgressView()
}
}
}
@Composable
private fun EmptySearchResultsView(
onClearSearch: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No matching models found",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Try a different search term",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onClearSearch) {
Text("Clear Search")
}
}
}

View File

@ -1,275 +0,0 @@
package com.arm.aiplayground.ui.screens
import android.content.Intent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.arm.aichat.ArmFeaturesMapper
import com.arm.aichat.ArmFeaturesMapper.DisplayItem
import com.arm.aiplayground.APP_NAME
import com.arm.aiplayground.BuildConfig
import com.arm.aiplayground.data.source.prefs.ColorThemeMode
import com.arm.aiplayground.data.source.prefs.DarkThemeMode
import com.arm.aiplayground.ui.components.ArmFeaturesVisualizer
import com.arm.aiplayground.viewmodel.SettingsViewModel
import kotlin.math.sqrt
/**
* Screen for general app settings
*/
@Composable
fun SettingsGeneralScreen(
viewModel: SettingsViewModel,
) {
// Collect state from ViewModel
val isMonitoringEnabled by viewModel.isMonitoringEnabled.collectAsState()
val useFahrenheit by viewModel.useFahrenheitUnit.collectAsState()
val colorThemeMode by viewModel.colorThemeMode.collectAsState()
val darkThemeMode by viewModel.darkThemeMode.collectAsState()
val detectedTier = viewModel.detectedTier
val supportedFeatures = remember(detectedTier) {
ArmFeaturesMapper.getFeatureDisplayData(detectedTier)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
SettingsCategory(title = "Performance Monitoring") {
SettingsSwitch(
title = "Enable Monitoring",
description = "Display memory, battery and temperature info",
checked = isMonitoringEnabled,
onCheckedChange = { viewModel.setMonitoringEnabled(it) }
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
SettingsSwitch(
title = "Use Fahrenheit",
description = "Display temperature in Fahrenheit instead of Celsius",
checked = useFahrenheit,
onCheckedChange = { viewModel.setUseFahrenheitUnit(it) }
)
}
SettingsCategory(title = "Styling") {
// Color theme mode
Text(
text = "Color Theme",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Arm® or follow your system dynamic colors",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
ColorThemeMode.entries.forEachIndexed { index, mode ->
val weight = sqrt(sqrt(mode.label.length.toFloat()))
SegmentedButton(
modifier = Modifier.weight(weight),
selected = colorThemeMode == mode,
onClick = { viewModel.setColorThemeMode(mode) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = ColorThemeMode.entries.size
)
) {
Text(mode.label)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
// Dark theme mode
Text(
text = "Dark Mode",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Follow system setting or override with your choice",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
DarkThemeMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = darkThemeMode == mode,
onClick = { viewModel.setDarkThemeMode(mode) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = DarkThemeMode.entries.size)
) {
Text(mode.label)
}
}
}
}
// ARM Features Visualizer with Tier Information description
detectedTier?.let { tier ->
SettingsCategory(title = "About your device") {
Text(
text = "AI Accelerated by Arm®",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Available hardware capabilities on your device are highlighted below:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
supportedFeatures?.let {
ArmFeaturesVisualizerClickable(supportedFeatures = it)
}
Text(
text = "Tap a feature above to learn more about how it accelerates Generative AI!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
SettingsCategory(title = "About this app") {
Text(
text = APP_NAME,
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Version ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Run Large Language Models locally at your fingertips, harness the power of mobile AI with Arm®.",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
fun SettingsCategory(
title: String,
content: @Composable () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
Card(modifier = Modifier.fillMaxWidth()) {
Column( modifier = Modifier.fillMaxWidth().padding(16.dp)) {
content()
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
fun SettingsSwitch(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}
/**
* Alternative version with clickable features that open ARM documentation.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArmFeaturesVisualizerClickable(
supportedFeatures: List<DisplayItem>,
) {
val context = LocalContext.current
ArmFeaturesVisualizer(
supportedFeatures = supportedFeatures,
onFeatureClick = { feature ->
// Open ARM documentation in browser
val intent = Intent(Intent.ACTION_VIEW, feature.armDocUrl.toUri())
context.startActivity(intent)
}
)
}

View File

@ -1,124 +0,0 @@
package com.arm.aiplayground.ui.theme
import androidx.compose.ui.graphics.Color
// --- Light Theme Colors ---
val md_theme_light_primary = Color(0xFF0747C9)
val md_theme_light_onPrimary = Color(0xFFFCFCFC)
val md_theme_light_primaryContainer = Color(0xFFD0DEF6)
val md_theme_light_onPrimaryContainer = Color(0xFF1F2227)
val md_theme_light_inversePrimary = Color(0xFFD0DEF6)
val md_theme_light_secondary = Color(0xFFE0BBFF)
val md_theme_light_onSecondary = Color(0xFF1F1A2A)
val md_theme_light_secondaryContainer = Color(0xFFF2DDFF)
val md_theme_light_onSecondaryContainer = Color(0xFF2A183F)
val md_theme_light_tertiary = Color(0xFFFCFC00)
val md_theme_light_onTertiary = Color(0xFF1F2227)
val md_theme_light_tertiaryContainer = Color(0xFFEEEEAA)
val md_theme_light_onTertiaryContainer = Color(0xFF1F2227)
val md_theme_light_background = Color(0xFFFCFCFC)
val md_theme_light_onBackground = Color(0xFF1F2227)
val md_theme_light_surface = Color(0xFFFCFCFC)
val md_theme_light_onSurface = Color(0xFF1F2227)
val md_theme_light_surfaceVariant = Color(0xFFD0DEF6)
val md_theme_light_onSurfaceVariant = Color(0xFF1F2227)
val md_theme_light_surfaceTint = Color(0xFFDFE1E7)
val md_theme_light_inverseSurface = Color(0xFF737A8B)
val md_theme_light_inverseOnSurface = Color(0xFFFCFCFC)
val md_theme_light_error = Color(0xFF8F0D11)
val md_theme_light_onError = Color(0xFFFCFCFC)
val md_theme_light_errorContainer = Color(0xFFF5C8C3)
val md_theme_light_onErrorContainer = Color(0xFF1F2227)
val md_theme_light_outline = Color(0xFF40454F)
val md_theme_light_outlineVariant = Color(0xFF8A92A5)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_light_surfaceBright = Color(0xFFD0DEF6)
val md_theme_light_surfaceContainerHighest = Color(0xFFDDE1E8)
val md_theme_light_surfaceContainerHigh = Color(0xFFE5E8ED)
val md_theme_light_surfaceContainer = Color(0xFFEBEDF1)
val md_theme_light_surfaceContainerLow = Color(0xFFF2F4F6)
val md_theme_light_surfaceContainerLowest = Color(0xFFF7F8F9)
val md_theme_light_surfaceDim = Color(0xFFB8BECB)
val md_theme_light_primaryFixed = Color(0xFF0747C9)
val md_theme_light_primaryFixedDim = Color(0xFF04359B)
val md_theme_light_onPrimaryFixed = Color(0xFFFCFCFC)
val md_theme_light_onPrimaryFixedVariant = Color(0xFF2F333B)
val md_theme_light_secondaryFixed = Color(0xFFFCFCFC)
val md_theme_light_secondaryFixedDim = Color(0xFFE9EBEF)
val md_theme_light_onSecondaryFixed = Color(0xFF1F2227)
val md_theme_light_onSecondaryFixedVariant = Color(0xFF2F333B)
val md_theme_light_tertiaryFixed = Color(0xFF5613CD)
val md_theme_light_tertiaryFixedDim = Color(0xFF220857)
val md_theme_light_onTertiaryFixed = Color(0xFFFCFCFC)
val md_theme_light_onTertiaryFixedVariant = Color(0xFFDFE1E7)
// --- Dark Theme Colors ---
val md_theme_dark_primary = Color(0xFF0747C9)
val md_theme_dark_onPrimary = Color(0xFFFCFCFC)
val md_theme_dark_primaryContainer = Color(0xFF020D2B)
val md_theme_dark_onPrimaryContainer = Color(0xFFFCFCFC)
val md_theme_dark_inversePrimary = Color(0xFF020D2B)
val md_theme_dark_secondary = Color(0xFF1F2227)
val md_theme_dark_onSecondary = Color(0xFFFCFCFC)
val md_theme_dark_secondaryContainer = Color(0xFF2F333B)
val md_theme_dark_onSecondaryContainer = Color(0xFFFCFCFC)
val md_theme_dark_tertiary = Color(0xFF5613CD)
val md_theme_dark_onTertiary = Color(0xFFFCFCFC)
val md_theme_dark_tertiaryContainer = Color(0xFF0F0429)
val md_theme_dark_onTertiaryContainer = Color(0xFFFCFCFC)
val md_theme_dark_background = Color(0xFF0F1216)
val md_theme_dark_onBackground = Color(0xFFFCFCFC)
val md_theme_dark_surface = Color(0xFF0F1216)
val md_theme_dark_onSurface = Color(0xFFFCFCFC)
val md_theme_dark_surfaceVariant = Color(0xFF020D2B)
val md_theme_dark_onSurfaceVariant = Color(0xFFFCFCFC)
val md_theme_dark_surfaceTint = Color(0xFF2F333B)
val md_theme_dark_inverseSurface = Color(0xFF8A92A5)
val md_theme_dark_inverseOnSurface = Color(0xFFFCFCFC)
val md_theme_dark_error = Color(0xFF8F0D11)
val md_theme_dark_onError = Color(0xFFFCFCFC)
val md_theme_dark_errorContainer = Color(0xFF180203)
val md_theme_dark_onErrorContainer = Color(0xFFFCFCFC)
val md_theme_dark_outline = Color(0xFFDFE1E7)
val md_theme_dark_outlineVariant = Color(0xFF9BA1B2)
val md_theme_dark_scrim = Color(0xFF000000)
val md_theme_dark_surfaceBright = Color(0xFF020D2B)
val md_theme_dark_surfaceContainer = Color(0xFF2F333B)
val md_theme_dark_surfaceContainerLow = Color(0xFF1F2227)
val md_theme_dark_surfaceContainerHighest = Color(0xFF505562)
val md_theme_dark_surfaceContainerHigh = Color(0xFF40454F)
val md_theme_dark_surfaceContainerLowest = Color(0xFF0F1216)
val md_theme_dark_surfaceDim = Color(0xFF626977)
val md_theme_dark_primaryFixed = Color(0xFF0747C9)
val md_theme_dark_primaryFixedDim = Color(0xFF04359B)
val md_theme_dark_onPrimaryFixed = Color(0xFFFCFCFC)
val md_theme_dark_onPrimaryFixedVariant = Color(0xFFDFE1E7)
val md_theme_dark_secondaryFixed = Color(0xFF1F2227)
val md_theme_dark_secondaryFixedDim = Color(0xFF40454F)
val md_theme_dark_onSecondaryFixed = Color(0xFFFCFCFC)
val md_theme_dark_onSecondaryFixedVariant = Color(0xFFDFE1E7)
val md_theme_dark_tertiaryFixed = Color(0xFF5613CD)
val md_theme_dark_tertiaryFixedDim = Color(0xFF220857)
val md_theme_dark_onTertiaryFixed = Color(0xFFFCFCFC)
val md_theme_dark_onTertiaryFixedVariant = Color(0xFFDFE1E7)

View File

@ -1,16 +0,0 @@
package com.arm.aiplayground.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp)
)
// Additional custom shapes for specific components
val CardShape = RoundedCornerShape(12.dp)
val ButtonShape = RoundedCornerShape(8.dp)
val InputFieldShape = RoundedCornerShape(8.dp)

View File

@ -1,164 +0,0 @@
package com.arm.aiplayground.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.arm.aiplayground.data.source.prefs.ColorThemeMode
import com.arm.aiplayground.data.source.prefs.DarkThemeMode
// -------------------- Color Schemes --------------------
internal val armLightColorScheme: ColorScheme = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
inversePrimary = md_theme_light_inversePrimary,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
surfaceTint = md_theme_light_surfaceTint,
inverseSurface = md_theme_light_inverseSurface,
inverseOnSurface = md_theme_light_inverseOnSurface,
error = md_theme_light_error,
onError = md_theme_light_onError,
errorContainer = md_theme_light_errorContainer,
onErrorContainer = md_theme_light_onErrorContainer,
outline = md_theme_light_outline,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
surfaceBright = md_theme_light_surfaceBright,
surfaceContainer = md_theme_light_surfaceContainer,
surfaceContainerHigh = md_theme_light_surfaceContainerHigh,
surfaceContainerHighest = md_theme_light_surfaceContainerHighest,
surfaceContainerLow = md_theme_light_surfaceContainerLow,
surfaceContainerLowest = md_theme_light_surfaceContainerLowest,
surfaceDim = md_theme_light_surfaceDim,
primaryFixed = md_theme_light_primaryFixed,
primaryFixedDim = md_theme_light_primaryFixedDim,
onPrimaryFixed = md_theme_light_onPrimaryFixed,
onPrimaryFixedVariant = md_theme_light_onPrimaryFixedVariant,
secondaryFixed = md_theme_light_secondaryFixed,
secondaryFixedDim = md_theme_light_secondaryFixedDim,
onSecondaryFixed = md_theme_light_onSecondaryFixed,
onSecondaryFixedVariant = md_theme_light_onSecondaryFixedVariant,
tertiaryFixed = md_theme_light_tertiaryFixed,
tertiaryFixedDim = md_theme_light_tertiaryFixedDim,
onTertiaryFixed = md_theme_light_onTertiaryFixed,
onTertiaryFixedVariant = md_theme_light_onTertiaryFixedVariant,
)
internal val armDarkColorScheme: ColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
inversePrimary = md_theme_dark_inversePrimary,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
surfaceTint = md_theme_dark_surfaceTint,
inverseSurface = md_theme_dark_inverseSurface,
inverseOnSurface = md_theme_dark_inverseOnSurface,
error = md_theme_dark_error,
onError = md_theme_dark_onError,
errorContainer = md_theme_dark_errorContainer,
onErrorContainer = md_theme_dark_onErrorContainer,
outline = md_theme_dark_outline,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
surfaceBright = md_theme_dark_surfaceBright,
surfaceContainer = md_theme_dark_surfaceContainer,
surfaceContainerHigh = md_theme_dark_surfaceContainerHigh,
surfaceContainerHighest = md_theme_dark_surfaceContainerHighest,
surfaceContainerLow = md_theme_dark_surfaceContainerLow,
surfaceContainerLowest = md_theme_dark_surfaceContainerLowest,
surfaceDim = md_theme_dark_surfaceDim,
primaryFixed = md_theme_dark_primaryFixed,
primaryFixedDim = md_theme_dark_primaryFixedDim,
onPrimaryFixed = md_theme_dark_onPrimaryFixed,
onPrimaryFixedVariant = md_theme_dark_onPrimaryFixedVariant,
secondaryFixed = md_theme_dark_secondaryFixed,
secondaryFixedDim = md_theme_dark_secondaryFixedDim,
onSecondaryFixed = md_theme_dark_onSecondaryFixed,
onSecondaryFixedVariant = md_theme_dark_onSecondaryFixedVariant,
tertiaryFixed = md_theme_dark_tertiaryFixed,
tertiaryFixedDim = md_theme_dark_tertiaryFixedDim,
onTertiaryFixed = md_theme_dark_onTertiaryFixed,
onTertiaryFixedVariant = md_theme_dark_onTertiaryFixedVariant,
)
@Composable
fun isDarkTheme(darkThemeMode: DarkThemeMode) =
when (darkThemeMode) {
DarkThemeMode.AUTO -> isSystemInDarkTheme()
DarkThemeMode.LIGHT -> false
DarkThemeMode.DARK -> true
}
@Composable
fun LlamaTheme(
colorThemeMode: ColorThemeMode,
isDarkTheme: Boolean,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = when(colorThemeMode) {
ColorThemeMode.ARM ->
if (isDarkTheme) armDarkColorScheme else armLightColorScheme
ColorThemeMode.MATERIAL ->
if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}

View File

@ -1,125 +0,0 @@
package com.arm.aiplayground.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
// Additional specific text styles
val MonospacedTextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp
)

View File

@ -1,114 +0,0 @@
package com.arm.aiplayground.util
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.yield
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.channels.ReadableByteChannel
import java.nio.channels.WritableByteChannel
/**
* Gets the file name from a content URI
*/
fun getFileNameFromUri(context: Context, uri: Uri): String? =
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { nameIndex ->
if (nameIndex != -1) cursor.getString(nameIndex) else null
}
} else {
null
}
} ?: uri.lastPathSegment
/**
* Gets the file size from a content URI
*/
fun getFileSizeFromUri(context: Context, uri: Uri): Long? =
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.SIZE).let { sizeIndex ->
if (sizeIndex != -1) cursor.getLong(sizeIndex) else null
}
} else {
null
}
}
suspend fun copyWithChannels(
input: InputStream,
output: OutputStream,
totalSize: Long,
bufferSize: Int,
yieldSize: Int,
onProgress: (suspend (Float) -> Unit)?
) {
val inChannel: ReadableByteChannel = Channels.newChannel(input)
val outChannel: WritableByteChannel = Channels.newChannel(output)
val buffer = ByteBuffer.allocateDirect(bufferSize)
var totalBytesRead = 0L
var yieldCounter = 0L
while (inChannel.read(buffer) != -1) {
buffer.flip()
while (buffer.hasRemaining()) {
outChannel.write(buffer)
}
val bytesRead = buffer.position()
totalBytesRead += bytesRead
yieldCounter += bytesRead
buffer.clear()
// Report progress
onProgress?.invoke(totalBytesRead.toFloat() / totalSize)
if (yieldCounter >= yieldSize) {
yield()
yieldCounter = 0L
}
}
outChannel.close()
inChannel.close()
}
suspend fun copyWithBuffer(
input: InputStream,
output: OutputStream,
totalSize: Long,
bufferSize: Int,
yieldSize: Int,
onProgress: (suspend (Float) -> Unit)?
) {
val bufferedInput = BufferedInputStream(input, bufferSize)
val bufferedOutput = BufferedOutputStream(output, bufferSize)
val buffer = ByteArray(bufferSize)
var bytesRead: Int
var totalBytesRead = 0L
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
// Report progress
onProgress?.invoke(totalBytesRead.toFloat() / totalSize)
// Yield less frequently with larger buffers
if (totalBytesRead % (yieldSize) == 0L) { // Every 64MB
yield()
}
}
bufferedOutput.close()
bufferedInput.close()
}

View File

@ -1,88 +0,0 @@
package com.arm.aiplayground.util
import java.util.concurrent.TimeUnit
import java.util.Locale
import kotlin.math.round
/**
* Maps [TimeUnit] to English names, with plural form support
*/
fun TimeUnit.toEnglishName(plural: Boolean = false): String = when (this) {
TimeUnit.NANOSECONDS -> if (plural) "nanoseconds" else "nanosecond"
TimeUnit.MICROSECONDS -> if (plural) "microseconds" else "microsecond"
TimeUnit.MILLISECONDS -> if (plural) "milliseconds" else "millisecond"
TimeUnit.SECONDS -> if (plural) "seconds" else "second"
TimeUnit.MINUTES -> if (plural) "minutes" else "minute"
TimeUnit.HOURS -> if (plural) "hours" else "hour"
TimeUnit.DAYS -> if (plural) "days" else "day"
}
/**
* Formats milliseconds into a human-readable time string
* e.g., 2300ms -> "2.3 sec"
*/
fun formatMilliSeconds(millis: Long): String {
val seconds = millis / 1000.0
return if (seconds < 1.0) {
"${(seconds * 1000).toInt()} ms"
} else if (seconds < 60.0) {
"%.1f sec".format(seconds)
} else {
val minutes = seconds / 60.0
"%.1f min".format(minutes)
}
}
data class DurationValue(
val value: Double,
val unit: TimeUnit
)
/**
* Converts milliseconds into a structured DurationValue.
*
* Rules:
* - < 100 seconds -> show in SECONDS
* - < 100 minutes -> show in MINUTES
* - < 100 hours -> show in HOURS
*/
fun formatMilliSecondstructured(millis: Long): DurationValue {
val seconds = millis / 1000.0
return when {
seconds < 100 -> DurationValue(round2(seconds), TimeUnit.SECONDS)
seconds < 100 * 60 -> DurationValue(round2(seconds / 60.0), TimeUnit.MINUTES)
else -> DurationValue(round2(seconds / 3600.0), TimeUnit.HOURS)
}
}
private fun round2(v: Double): Double = round(v * 100) / 100
/**
* Convert bytes into human readable sizes
*/
fun formatFileByteSize(sizeInBytes: Long) = when {
sizeInBytes >= 1_000_000_000 -> {
val sizeInGb = sizeInBytes / 1_000_000_000.0
String.format(Locale.getDefault(), "%.1f GB", sizeInGb)
}
sizeInBytes >= 1_000_000 -> {
val sizeInMb = sizeInBytes / 1_000_000.0
String.format(Locale.getDefault(), "%.0f MB", sizeInMb)
}
else -> {
val sizeInKb = sizeInBytes / 1_000.0
String.format(Locale.getDefault(), "%.0f KB", sizeInKb)
}
}
/**
* Formats numbers to human-readable form (K, M)
*/
fun formatContextLength(contextLength: Int): String {
return when {
contextLength >= 1_000_000 -> String.format(Locale.getDefault(), "%.1fM", contextLength / 1_000_000.0)
contextLength >= 1_000 -> String.format(Locale.getDefault(), "%.0fK", contextLength / 1_000.0)
else -> contextLength.toString()
}
}

View File

@ -1,89 +0,0 @@
package com.arm.aiplayground.util
/**
* Maps ISO 639-1 language codes into ISO 3166-1 alpha-2 country codes, and then map to Emoji.
*
*/
fun languageCodeToFlagEmoji(languageCode: String): String? {
SPECIAL_LANGUAGES[languageCode]?.let { return it }
val countryCode = LANGUAGE_TO_COUNTRY[languageCode.lowercase()] ?: return null
return countryCodeToFlagEmoji(countryCode)
}
/**
* Formats ISO 3166-1 alpha-2 country code into corresponding Emoji.
*/
private fun countryCodeToFlagEmoji(countryCode: String): String? {
if (countryCode.length != 2) return null
// Convert each character to a Regional Indicator Symbol
val firstChar = Character.codePointAt(countryCode.uppercase(), 0) - 'A'.code + 0x1F1E6
val secondChar = Character.codePointAt(countryCode.uppercase(), 1) - 'A'.code + 0x1F1E6
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
}
private val SPECIAL_LANGUAGES = mapOf(
"multi" to "🌐",
"multilingual" to "🌐",
"multi-lingual" to "🌐",
)
private val LANGUAGE_TO_COUNTRY by lazy {
mapOf(
"af" to "ZA",
"am" to "ET",
"ar" to "SA",
"bg" to "BG",
"bn" to "BD",
"cs" to "CZ",
"da" to "DK",
"de" to "DE",
"el" to "GR",
"en" to "US",
"es" to "ES",
"et" to "EE",
"fa" to "IR",
"fi" to "FI",
"fil" to "PH",
"fr" to "FR",
"he" to "IL",
"hi" to "IN",
"hr" to "HR",
"hu" to "HU",
"id" to "ID",
"it" to "IT",
"ja" to "JP",
"kn" to "IN",
"ko" to "KR",
"lt" to "LT",
"lv" to "LV",
"ml" to "IN",
"mr" to "IN",
"ms" to "MY",
"nl" to "NL",
"no" to "NO",
"pa" to "IN",
"pl" to "PL",
"pt" to "PT",
"ro" to "RO",
"ru" to "RU",
"sk" to "SK",
"sl" to "SI",
"sr" to "RS",
"sv" to "SE",
"sw" to "KE",
"ta" to "LK",
"te" to "IN",
"th" to "TH",
"tr" to "TR",
"uk" to "UA",
"ur" to "PK",
"vi" to "VN",
"zh" to "CN",
"zu" to "ZA",
)
}

View File

@ -1,41 +0,0 @@
package com.arm.aiplayground.util
import java.util.Locale
@Deprecated("Use GgufMetadataReader instead!")
class NaiveMetadataExtractor private constructor() {
/**
* Try to extract parameters by looking for patterns like 7B, 13B, etc.
*/
fun extractParametersFromFilename(filename: String): String? =
Regex("([0-9]+(\\.[0-9]+)?)[bB]").find(filename)?.value?.uppercase()
/**
* Try to extract quantization by looking for patterns like Q4_0, Q5_K_M, etc.
*/
fun extractQuantizationFromFilename(filename: String) =
listOf(
Regex("[qQ][0-9]_[0-9]"),
Regex("[qQ][0-9]_[kK]_[mM]"),
Regex("[qQ][0-9]_[kK]"),
Regex("[qQ][0-9][fF](16|32)")
).firstNotNullOfOrNull {
it.find(filename)?.value?.uppercase()
}
/**
* Try to extract model type (Llama, Mistral, etc.)
*/
fun extractModelTypeFromFilename(filename: String): String? =
filename.lowercase().let { lowerFilename ->
listOf("llama", "mistral", "phi", "qwen", "falcon", "mpt")
.firstNotNullOfOrNull { type ->
if (lowerFilename.contains(type)) {
type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
}
} else { null }
}
}
}

View File

@ -1,49 +0,0 @@
package com.arm.aiplayground.util
/**
* A basic table data holder separating rows and columns
*/
data class TableData(
val headers: List<String>,
val rows: List<List<String>>
) {
val columnCount: Int get() = headers.size
val rowCount: Int get() = rows.size
/**
* Generate a copy of the original table with only the [keep] columns
*/
fun filterColumns(keep: Set<String>): TableData =
headers.mapIndexedNotNull { index, name ->
if (name in keep) index else null
}.let { keepIndices ->
val newHeaders = keepIndices.map { headers[it] }
val newRows = rows.map { row -> keepIndices.map { row.getOrElse(it) { "" } } }
TableData(newHeaders, newRows)
}
/**
* Obtain the data in the specified column
*/
fun getColumn(name: String): List<String> {
val index = headers.indexOf(name)
if (index == -1) return emptyList()
return rows.mapNotNull { it.getOrNull(index) }
}
}
/**
* Formats llama-bench's markdown output into structured [MarkdownTableData]
*/
fun parseMarkdownTable(markdown: String): TableData {
val lines = markdown.trim().lines().filter { it.startsWith("|") }
if (lines.size < 2) return TableData(emptyList(), emptyList())
val headers = lines[0].split("|").map { it.trim() }.filter { it.isNotEmpty() }
val rows = lines.drop(2).map { line ->
line.split("|").map { it.trim() }.filter { it.isNotEmpty() }
}
return TableData(headers, rows)
}

View File

@ -1,116 +0,0 @@
package com.arm.aiplayground.viewmodel
import androidx.lifecycle.viewModelScope
import com.arm.aichat.isUninterruptible
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.engine.BenchmarkService
import com.arm.aiplayground.ui.scaffold.ScaffoldEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.zip
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class BenchmarkViewModel @Inject constructor(
private val benchmarkService: BenchmarkService
) : ModelUnloadingViewModel(benchmarkService) {
// Data
val selectedModel: StateFlow<ModelInfo?> = benchmarkService.currentSelectedModel
private val _benchmarkDuration = MutableSharedFlow<Long>()
private val _benchmarkResults = MutableStateFlow<List<BenchmarkResult>>(emptyList())
val benchmarkResults: StateFlow<List<BenchmarkResult>> = _benchmarkResults.asStateFlow()
// UI state: Model card
private val _showModelCard = MutableStateFlow(false)
val showModelCard = _showModelCard.asStateFlow()
fun toggleModelCard(show: Boolean) {
_showModelCard.value = show
}
// UI state: Share FAB
private val _showShareFab = MutableStateFlow(false)
val showShareFab = _showShareFab.asStateFlow()
init {
viewModelScope.launch {
benchmarkService.benchmarkResults
.filterNotNull()
.zip(_benchmarkDuration) { result, duration ->
_benchmarkResults.update { oldResults ->
oldResults.toMutableList().apply {
add(BenchmarkResult(result, duration))
}
}
}.collect()
}
}
/**
* Run benchmark with specified parameters
*/
fun runBenchmark(pp: Int = 512, tg: Int = 128, pl: Int = 1, nr: Int = 3): Boolean {
if (engineState.value.isUninterruptible) {
return false
}
viewModelScope.launch {
_showShareFab.value = false
val benchmarkStartTs = System.currentTimeMillis()
benchmarkService.benchmark(pp, tg, pl, nr)
val benchmarkEndTs = System.currentTimeMillis()
_benchmarkDuration.emit(benchmarkEndTs - benchmarkStartTs)
_showShareFab.value = true
}
return true
}
override suspend fun performCleanup() { clearResults(null) }
fun clearResults(onScaffoldEvent: ((ScaffoldEvent) -> Unit)?) =
if (engineState.value.isUninterruptible) {
false
} else {
_benchmarkResults.value = emptyList()
_showShareFab.value = false
onScaffoldEvent?.invoke(ScaffoldEvent.ShowSnackbar(
message = "All benchmark results cleared."
))
true
}
/**
* Rerun the benchmark
*/
fun rerunBenchmark(onScaffoldEvent: (ScaffoldEvent) -> Unit) {
if (engineState.value.isUninterruptible) {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Benchmark already in progress!\n" +
"Please wait for the current run to complete."
))
} else {
runBenchmark()
}
}
fun shareResult(onScaffoldEvent: (ScaffoldEvent) -> Unit) {
_benchmarkResults.value.lastOrNull()?.let{
onScaffoldEvent(ScaffoldEvent.ShareText(it.text))
}
}
}
data class BenchmarkResult(
val text: String,
val duration: Long
)

View File

@ -1,206 +0,0 @@
package com.arm.aiplayground.viewmodel
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.lifecycle.viewModelScope
import com.arm.aiplayground.engine.ConversationService
import com.arm.aiplayground.engine.GenerationUpdate
import com.arm.aiplayground.engine.TokenMetrics
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class ConversationViewModel @Inject constructor(
private val conversationService: ConversationService
) : ModelUnloadingViewModel(conversationService) {
// Data
val selectedModel = conversationService.currentSelectedModel
val systemPrompt = conversationService.systemPrompt
// UI state: Model card
private val _showModelCard = MutableStateFlow(false)
val showModelCard = _showModelCard.asStateFlow()
fun toggleModelCard(show: Boolean) {
_showModelCard.value = show
}
// UI state: conversation messages
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
// UI state: Input text field
val inputFieldState = TextFieldState()
// Ongoing coroutine jobs
private var tokenCollectionJob: Job? = null
/**
* Send a message with the provided content
*/
fun sendMessage() {
val content = inputFieldState.text.toString()
if (content.isBlank()) return
// Cancel ongoing collection
stopGeneration()
// Add user message
val userMessage = Message.User(
content = content,
timestamp = System.currentTimeMillis()
)
_messages.value += userMessage
// Add placeholder for assistant response
val assistantMessage = Message.Assistant.Ongoing(
content = "",
timestamp = System.currentTimeMillis(),
tokensCount = 0,
)
_messages.value += assistantMessage
// Clear input field
inputFieldState.clearText()
// Collect response
tokenCollectionJob = viewModelScope.launch {
try {
conversationService.generateResponse(content)
.onCompletion { tokenCollectionJob = null }
.collect(::updateAssistantMessage)
} catch (_: CancellationException) {
handleCancellation()
tokenCollectionJob = null
} catch (e: Exception) {
handleResponseError(e)
tokenCollectionJob = null
}
}
}
/**
* Stop ongoing generation
*/
fun stopGeneration() =
tokenCollectionJob?.let { job ->
// handled by the catch blocks
if (job.isActive) { job.cancel() }
}
/**
* Handle the case when generation is explicitly cancelled by adding a stopping suffix
*/
private fun handleCancellation() =
_messages.value.toMutableList().apply {
(removeLastOrNull() as? Message.Assistant.Stopped)?.let {
add(it.copy(content = it.content + SUFFIX_GENERATION_STOPPED))
_messages.value = toList()
}
}
/**
* Handle response error by appending an error suffix
*/
private fun handleResponseError(e: Exception) =
_messages.value.toMutableList().apply {
(removeLastOrNull() as? Message.Assistant.Stopped)?.let {
add(it.copy(content = it.content + SUFFIX_GENERATION_ERROR.format(e.message)))
_messages.value = this.toList()
}
}
/**
* Handle updating the assistant message
*/
private fun updateAssistantMessage(update: GenerationUpdate) =
_messages.value.toMutableList().apply {
(removeLastOrNull() as? Message.Assistant.Ongoing)?.let {
if (update.metrics != null) {
// Finalized message (partial or complete) with metrics
add(Message.Assistant.Stopped(
content = update.text,
timestamp = it.timestamp,
metrics = update.metrics
))
} else if (!update.isComplete) {
// Ongoing message update
add(Message.Assistant.Ongoing(
content = update.text,
timestamp = it.timestamp,
tokensCount = it.tokensCount + 1,
))
}
_messages.value = toList()
}
}
override suspend fun performCleanup() = clearConversation()
/**
* Stop ongoing generation if any, then clean up all messages in the current conversation
*/
fun clearConversation() {
stopGeneration()
_messages.value = emptyList()
}
override fun onCleared() {
stopGeneration()
super.onCleared()
}
companion object {
private const val SUFFIX_GENERATION_STOPPED = " [Generation stopped]"
private const val SUFFIX_GENERATION_ERROR = " [Error: %s]"
}
}
/**
* Sealed class representing messages in a conversation.
*/
sealed class Message {
abstract val timestamp: Long
abstract val content: String
val formattedTime: String
get() = datetimeFormatter.format(Date(timestamp))
data class User(
override val timestamp: Long,
override val content: String
) : Message()
sealed class Assistant : Message() {
data class Ongoing(
override val timestamp: Long,
override val content: String,
val tokensCount: Int,
) : Assistant()
data class Stopped(
override val timestamp: Long,
override val content: String,
val metrics: TokenMetrics
) : Assistant()
}
companion object {
private val datetimeFormatter by lazy { SimpleDateFormat("h:mm a", Locale.getDefault()) }
}
}

View File

@ -1,77 +0,0 @@
package com.arm.aiplayground.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arm.aiplayground.data.source.prefs.AppPreferences
import com.arm.aiplayground.engine.InferenceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
/**
* Main ViewModel that expose the core states of [InferenceService] and [AppPreferences]
*/
class MainViewModel @Inject constructor (
private val appPreferences: AppPreferences,
private val inferenceService: InferenceService,
) : ViewModel() {
val engineState = inferenceService.engineState
// App preferences
private val _showModelImportTooltip = MutableStateFlow(true)
val showModelImportTooltip: StateFlow<Boolean> = _showModelImportTooltip.asStateFlow()
private val _showChatTooltip = MutableStateFlow(true)
val showChatTooltip: StateFlow<Boolean> = _showChatTooltip.asStateFlow()
private val _showModelManagementTooltip = MutableStateFlow(true)
val showModelManagementTooltip: StateFlow<Boolean> = _showModelManagementTooltip.asStateFlow()
/**
* Unload the current model and release the resources
*/
suspend fun unloadModel() = inferenceService.unloadModel()
init {
viewModelScope.launch {
launch {
appPreferences.userHasImportedFirstModel().collect {
_showModelImportTooltip.value = !it
}
}
launch {
appPreferences.userHasChattedWithModel().collect {
_showChatTooltip.value = !it
}
}
launch {
appPreferences.userHasNavigatedToManagement().collect {
_showModelManagementTooltip.value = !it
}
}
}
}
fun waiveChatTooltip() {
viewModelScope.launch {
appPreferences.setUserHasChattedWithModel(true)
}
}
fun waiveModelImportTooltip() {
viewModelScope.launch {
appPreferences.setUserHasImportedFirstModel(true)
}
}
fun waiveModelManagementTooltip() {
viewModelScope.launch {
appPreferences.setUserHasNavigatedToManagement(true)
}
}
}

View File

@ -1,118 +0,0 @@
package com.arm.aiplayground.viewmodel
import androidx.lifecycle.viewModelScope
import com.arm.aiplayground.data.model.SystemPrompt
import com.arm.aiplayground.data.repo.ModelRepository
import com.arm.aiplayground.data.repo.SystemPromptRepository
import com.arm.aiplayground.engine.ModelLoadingMetrics
import com.arm.aiplayground.engine.ModelLoadingService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ModelLoadingViewModel @Inject constructor(
private val modelLoadingService: ModelLoadingService,
private val systemPromptRepository: SystemPromptRepository,
private val modelRepository: ModelRepository,
) : ModelUnloadingViewModel(modelLoadingService) {
/**
* Currently selected model to be loaded
*/
val selectedModel = modelLoadingService.currentSelectedModel
/**
* Preset prompts
*/
val presetPrompts: StateFlow<List<SystemPrompt>> = systemPromptRepository.getPresetPrompts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = emptyList()
)
/**
* Recent prompts
*/
val recentPrompts: StateFlow<List<SystemPrompt>> = systemPromptRepository.getRecentPrompts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = emptyList()
)
/**
* Save a prompt to the recents list.
*/
fun savePromptToRecents(prompt: SystemPrompt) {
viewModelScope.launch {
systemPromptRepository.savePromptToRecents(prompt)
}
}
/**
* Create and save a custom prompt.
*/
fun saveCustomPromptToRecents(content: String) {
viewModelScope.launch {
systemPromptRepository.saveCustomPrompt(content)
}
}
/**
* Delete a prompt by ID.
*/
fun deletePrompt(id: String) {
viewModelScope.launch {
systemPromptRepository.deletePrompt(id)
}
}
/**
* Clear all recent prompts.
*/
fun clearRecentPrompts() {
viewModelScope.launch {
systemPromptRepository.deleteAllPrompts()
}
}
/**
* Loads the model, then navigate to [BenchmarkScreen] with [ModelLoadingMetrics]
*/
fun onBenchmarkSelected(onNavigateToBenchmark: (ModelLoadingMetrics) -> Unit) =
viewModelScope.launch {
selectedModel.value?.let { model ->
modelLoadingService.loadModelForBenchmark()?.let { metrics ->
modelRepository.updateModelLastUsed(model.id)
onNavigateToBenchmark(metrics)
}
}
}
/**
* Loads the model, process system prompt if any,
* then navigate to [ConversationScreen] with [ModelLoadingMetrics]
*/
fun onConversationSelected(
systemPrompt: String? = null,
onNavigateToConversation: (ModelLoadingMetrics) -> Unit
) = viewModelScope.launch {
selectedModel.value?.let { model ->
modelLoadingService.loadModelForConversation(systemPrompt)?.let { metrics ->
modelRepository.updateModelLastUsed(model.id)
onNavigateToConversation(metrics)
}
}
}
companion object {
private val TAG = ModelLoadingViewModel::class.java.simpleName
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
}
}

View File

@ -1,114 +0,0 @@
package com.arm.aiplayground.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arm.aichat.InferenceEngine
import com.arm.aichat.InferenceEngine.State
import com.arm.aichat.isModelLoaded
import com.arm.aichat.isUninterruptible
import com.arm.aiplayground.engine.InferenceService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* UI states to be consumed by [ModelUnloadDialogHandler], etc.
*/
sealed class UnloadModelState {
object Hidden : UnloadModelState()
object Confirming : UnloadModelState()
object Unloading : UnloadModelState()
data class Error(val message: String) : UnloadModelState()
}
/**
* Base ViewModel class for screens that requires additional model unloading functionality
*/
abstract class ModelUnloadingViewModel(
private val inferenceService: InferenceService
) : ViewModel() {
/**
* [InferenceEngine]'s core state
*/
val engineState: StateFlow<State> = inferenceService.engineState
/**
* Determine if the screen is in an uninterruptible state
*
* Subclass can override this default implementation
*/
protected open val isUninterruptible: Boolean
get() = engineState.value.isUninterruptible
protected open val isModelLoaded: Boolean
get() = engineState.value.isModelLoaded
/**
* [UnloadModelConfirmationDialog]'s UI states
*/
private val _unloadModelState = MutableStateFlow<UnloadModelState>(UnloadModelState.Hidden)
val unloadModelState: StateFlow<UnloadModelState> = _unloadModelState.asStateFlow()
/**
* Handle back press from both back button and top bar
*
* Subclass can override this default implementation
*/
open fun onBackPressed(onNavigateBack: () -> Unit) =
if (isUninterruptible) {
// During uninterruptible operations, ignore back navigation requests
} else if (!isModelLoaded) {
// If model not loaded, no need to unload at all, directly perform back navigation
onNavigateBack.invoke()
} else {
// If model is loaded, show confirmation dialog
_unloadModelState.value = UnloadModelState.Confirming
}
/**
* Handle confirmation from unload dialog
*/
fun onUnloadConfirmed(onNavigateBack: () -> Unit) =
viewModelScope.launch {
// Set unloading state to show progress
_unloadModelState.value = UnloadModelState.Unloading
try {
// Perform screen-specific cleanup
performCleanup()
// Unload the model
inferenceService.unloadModel()
// Reset state and navigate back
_unloadModelState.value = UnloadModelState.Hidden
onNavigateBack()
} catch (e: Exception) {
// Handle error
_unloadModelState.value = UnloadModelState.Error(
e.message ?: "Unknown error while unloading the model"
)
}
}
/**
* Handle dismissal of unload dialog
*/
fun onUnloadDismissed() =
when (_unloadModelState.value) {
is UnloadModelState.Unloading -> {
// Ignore dismissing requests during unloading
}
else -> _unloadModelState.value = UnloadModelState.Hidden
}
/**
* Perform any screen-specific cleanup before unloading the model
*
* To be implemented by subclasses if needed
*/
protected open suspend fun performCleanup() {}
}

View File

@ -1,334 +0,0 @@
package com.arm.aiplayground.viewmodel
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arm.aichat.gguf.InvalidFileFormatException
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.data.repo.InsufficientStorageException
import com.arm.aiplayground.data.repo.ModelRepository
import com.arm.aiplayground.data.source.remote.HuggingFaceDownloadInfo
import com.arm.aiplayground.data.source.remote.HuggingFaceModel
import com.arm.aiplayground.data.source.remote.HuggingFaceModelDetails
import com.arm.aiplayground.monitoring.MemoryMetrics
import com.arm.aiplayground.util.formatFileByteSize
import com.arm.aiplayground.util.getFileNameFromUri
import com.arm.aiplayground.util.getFileSizeFromUri
import com.arm.aiplayground.viewmodel.ModelManagementState.Deletion
import com.arm.aiplayground.viewmodel.ModelManagementState.Download
import com.arm.aiplayground.viewmodel.ModelManagementState.Importation
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@HiltViewModel
class ModelsManagementViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val modelRepository: ModelRepository,
) : ViewModel() {
// UI state: models selected to be batch-deleted
private val _selectedModelsToDelete = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
fun toggleModelSelectionById(filteredModels: List<ModelInfo>, modelId: String) {
val current = _selectedModelsToDelete.value.toMutableMap()
val model = filteredModels.find { it.id == modelId }
if (model != null) {
if (current.containsKey(modelId)) {
current.remove(modelId)
} else {
current[modelId] = model
}
_selectedModelsToDelete.value = current
}
}
fun selectModelsToDelete(models: List<ModelInfo>) {
_selectedModelsToDelete.value = models.associateBy { it.id }
}
fun clearSelectedModelsToDelete() {
_selectedModelsToDelete.value = emptyMap()
}
// UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
fun toggleImportMenu(show: Boolean) {
_showImportModelMenu.value = show
}
// HuggingFace: ongoing query jobs
private var huggingFaceQueryJob: Job? = null
// HuggingFace: Ongoing download jobs
private val activeDownloads = mutableMapOf<Long, HuggingFaceModel>()
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1).let { id ->
if (id in activeDownloads) {
handleDownloadComplete(id)
}
}
}
}
// Internal state
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
fun resetManagementState() {
huggingFaceQueryJob?.let {
if (it.isActive) { it.cancel() }
}
clearSelectedModelsToDelete()
_managementState.value = ModelManagementState.Idle
}
init {
context.registerReceiver(
downloadReceiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
RECEIVER_EXPORTED
)
}
/**
* First show confirmation instead of starting import local file immediately
*/
fun importLocalModelFileSelected(uri: Uri) = viewModelScope.launch {
try {
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
_managementState.value = Importation.Confirming(uri, fileName, fileSize)
} catch (e: Exception) {
_managementState.value = Importation.Error(
message = e.message ?: "Unknown error preparing import"
)
}
}
/**
* Import a local model file from device storage while updating UI states with realtime progress
*/
fun importLocalModelFileConfirmed(uri: Uri, fileName: String, fileSize: Long) = viewModelScope.launch {
try {
_managementState.value = Importation.Importing(0f, fileName, fileSize)
val model = modelRepository.importModel(uri, fileName, fileSize) { progress ->
_managementState.value = Importation.Importing(progress, fileName, fileSize)
}
_managementState.value = Importation.Success(model)
} catch (_: InvalidFileFormatException) {
_managementState.value = Importation.Error(
message = "Not a valid GGUF model!",
learnMoreUrl = "https://huggingface.co/docs/hub/en/gguf",
)
} catch (e: InsufficientStorageException) {
_managementState.value = Importation.Error(
message = e.message ?: "Insufficient storage space to import $fileName",
learnMoreUrl = "https://support.google.com/android/answer/7431795?hl=en",
)
} catch (e: Exception) {
Log.e(TAG, "Unknown exception importing $fileName", e)
_managementState.value = Importation.Error(
message = e.message ?: "Unknown error importing $fileName",
)
}
}
fun cancelOngoingLocalModelImport() = viewModelScope.launch {
viewModelScope.launch {
// First update UI to show we're attempting to cancel
_managementState.update { current ->
if (current is Importation.Importing) {
current.copy(isCancelling = true)
} else {
current
}
}
// Attempt to cancel
when (modelRepository.cancelImport()) {
null, true -> { _managementState.value = ModelManagementState.Idle }
false -> {
_managementState.value = Importation.Error(
message = "Failed to cancel import. Try again later."
)
}
}
}
}
/**
* Query models on HuggingFace available for download even without signing in
*/
fun queryModelsFromHuggingFace(memoryUsage: MemoryMetrics) {
huggingFaceQueryJob = viewModelScope.launch {
_managementState.emit(Download.Querying)
try {
val models = modelRepository.fetchPreselectedHuggingFaceModels(memoryUsage)
.map(HuggingFaceModelDetails::toModel)
_managementState.emit(Download.Ready(models))
} catch (_: CancellationException) {
resetManagementState()
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (_: FileNotFoundException) {
_managementState.emit(Download.Error(message = "No eligible models"))
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: Exception) {
_managementState.emit(Download.Error(message = e.message ?: "Unknown error"))
}
}
}
/**
* Dispatch download request to [DownloadManager] and update UI
*/
fun downloadHuggingFaceModelConfirmed(model: HuggingFaceModel) = viewModelScope.launch {
try {
require(!model.gated) { "Model is gated!" }
require(!model.private) { "Model is private!" }
val downloadInfo = model.toDownloadInfo()
requireNotNull(downloadInfo) { "Download URL is missing!" }
modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold(
onSuccess = { actualSize ->
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
.onSuccess { downloadId ->
activeDownloads[downloadId] = model
_managementState.value = Download.Dispatched(downloadInfo)
}
.onFailure { throw it }
},
onFailure = { throw it }
)
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: InsufficientStorageException) {
_managementState.value = Download.Error(
message = e.message ?: "Insufficient storage space to download ${model.modelId}",
)
} catch (e: Exception) {
_managementState.value = Download.Error(
message = e.message ?: "Unknown error downloading ${model.modelId}",
)
}
}
private fun handleDownloadComplete(downloadId: Long) = viewModelScope.launch {
val model = activeDownloads.remove(downloadId) ?: return@launch
(context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager)
.getUriForDownloadedFile(downloadId)?.let { uri ->
try {
val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A")
val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A")
_managementState.emit(Download.Completed(model, uri, fileName, fileSize))
} catch (e: Exception) {
_managementState.value = Download.Error(
message = e.message ?: "Unknown error downloading ${model.modelId}"
)
}
}
}
/**
* First show confirmation instead of starting deletion immediately
*/
fun batchDeletionClicked(models: Map<String, ModelInfo>) {
_managementState.value = Deletion.Confirming(models)
}
/**
* Delete multiple models one by one while updating UI states with realtime progress
*/
fun deleteModels(modelsToDelete: Map<String, ModelInfo>) = viewModelScope.launch {
val total = modelsToDelete.size
if (total == 0) return@launch
try {
_managementState.value = Deletion.Deleting(0f, modelsToDelete)
var deleted = 0
modelsToDelete.keys.toList().forEach {
modelRepository.deleteModel(it)
deleted++
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, modelsToDelete)
}
_managementState.value = Deletion.Success(modelsToDelete.values.toList())
clearSelectedModelsToDelete()
// Reset state after a delay
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
_managementState.value = ModelManagementState.Idle
} catch (e: Exception) {
_managementState.value = Deletion.Error(
message = e.message ?: "Error deleting $total models"
)
}
}
companion object {
private val TAG = ModelsManagementViewModel::class.java.simpleName
private const val FETCH_HUGGINGFACE_MODELS_LIMIT_SIZE = 50
private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L
}
}
sealed class ModelManagementState {
object Idle : ModelManagementState()
sealed class Importation : ModelManagementState() {
data class Confirming(val uri: Uri, val fileName: String, val fileSize: Long) : Importation()
data class Importing(val progress: Float = 0f, val fileName: String, val fileSize: Long, val isCancelling: Boolean = false) : Importation()
data class Success(val model: ModelInfo) : Importation()
data class Error(val message: String, val learnMoreUrl: String? = null) : Importation()
}
sealed class Download: ModelManagementState() {
object Querying : Download()
data class Ready(val models: List<HuggingFaceModel>) : Download()
data class Dispatched(val downloadInfo: HuggingFaceDownloadInfo) : Download()
data class Completed(val model: HuggingFaceModel, val uri: Uri, val fileName: String, val fileSize: Long) : Download()
data class Error(val message: String) : Download()
}
sealed class Deletion : ModelManagementState() {
data class Confirming(val models: Map<String, ModelInfo>): ModelManagementState()
data class Deleting(val progress: Float = 0f, val models: Map<String, ModelInfo>) : ModelManagementState()
data class Success(val models: List<ModelInfo>) : Deletion()
data class Error(val message: String) : Deletion()
}
}

View File

@ -1,289 +0,0 @@
package com.arm.aiplayground.viewmodel
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arm.aiplayground.data.model.ModelFilter
import com.arm.aiplayground.data.model.ModelInfo
import com.arm.aiplayground.data.model.ModelSortOrder
import com.arm.aiplayground.data.model.filterBy
import com.arm.aiplayground.data.model.queryBy
import com.arm.aiplayground.data.model.sortByOrder
import com.arm.aiplayground.data.repo.ModelRepository
import com.arm.aiplayground.engine.InferenceService
import com.arm.aiplayground.monitoring.PerformanceMonitor
import com.arm.aiplayground.viewmodel.PreselectedModelToRun.RamWarning
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(FlowPreview::class)
@HiltViewModel
class ModelsViewModel @Inject constructor(
private val modelRepository: ModelRepository,
private val performanceMonitor: PerformanceMonitor,
private val inferenceService: InferenceService,
) : ViewModel() {
// UI state: model management mode
private val _allModels = MutableStateFlow<List<ModelInfo>?>(null)
val allModels = _allModels.asStateFlow()
private val _modelScreenUiMode = MutableStateFlow(ModelScreenUiMode.BROWSING)
val modelScreenUiMode = _modelScreenUiMode.asStateFlow()
fun toggleMode(newMode: ModelScreenUiMode): Boolean {
val oldMode = _modelScreenUiMode.value
when (oldMode) {
ModelScreenUiMode.BROWSING -> {
when (newMode) {
ModelScreenUiMode.SEARCHING -> {
resetPreselection()
}
ModelScreenUiMode.MANAGING -> {
resetPreselection()
}
ModelScreenUiMode.DELETING -> { return false }
else -> { /* No-op */ }
}
}
ModelScreenUiMode.SEARCHING -> {
when (newMode) {
ModelScreenUiMode.BROWSING -> {
searchFieldState.clearText()
}
else -> { return false }
}
}
ModelScreenUiMode.MANAGING -> {
when (newMode) {
ModelScreenUiMode.SEARCHING -> { return false }
else -> { /* No-op */ }
}
}
ModelScreenUiMode.DELETING -> {
when (newMode) {
ModelScreenUiMode.BROWSING, ModelScreenUiMode.SEARCHING -> { return false }
else -> { /* No-op */ }
}
}
}
_modelScreenUiMode.value = newMode
return true
}
// UI state: search mode
val searchFieldState = TextFieldState()
// UI state: sort menu
private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
val sortOrder = _sortOrder.asStateFlow()
fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order
}
private val _showSortMenu = MutableStateFlow(false)
val showSortMenu = _showSortMenu.asStateFlow()
fun toggleSortMenu(visible: Boolean) {
_showSortMenu.value = visible
}
// UI state: filter menu
private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>(
ModelFilter.ALL_FILTERS.associateWith { false }
)
val activeFilters: StateFlow<Map<ModelFilter, Boolean>> = _activeFilters.asStateFlow()
fun toggleFilter(filter: ModelFilter, enabled: Boolean) {
_activeFilters.update { current ->
current.toMutableMap().apply {
this[filter] = enabled
}
}
}
fun clearFilters() {
_activeFilters.update { current ->
current.mapValues { false }
}
}
private val _showFilterMenu = MutableStateFlow(false)
val showFilterMenu = _showFilterMenu.asStateFlow()
fun toggleFilterMenu(visible: Boolean) {
_showFilterMenu.value = visible
}
// Data: filtered & sorted models
private val _filteredModels = MutableStateFlow<List<ModelInfo>?>(null)
val filteredModels = _filteredModels.asStateFlow()
// Data: queried models
private val _queryResults = MutableStateFlow<List<ModelInfo>?>(null)
val queryResults = _queryResults.asStateFlow()
// Data: pre-selected model in expansion mode
private val _preselectedModelToRun = MutableStateFlow<ModelInfo?>(null)
private val _showRamWarning = MutableStateFlow(false)
val preselectedModelToRun = combine(
_preselectedModelToRun,
performanceMonitor.monitorMemoryUsage(),
_showRamWarning,
) { model, memory, show ->
if (model == null) {
null
} else {
if (memory.availableMem >= model.sizeInBytes + RAM_LOAD_MODEL_BUFFER_BYTES) {
PreselectedModelToRun(model, null)
} else {
PreselectedModelToRun(model, RamWarning(model.sizeInBytes, memory.availableMem, show))
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = null
)
init {
viewModelScope.launch {
launch {
modelRepository.getModels().collectLatest {
_allModels.value = it
}
}
launch {
combine(
_allModels,
_activeFilters,
_sortOrder,
) { models, filters, sortOrder ->
models?.filterBy(filters)?.sortByOrder(sortOrder)
}.collectLatest {
_filteredModels.value = it
}
}
launch {
combine(
_allModels,
snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS)
) { models, query ->
if (query.isBlank()) {
emptyList()
} else {
models?.queryBy(query.toString())?.sortedBy {
it.dateLastUsed ?: it.dateAdded
}
}
}.collectLatest {
_queryResults.value = it
}
}
}
}
/**
* Pre-select a model to expand its details and show Run FAB
*/
fun preselectModel(modelInfo: ModelInfo, preselected: Boolean) {
_preselectedModelToRun.value = if (preselected) modelInfo else null
_showRamWarning.value = false
}
/**
* Reset preselected model to none (before navigating away)
*/
fun resetPreselection() {
_preselectedModelToRun.value = null
_showRamWarning.value = false
}
/**
* Select the currently pre-selected model
*
* @return True if RAM enough, otherwise False.
*/
fun selectModel(preselectedModelToRun: PreselectedModelToRun) =
when (preselectedModelToRun.ramWarning?.showing) {
null -> {
inferenceService.setCurrentModel(preselectedModelToRun.modelInfo)
true
}
false -> {
_showRamWarning.value = true
false
}
else -> false
}
/**
* Dismiss the RAM warnings
*/
fun dismissRamWarning() {
_showRamWarning.value = false
}
/**
* Acknowledge RAM warnings and confirm currently pre-selected model
*
* @return True if confirmed, otherwise False.
*/
fun confirmSelectedModel(modelInfo: ModelInfo, ramWarning: RamWarning): Boolean =
if (ramWarning.showing) {
inferenceService.setCurrentModel(modelInfo)
_showRamWarning.value = false
resetPreselection()
true
} else {
false
}
companion object {
private val TAG = ModelsViewModel::class.java.simpleName
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
}
}
enum class ModelScreenUiMode {
BROWSING,
SEARCHING,
MANAGING,
DELETING
}
data class PreselectedModelToRun(
val modelInfo: ModelInfo,
val ramWarning: RamWarning?,
) {
data class RamWarning(
val requiredRam: Long,
val availableRam: Long,
val showing: Boolean,
)
}

View File

@ -1,179 +0,0 @@
package com.arm.aiplayground.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arm.aichat.ArmCpuTier
import com.arm.aichat.TierDetection
import com.arm.aiplayground.data.repo.ModelRepository
import com.arm.aiplayground.data.source.prefs.ColorThemeMode
import com.arm.aiplayground.data.source.prefs.DarkThemeMode
import com.arm.aiplayground.data.source.prefs.UserPreferences
import com.arm.aiplayground.monitoring.BatteryMetrics
import com.arm.aiplayground.monitoring.MemoryMetrics
import com.arm.aiplayground.monitoring.PerformanceMonitor
import com.arm.aiplayground.monitoring.StorageMetrics
import com.arm.aiplayground.monitoring.TemperatureMetrics
import com.arm.aiplayground.monitoring.TemperatureWarningLevel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ViewModel that manages performance monitoring for the app.
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val userPreferences: UserPreferences,
private val performanceMonitor: PerformanceMonitor,
private val modelRepository: ModelRepository,
private val tierDetection: TierDetection,
) : 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()
// Battery information
private val _batteryInfo = MutableStateFlow(BatteryMetrics(0, false))
val batteryInfo: StateFlow<BatteryMetrics> = _batteryInfo.asStateFlow()
// User preferences: monitoring
private val _temperatureMetrics = MutableStateFlow(TemperatureMetrics(0f, TemperatureWarningLevel.NORMAL))
val temperatureMetrics: StateFlow<TemperatureMetrics> = _temperatureMetrics.asStateFlow()
private val _isMonitoringEnabled = MutableStateFlow(true)
val isMonitoringEnabled: StateFlow<Boolean> = _isMonitoringEnabled.asStateFlow()
private val _useFahrenheitUnit = MutableStateFlow(false)
val useFahrenheitUnit: StateFlow<Boolean> = _useFahrenheitUnit.asStateFlow()
private val _monitoringInterval = MutableStateFlow(5000L)
val monitoringInterval: StateFlow<Long> = _monitoringInterval.asStateFlow()
// User preferences: themes
private val _colorThemeMode = MutableStateFlow(ColorThemeMode.ARM)
val colorThemeMode: StateFlow<ColorThemeMode> = _colorThemeMode.asStateFlow()
private val _darkThemeMode = MutableStateFlow(DarkThemeMode.AUTO)
val darkThemeMode: StateFlow<DarkThemeMode> = _darkThemeMode.asStateFlow()
val detectedTier: ArmCpuTier?
get() = tierDetection.getDetectedTier()
init {
viewModelScope.launch {
// Load user preferences
_isMonitoringEnabled.value = userPreferences.isPerformanceMonitoringEnabled().first()
_useFahrenheitUnit.value = userPreferences.usesFahrenheitTemperature().first()
_monitoringInterval.value = userPreferences.getMonitoringInterval().first()
_colorThemeMode.value = userPreferences.getColorThemeMode().first()
_darkThemeMode.value = userPreferences.getDarkThemeMode().first()
// Start monitoring if enabled
if (_isMonitoringEnabled.value) {
startMonitoring()
}
}
}
/**
* Starts monitoring device performance.
*/
private var monitoringJob: Job? = null
private fun startMonitoring() {
val interval = _monitoringInterval.value
monitoringJob?.cancel()
viewModelScope.launch {
launch {
modelRepository.getStorageMetrics().collect { metrics ->
_storageMetrics.value = metrics
}
}
launch {
performanceMonitor.monitorMemoryUsage(interval).collect { metrics ->
_memoryUsage.value = metrics
}
}
launch {
performanceMonitor.monitorBattery(interval * 2).collect { metrics ->
_batteryInfo.value = metrics
}
}
launch {
performanceMonitor.monitorTemperature(interval * 2).collect { metrics ->
_temperatureMetrics.value = metrics
}
}
}
}
/**
* Sets whether performance monitoring is enabled.
*/
fun setMonitoringEnabled(enabled: Boolean) {
viewModelScope.launch {
if (enabled && !_isMonitoringEnabled.value) {
startMonitoring()
}
_isMonitoringEnabled.value = enabled
userPreferences.setPerformanceMonitoringEnabled(enabled)
}
}
/**
* Sets the temperature unit preference.
*/
fun setUseFahrenheitUnit(useFahrenheit: Boolean) {
viewModelScope.launch {
userPreferences.setUseFahrenheitTemperature(useFahrenheit)
_useFahrenheitUnit.value = useFahrenheit
}
}
/**
* Sets the monitoring interval.
*/
fun setMonitoringInterval(intervalMs: Long) {
viewModelScope.launch {
// Restart monitoring with new interval if active
if (_isMonitoringEnabled.value) {
startMonitoring()
}
userPreferences.setMonitoringInterval(intervalMs)
_monitoringInterval.value = intervalMs
}
}
/**
* Sets the color theme mode.
*/
fun setColorThemeMode(mode: ColorThemeMode) {
viewModelScope.launch {
userPreferences.setColorThemeMode(mode)
_colorThemeMode.value = mode
}
}
/**
* Sets the dark theme mode.
*/
fun setDarkThemeMode(mode: DarkThemeMode) {
viewModelScope.launch {
userPreferences.setDarkThemeMode(mode)
_darkThemeMode.value = mode
}
}
}

View File

@ -0,0 +1,266 @@
package com.example.llama
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.arm.aichat.AiChat
import com.arm.aichat.InferenceEngine
import com.arm.aichat.TierDetection
import com.arm.aichat.gguf.GgufMetadata
import com.arm.aichat.gguf.GgufMetadataReader
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
class MainActivity : AppCompatActivity() {
// Android views
private lateinit var tierTv: TextView
private lateinit var pickerBtn: FloatingActionButton
private lateinit var ggufTv: TextView
private lateinit var messagesRv: RecyclerView
private lateinit var userInputEt: EditText
private lateinit var userSendBtn: FloatingActionButton
// Arm AI Chat engine and utils
private lateinit var detection: TierDetection
private lateinit var engine: InferenceEngine
// Conversation states
private val messages = mutableListOf<Message>()
private val lastAssistantMsg = StringBuilder()
private val messageAdapter = MessageAdapter(messages)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
// Find views
tierTv = findViewById(R.id.tier)
pickerBtn = findViewById(R.id.pick_model)
ggufTv = findViewById(R.id.gguf)
messagesRv = findViewById(R.id.messages)
messagesRv.layoutManager = LinearLayoutManager(this)
messagesRv.adapter = messageAdapter
userInputEt = findViewById(R.id.user_input)
userSendBtn = findViewById(R.id.user_send)
// Arm AI Chat initialization
lifecycleScope.launch(Dispatchers.Default) {
// Obtain the device's CPU feature tier
detection = AiChat.getTierDetection(applicationContext)
withContext(Dispatchers.Main) {
tierTv.text = detection.getDetectedTier()?.description ?: "N/A"
}
// Obtain the inference engine
engine = AiChat.getInferenceEngine(applicationContext)
}
// Upon file picker button tapped, prompt user to select a GGUF metadata on the device
pickerBtn.setOnClickListener {
getContent.launch(arrayOf("*/*"))
}
// Upon user send button tapped, validate input and send to engine
userSendBtn.setOnClickListener {
handleUserInput()
}
}
private val getContent = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
Log.i(TAG, "Selected file uri:\n $uri")
uri?.let { handleSelectedModel(it) }
}
/**
* Handles the file Uri from [getContent] result
*/
private fun handleSelectedModel(uri: Uri) {
// Update UI states
pickerBtn.isEnabled = false
userInputEt.hint = "Parsing GGUF..."
ggufTv.text = "Parsing metadata from selected file \n$uri"
lifecycleScope.launch(Dispatchers.IO) {
// Parse GGUF metadata
Log.i(TAG, "Parsing GGUF metadata...")
contentResolver.openInputStream(uri)?.use {
GgufMetadataReader.create().readStructuredMetadata(it)
}?.let { metadata ->
// Update UI to show GGUF metadata to user
Log.i(TAG, "GGUF parsed: \n$metadata")
withContext(Dispatchers.Main) {
ggufTv.text = metadata.toString()
}
// Ensure the model file is available
val modelName = metadata.filename() + FILE_EXTENSION_GGUF
contentResolver.openInputStream(uri)?.use { input ->
ensureModelFile(modelName, input)
}?.let { modelFile ->
loadModel(modelName, modelFile)
withContext(Dispatchers.Main) {
userInputEt.hint = "Type and send a message!"
userInputEt.isEnabled = true
userSendBtn.isEnabled = true
}
}
}
}
}
/**
* Prepare the model file within app's private storage
*/
private suspend fun ensureModelFile(modelName: String, input: InputStream) =
withContext(Dispatchers.IO) {
File(ensureModelsDirectory(), modelName).also { file ->
// Copy the file into local storage if not yet done
if (!file.exists()) {
Log.i(TAG, "Start copying file to $modelName")
withContext(Dispatchers.Main) {
userInputEt.hint = "Copying file..."
}
FileOutputStream(file).use { input.copyTo(it) }
Log.i(TAG, "Finished copying file to $modelName")
} else {
Log.i(TAG, "File already exists $modelName")
}
}
}
/**
* Load the model file from the app private storage
*/
private suspend fun loadModel(modelName: String, modelFile: File) =
withContext(Dispatchers.IO) {
Log.i(TAG, "Loading model $modelName")
withContext(Dispatchers.Main) {
userInputEt.hint = "Loading model..."
}
engine.loadModel(modelFile.path)
}
/**
* Validate and send the user message into [InferenceEngine]
*/
private fun handleUserInput() {
userInputEt.text.toString().also { userSsg ->
if (userSsg.isEmpty()) {
Toast.makeText(this, "Input message is empty!", Toast.LENGTH_SHORT).show()
} else {
userInputEt.text = null
userSendBtn.isEnabled = false
// Update message states
messages.add(Message(UUID.randomUUID().toString(), userSsg, true))
lastAssistantMsg.clear()
messages.add(Message(UUID.randomUUID().toString(), lastAssistantMsg.toString(), false))
lifecycleScope.launch(Dispatchers.Default) {
engine.sendUserPrompt(userSsg)
.onCompletion {
withContext(Dispatchers.Main) {
userSendBtn.isEnabled = true
}
}.collect { token ->
val messageCount = messages.size
check(messageCount > 0 && !messages[messageCount - 1].isUser)
messages.removeAt(messageCount - 1).copy(
content = lastAssistantMsg.append(token).toString()
).let { messages.add(it) }
withContext(Dispatchers.Main) {
messageAdapter.notifyItemChanged(messages.size - 1)
}
}
}
}
}
}
/**
* Run a benchmark with the model file
*/
private suspend fun runBenchmark(modelName: String, modelFile: File) =
withContext(Dispatchers.Default) {
Log.i(TAG, "Starts benchmarking $modelName")
withContext(Dispatchers.Main) {
userInputEt.hint = "Running benchmark..."
}
engine.bench(
pp=BENCH_PROMPT_PROCESSING_TOKENS,
tg=BENCH_TOKEN_GENERATION_TOKENS,
pl=BENCH_SEQUENCE,
nr=BENCH_REPETITION
).let { result ->
messages.add(Message(UUID.randomUUID().toString(), result, false))
withContext(Dispatchers.Main) {
messageAdapter.notifyItemChanged(messages.size - 1)
}
}
}
/**
* Create the `models` directory if not exist.
*/
private fun ensureModelsDirectory() =
File(filesDir, DIRECTORY_MODELS).also {
if (it.exists() && !it.isDirectory) { it.delete() }
if (!it.exists()) { it.mkdir() }
}
companion object {
private val TAG = MainActivity::class.java.simpleName
private const val DIRECTORY_MODELS = "models"
private const val FILE_EXTENSION_GGUF = ".gguf"
private const val BENCH_PROMPT_PROCESSING_TOKENS = 512
private const val BENCH_TOKEN_GENERATION_TOKENS = 128
private const val BENCH_SEQUENCE = 1
private const val BENCH_REPETITION = 3
}
}
fun GgufMetadata.filename() = when {
basic.name != null -> {
basic.name?.let { name ->
basic.sizeLabel?.let { size ->
"$name-$size"
} ?: name
}
}
architecture?.architecture != null -> {
architecture?.architecture?.let { arch ->
basic.uuid?.let { uuid ->
"$arch-$uuid"
} ?: "$arch-${System.currentTimeMillis()}"
}
}
else -> {
"model-${System.currentTimeMillis().toHexString()}"
}
}

View File

@ -0,0 +1,51 @@
package com.example.llama
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
data class Message(
val id: String,
val content: String,
val isUser: Boolean
)
class MessageAdapter(
private val messages: List<Message>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val VIEW_TYPE_USER = 1
private const val VIEW_TYPE_ASSISTANT = 2
}
override fun getItemViewType(position: Int): Int {
return if (messages[position].isUser) VIEW_TYPE_USER else VIEW_TYPE_ASSISTANT
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return if (viewType == VIEW_TYPE_USER) {
val view = layoutInflater.inflate(R.layout.item_message_user, parent, false)
UserMessageViewHolder(view)
} else {
val view = layoutInflater.inflate(R.layout.item_message_assistant, parent, false)
AssistantMessageViewHolder(view)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val message = messages[position]
if (holder is UserMessageViewHolder || holder is AssistantMessageViewHolder) {
val textView = holder.itemView.findViewById<TextView>(R.id.msg_content)
textView.text = message.content
}
}
override fun getItemCount(): Int = messages.size
class UserMessageViewHolder(view: View) : RecyclerView.ViewHolder(view)
class AssistantMessageViewHolder(view: View) : RecyclerView.ViewHolder(view)
}

View File

@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E5E5EA" />
<corners android:radius="16dp" />
</shape>

View File

@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#4285F4" />
<corners android:radius="16dp" />
</shape>

View File

@ -1,4 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
</layer-list>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -1,17 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="256"
android:viewportHeight="256">
<group
android:scaleX="0.030"
android:scaleY="0.030"
android:translateX="64"
android:translateY="110">
<path android:fillColor="#080225" android:pathData="M977.9,31.9h290.4v1247.6H977.9v-130.4C850.5,1297.2 693.4,1318 604.5,1318C219.3,1318 0,998 0,654.2C0,248.2 278.5,-0.7 607.5,-0.7c91.9,0 251.9,23.7 370.4,177.8V31.9zM296.3,660.1c0,216.3 136.3,397.1 346.7,397.1c183.7,0 352.6,-133.3 352.6,-394.1c0,-272.6 -168.9,-403 -352.6,-403C432.6,260.1 296.3,437.9 296.3,660.1zM1614.1,31.9h290.4v112.6c32.6,-38.5 80,-80 121.5,-103.7c56.3,-32.6 112.6,-41.5 177.8,-41.5c71.1,0 148.2,11.9 228.2,59.3l-118.5,263.7c-65.2,-41.5 -118.5,-44.4 -148.2,-44.4c-62.2,0 -124.5,8.9 -180.8,68.2c-80,85.9 -80,204.5 -80,287.4v646h-290.4V31.9zM2619.5,31.9h290.4v115.6C3007.7,29 3123.3,-0.7 3218.1,-0.7c130.4,0 251.9,62.2 323,183.7C3644.8,34.9 3801.9,-0.7 3911.5,-0.7c151.1,0 284.5,71.1 355.6,195.6c23.7,41.5 65.2,133.3 65.2,314.1v770.5h-290.4V592c0,-139.3 -14.8,-195.6 -26.7,-222.2c-17.8,-47.4 -62.2,-109.6 -165.9,-109.6c-71.1,0 -133.3,38.5 -171.9,91.9c-50.4,71.1 -56.3,177.8 -56.3,284.5v643h-290.4V592c0,-139.3 -14.8,-195.6 -26.7,-222.2c-17.8,-47.4 -62.2,-109.6 -166,-109.6c-71.1,0 -133.3,38.5 -171.9,91.9c-50.4,71.1 -56.3,177.8 -56.3,284.5v643h-290.4V31.9z"/>
</group>
</vector>
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="95dp"
android:height="88dp"
android:viewportWidth="95"
android:viewportHeight="88">
<path
android:pathData="M47.21,76.5a34.75,34.75 0,1 0,0 -69.5,34.75 34.75,0 0,0 0,69.5Z"
android:fillColor="#FFD21E"/>
<path
android:pathData="M81.96,41.75a34.75,34.75 0,1 0,-69.5 0,34.75 34.75,0 0,0 69.5,0ZM8.46,41.75a38.75,38.75 0,1 1,77.5 0,38.75 38.75,0 0,1 -77.5,0Z"
android:fillColor="#FF9D0B"/>
<path
android:pathData="M58.5,32.3c1.28,0.44 1.78,3.06 3.07,2.38a5,5 0,1 0,-6.76 -2.07c0.61,1.15 2.55,-0.72 3.7,-0.32ZM34.95,32.3c-1.28,0.44 -1.79,3.06 -3.07,2.38a5,5 0,1 1,6.76 -2.07c-0.61,1.15 -2.56,-0.72 -3.7,-0.32Z"
android:fillColor="#3A3B45"/>
<path
android:pathData="M46.96,56.29c9.83,0 13,-8.76 13,-13.26 0,-2.34 -1.57,-1.6 -4.09,-0.36 -2.33,1.15 -5.46,2.74 -8.9,2.74 -7.19,0 -13,-6.88 -13,-2.38s3.16,13.26 13,13.26Z"
android:fillColor="#FF323D"/>
<path
android:pathData="M39.43,54a8.7,8.7 0,0 1,5.3 -4.49c0.4,-0.12 0.81,0.57 1.24,1.28 0.4,0.68 0.82,1.37 1.24,1.37 0.45,0 0.9,-0.68 1.33,-1.35 0.45,-0.7 0.89,-1.38 1.32,-1.25a8.61,8.61 0,0 1,5 4.17c3.73,-2.94 5.1,-7.74 5.1,-10.7 0,-2.34 -1.57,-1.6 -4.09,-0.36l-0.14,0.07c-2.31,1.15 -5.39,2.67 -8.77,2.67s-6.45,-1.52 -8.77,-2.67c-2.6,-1.29 -4.23,-2.1 -4.23,0.29 0,3.05 1.46,8.06 5.47,10.97Z"
android:fillColor="#3A3B45"
android:fillType="evenOdd"/>
<path
android:pathData="M70.71,37a3.25,3.25 0,1 0,0 -6.5,3.25 3.25,0 0,0 0,6.5ZM24.21,37a3.25,3.25 0,1 0,0 -6.5,3.25 3.25,0 0,0 0,6.5ZM17.52,48c-1.62,0 -3.06,0.66 -4.07,1.87a5.97,5.97 0,0 0,-1.33 3.76,7.1 7.1,0 0,0 -1.94,-0.3c-1.55,0 -2.95,0.59 -3.94,1.66a5.8,5.8 0,0 0,-0.8 7,5.3 5.3,0 0,0 -1.79,2.82c-0.24,0.9 -0.48,2.8 0.8,4.74a5.22,5.22 0,0 0,-0.37 5.02c1.02,2.32 3.57,4.14 8.52,6.1 3.07,1.22 5.89,2 5.91,2.01a44.33,44.33 0,0 0,10.93 1.6c5.86,0 10.05,-1.8 12.46,-5.34 3.88,-5.69 3.33,-10.9 -1.7,-15.92 -2.77,-2.78 -4.62,-6.87 -5,-7.77 -0.78,-2.66 -2.84,-5.62 -6.25,-5.62a5.7,5.7 0,0 0,-4.6 2.46c-1,-1.26 -1.98,-2.25 -2.86,-2.82A7.4,7.4 0,0 0,17.52 48ZM17.52,52c0.51,0 1.14,0.22 1.82,0.65 2.14,1.36 6.25,8.43 7.76,11.18 0.5,0.92 1.37,1.31 2.14,1.31 1.55,0 2.75,-1.53 0.15,-3.48 -3.92,-2.93 -2.55,-7.72 -0.68,-8.01 0.08,-0.02 0.17,-0.02 0.24,-0.02 1.7,0 2.45,2.93 2.45,2.93s2.2,5.52 5.98,9.3c3.77,3.77 3.97,6.8 1.22,10.83 -1.88,2.75 -5.47,3.58 -9.16,3.58 -3.81,0 -7.73,-0.9 -9.92,-1.46 -0.11,-0.03 -13.45,-3.8 -11.76,-7 0.28,-0.54 0.75,-0.76 1.34,-0.76 2.38,0 6.7,3.54 8.57,3.54 0.41,0 0.7,-0.17 0.83,-0.6 0.79,-2.85 -12.06,-4.05 -10.98,-8.17 0.2,-0.73 0.71,-1.02 1.44,-1.02 3.14,0 10.2,5.53 11.68,5.53 0.11,0 0.2,-0.03 0.24,-0.1 0.74,-1.2 0.33,-2.04 -4.9,-5.2 -5.21,-3.16 -8.88,-5.06 -6.8,-7.33 0.24,-0.26 0.58,-0.38 1,-0.38 3.17,0 10.66,6.82 10.66,6.82s2.02,2.1 3.25,2.1c0.28,0 0.52,-0.1 0.68,-0.38 0.86,-1.46 -8.06,-8.22 -8.56,-11.01 -0.34,-1.9 0.24,-2.85 1.31,-2.85Z"
android:fillColor="#FF9D0B"/>
<path
android:pathData="M38.6,76.69c2.75,-4.04 2.55,-7.07 -1.22,-10.84 -3.78,-3.77 -5.98,-9.3 -5.98,-9.3s-0.82,-3.2 -2.69,-2.9c-1.87,0.3 -3.24,5.08 0.68,8.01 3.91,2.93 -0.78,4.92 -2.29,2.17 -1.5,-2.75 -5.62,-9.82 -7.76,-11.18 -2.13,-1.35 -3.63,-0.6 -3.13,2.2 0.5,2.79 9.43,9.55 8.56,11 -0.87,1.47 -3.93,-1.71 -3.93,-1.71s-9.57,-8.71 -11.66,-6.44c-2.08,2.27 1.59,4.17 6.8,7.33 5.23,3.16 5.64,4 4.9,5.2 -0.75,1.2 -12.28,-8.53 -13.36,-4.4 -1.08,4.11 11.77,5.3 10.98,8.15 -0.8,2.85 -9.06,-5.38 -10.74,-2.18 -1.7,3.21 11.65,6.98 11.76,7.01 4.3,1.12 15.25,3.49 19.08,-2.12Z"
android:fillColor="#FFD21E"/>
<path
android:pathData="M77.4,48c1.62,0 3.07,0.66 4.07,1.87a5.97,5.97 0,0 1,1.33 3.76,7.1 7.1,0 0,1 1.95,-0.3c1.55,0 2.95,0.59 3.94,1.66a5.8,5.8 0,0 1,0.8 7,5.3 5.3,0 0,1 1.78,2.82c0.24,0.9 0.48,2.8 -0.8,4.74a5.22,5.22 0,0 1,0.37 5.02c-1.02,2.32 -3.57,4.14 -8.51,6.1 -3.08,1.22 -5.9,2 -5.92,2.01a44.33,44.33 0,0 1,-10.93 1.6c-5.86,0 -10.05,-1.8 -12.46,-5.34 -3.88,-5.69 -3.33,-10.9 1.7,-15.92 2.78,-2.78 4.63,-6.87 5.01,-7.77 0.78,-2.66 2.83,-5.62 6.24,-5.62a5.7,5.7 0,0 1,4.6 2.46c1,-1.26 1.98,-2.25 2.87,-2.82A7.4,7.4 0,0 1,77.4 48ZM77.4,52c-0.51,0 -1.13,0.22 -1.82,0.65 -2.13,1.36 -6.25,8.43 -7.76,11.18a2.43,2.43 0,0 1,-2.14 1.31c-1.54,0 -2.75,-1.53 -0.14,-3.48 3.91,-2.93 2.54,-7.72 0.67,-8.01a1.54,1.54 0,0 0,-0.24 -0.02c-1.7,0 -2.45,2.93 -2.45,2.93s-2.2,5.52 -5.97,9.3c-3.78,3.77 -3.98,6.8 -1.22,10.83 1.87,2.75 5.47,3.58 9.15,3.58 3.82,0 7.73,-0.9 9.93,-1.46 0.1,-0.03 13.45,-3.8 11.76,-7 -0.29,-0.54 -0.75,-0.76 -1.34,-0.76 -2.38,0 -6.71,3.54 -8.57,3.54 -0.42,0 -0.71,-0.17 -0.83,-0.6 -0.8,-2.85 12.05,-4.05 10.97,-8.17 -0.19,-0.73 -0.7,-1.02 -1.44,-1.02 -3.14,0 -10.2,5.53 -11.68,5.53 -0.1,0 -0.19,-0.03 -0.23,-0.1 -0.74,-1.2 -0.34,-2.04 4.88,-5.2 5.23,-3.16 8.9,-5.06 6.8,-7.33 -0.23,-0.26 -0.57,-0.38 -0.98,-0.38 -3.18,0 -10.67,6.82 -10.67,6.82s-2.02,2.1 -3.24,2.1a0.74,0.74 0,0 1,-0.68 -0.38c-0.87,-1.46 8.05,-8.22 8.55,-11.01 0.34,-1.9 -0.24,-2.85 -1.31,-2.85Z"
android:fillColor="#FF9D0B"/>
<path
android:pathData="M56.33,76.69c-2.75,-4.04 -2.56,-7.07 1.22,-10.84 3.77,-3.77 5.97,-9.3 5.97,-9.3s0.82,-3.2 2.7,-2.9c1.86,0.3 3.23,5.08 -0.68,8.01 -3.92,2.93 0.78,4.92 2.28,2.17 1.51,-2.75 5.63,-9.82 7.76,-11.18 2.13,-1.35 3.64,-0.6 3.13,2.2 -0.5,2.79 -9.42,9.55 -8.55,11 0.86,1.47 3.92,-1.71 3.92,-1.71s9.58,-8.71 11.66,-6.44c2.08,2.27 -1.58,4.17 -6.8,7.33 -5.23,3.16 -5.63,4 -4.9,5.2 0.75,1.2 12.28,-8.53 13.36,-4.4 1.08,4.11 -11.76,5.3 -10.97,8.15 0.8,2.85 9.05,-5.38 10.74,-2.18 1.69,3.21 -11.65,6.98 -11.76,7.01 -4.31,1.12 -15.26,3.49 -19.08,-2.12Z"
android:fillColor="#FFD21E"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M4.01,6.03l7.51,3.22 -7.52,-1 0.01,-2.22m7.5,8.72L4,17.97v-2.22l7.51,-1M2.01,3L2,10l15,2 -15,2 0.01,7L23,12 2.01,3z"/>
</vector>

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_height="match_parent"
android:layout_width="match_parent">
<LinearLayout
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tier"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="16dp"
android:layout_gravity="center"
android:maxLines="3"
android:text="Checking your device's CPU features"
style="@style/TextAppearance.MaterialComponents.Body1" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/pick_model"
style="@style/Widget.Material3.FloatingActionButton.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:src="@drawable/outline_folder_open_24" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fadeScrollbars="false">
<TextView
android:id="@+id/gguf"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:text="Selected GGUF model's metadata will show here."
style="@style/TextAppearance.MaterialComponents.Body2"
android:maxLines="100" />
</ScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/messages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="4"
android:padding="16dp"
android:fadeScrollbars="false"
app:reverseLayout="true"
tools:listitem="@layout/item_message_assistant"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<EditText
android:id="@+id/user_input"
android:enabled="false"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:padding="8dp"
style="@style/TextAppearance.MaterialComponents.Body2"
android:hint="Please first pick a GGUF model file to import." />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/user_send"
android:enabled="false"
style="@style/Widget.Material3.FloatingActionButton.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:src="@drawable/outline_send_24" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:gravity="start">
<TextView
android:id="@+id/msg_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_assistant_message"
android:padding="12dp"
android:textColor="@android:color/black" />
</LinearLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:gravity="end">
<TextView
android:id="@+id/msg_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_user_message"
android:padding="12dp"
android:textColor="@android:color/white" />
</LinearLayout>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -3,4 +3,4 @@
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Some files were not shown because too many files have changed in this diff Show More