@@ -518,81 +518,130 @@ private fun ModelSelectorDialog(
518518 onDownloadModel : (AiModel ) -> Unit ,
519519 onDismiss : () -> Unit
520520) {
521+ // Categorize models
522+ val builtInModels = availableModels.filter { it == AiModel .RULE_BASED }
523+ val googleAiModels = availableModels.filter { it == AiModel .GEMINI_NANO }
524+ val downloadableModels = availableModels.filter {
525+ it != AiModel .RULE_BASED && it != AiModel .GEMINI_NANO
526+ }
527+
521528 AlertDialog (
522529 onDismissRequest = onDismiss,
523- title = { Text (" Select AI Model" ) },
530+ title = {
531+ Column {
532+ Text (" Select AI Engine" )
533+ Text (
534+ text = " Choose how detection analysis is performed" ,
535+ style = MaterialTheme .typography.bodySmall,
536+ color = MaterialTheme .colorScheme.onSurfaceVariant
537+ )
538+ }
539+ },
524540 text = {
525541 LazyColumn (
526542 verticalArrangement = Arrangement .spacedBy(8 .dp)
527543 ) {
528- items(availableModels) { model ->
529- val isSelected = model.id == currentModelId
530- val isDownloaded = model == AiModel .RULE_BASED || model == AiModel .GEMINI_NANO // Simplified check
544+ // Built-in section
545+ if (builtInModels.isNotEmpty()) {
546+ item {
547+ EngineCategoryHeader (
548+ title = " Built-in" ,
549+ subtitle = " No download required" ,
550+ icon = Icons .Default .CheckCircle
551+ )
552+ }
553+ items(builtInModels) { model ->
554+ EngineOptionCard (
555+ model = model,
556+ isSelected = model.id == currentModelId,
557+ isRecommended = true ,
558+ recommendedReason = " Works everywhere" ,
559+ onSelect = { onSelectModel(model) }
560+ )
561+ }
562+ }
531563
532- Card (
533- modifier = Modifier
534- .fillMaxWidth()
535- .clickable {
536- if (isDownloaded || model == AiModel .RULE_BASED ) {
537- onSelectModel(model)
538- } else {
539- onDownloadModel(model)
540- }
541- },
542- colors = CardDefaults .cardColors(
543- containerColor = if (isSelected)
544- MaterialTheme .colorScheme.primaryContainer
545- else
546- MaterialTheme .colorScheme.surfaceVariant
547- ),
548- border = if (isSelected) BorderStroke (2 .dp, MaterialTheme .colorScheme.primary) else null
549- ) {
550- Column (modifier = Modifier .padding(12 .dp)) {
551- Row (
552- verticalAlignment = Alignment .CenterVertically
553- ) {
554- Column (modifier = Modifier .weight(1f )) {
555- Row (verticalAlignment = Alignment .CenterVertically ) {
556- Text (
557- text = model.displayName,
558- style = MaterialTheme .typography.titleSmall,
559- fontWeight = FontWeight .Medium
560- )
561- if (model.requiresNpu) {
562- Spacer (modifier = Modifier .width(4 .dp))
563- SuggestionChip (
564- onClick = { },
565- label = { Text (" NPU" , style = MaterialTheme .typography.labelSmall) }
566- )
567- }
568- }
569- if (model.sizeMb > 0 ) {
570- Text (
571- text = " ${model.sizeMb} MB • ${model.quantization} " ,
572- style = MaterialTheme .typography.labelSmall,
573- color = MaterialTheme .colorScheme.onSurfaceVariant
574- )
575- }
576- }
577- if (isSelected) {
578- Icon (
579- imageVector = Icons .Default .CheckCircle ,
580- contentDescription = " Selected" ,
581- tint = MaterialTheme .colorScheme.primary
582- )
583- } else if (model.sizeMb > 0 && model != AiModel .GEMINI_NANO ) {
584- Icon (
585- imageVector = Icons .Default .Download ,
586- contentDescription = " Download required" ,
587- tint = MaterialTheme .colorScheme.onSurfaceVariant
588- )
589- }
590- }
564+ // Google AI section (Gemini Nano)
565+ if (googleAiModels.isNotEmpty()) {
566+ item {
567+ Spacer (modifier = Modifier .height(8 .dp))
568+ EngineCategoryHeader (
569+ title = " Google AI" ,
570+ subtitle = " Powered by Gemini Nano via AICore" ,
571+ icon = Icons .Default .AutoAwesome
572+ )
573+ }
574+ items(googleAiModels) { model ->
575+ val hasAiCore = deviceCapabilities?.hasAiCore == true
576+ EngineOptionCard (
577+ model = model,
578+ isSelected = model.id == currentModelId,
579+ isRecommended = hasAiCore,
580+ recommendedReason = if (hasAiCore) " Best for Pixel 8+" else null ,
581+ isAvailable = hasAiCore,
582+ unavailableReason = if (! hasAiCore) " Requires Pixel 8+ with AICore" else null ,
583+ onSelect = { if (hasAiCore) onSelectModel(model) }
584+ )
585+ }
586+ }
587+
588+ // Downloadable GGUF Models section
589+ if (downloadableModels.isNotEmpty()) {
590+ item {
591+ Spacer (modifier = Modifier .height(8 .dp))
592+ EngineCategoryHeader (
593+ title = " Downloadable Models" ,
594+ subtitle = " GGUF format • Runs on any device" ,
595+ icon = Icons .Default .Download
596+ )
597+ }
598+
599+ // Small models (< 500MB)
600+ val smallModels = downloadableModels.filter { it.sizeMb < 500 }
601+ val largeModels = downloadableModels.filter { it.sizeMb >= 500 }
602+
603+ if (smallModels.isNotEmpty()) {
604+ item {
605+ Text (
606+ text = " Lightweight (< 500 MB)" ,
607+ style = MaterialTheme .typography.labelSmall,
608+ color = MaterialTheme .colorScheme.primary,
609+ modifier = Modifier .padding(start = 4 .dp, top = 4 .dp)
610+ )
611+ }
612+ items(smallModels) { model ->
613+ EngineOptionCard (
614+ model = model,
615+ isSelected = model.id == currentModelId,
616+ isRecommended = model == AiModel .SMOLLM_360M ,
617+ recommendedReason = if (model == AiModel .SMOLLM_360M ) " Best balance" else null ,
618+ showDownloadIcon = true ,
619+ onSelect = { onDownloadModel(model) }
620+ )
621+ }
622+ }
623+
624+ if (largeModels.isNotEmpty()) {
625+ item {
591626 Spacer (modifier = Modifier .height(4 .dp))
592627 Text (
593- text = model.description,
594- style = MaterialTheme .typography.bodySmall,
595- color = MaterialTheme .colorScheme.onSurfaceVariant
628+ text = " Full-Size (500+ MB)" ,
629+ style = MaterialTheme .typography.labelSmall,
630+ color = MaterialTheme .colorScheme.primary,
631+ modifier = Modifier .padding(start = 4 .dp, top = 4 .dp)
632+ )
633+ }
634+ items(largeModels) { model ->
635+ val hasEnoughRam = (deviceCapabilities?.availableRamMb ? : 0 ) > model.sizeMb * 1.5
636+ EngineOptionCard (
637+ model = model,
638+ isSelected = model.id == currentModelId,
639+ isRecommended = model == AiModel .GEMMA2_2B && hasEnoughRam,
640+ recommendedReason = if (model == AiModel .GEMMA2_2B && hasEnoughRam) " Best quality" else null ,
641+ isAvailable = hasEnoughRam,
642+ unavailableReason = if (! hasEnoughRam) " Needs ${(model.sizeMb * 1.5 ).toInt()} MB RAM" else null ,
643+ showDownloadIcon = true ,
644+ onSelect = { if (hasEnoughRam) onDownloadModel(model) }
596645 )
597646 }
598647 }
@@ -607,6 +656,174 @@ private fun ModelSelectorDialog(
607656 )
608657}
609658
659+ @Composable
660+ private fun EngineCategoryHeader (
661+ title : String ,
662+ subtitle : String ,
663+ icon : androidx.compose.ui.graphics.vector.ImageVector
664+ ) {
665+ Row (
666+ modifier = Modifier
667+ .fillMaxWidth()
668+ .padding(vertical = 4 .dp),
669+ verticalAlignment = Alignment .CenterVertically
670+ ) {
671+ Icon (
672+ imageVector = icon,
673+ contentDescription = null ,
674+ tint = MaterialTheme .colorScheme.primary,
675+ modifier = Modifier .size(20 .dp)
676+ )
677+ Spacer (modifier = Modifier .width(8 .dp))
678+ Column {
679+ Text (
680+ text = title,
681+ style = MaterialTheme .typography.titleSmall,
682+ fontWeight = FontWeight .Bold ,
683+ color = MaterialTheme .colorScheme.primary
684+ )
685+ Text (
686+ text = subtitle,
687+ style = MaterialTheme .typography.labelSmall,
688+ color = MaterialTheme .colorScheme.onSurfaceVariant
689+ )
690+ }
691+ }
692+ }
693+
694+ @OptIn(ExperimentalMaterial3Api ::class )
695+ @Composable
696+ private fun EngineOptionCard (
697+ model : AiModel ,
698+ isSelected : Boolean ,
699+ isRecommended : Boolean = false,
700+ recommendedReason : String? = null,
701+ isAvailable : Boolean = true,
702+ unavailableReason : String? = null,
703+ showDownloadIcon : Boolean = false,
704+ onSelect : () -> Unit
705+ ) {
706+ val alpha = if (isAvailable) 1f else 0.5f
707+
708+ Card (
709+ modifier = Modifier
710+ .fillMaxWidth()
711+ .clickable(enabled = isAvailable) { onSelect() },
712+ colors = CardDefaults .cardColors(
713+ containerColor = when {
714+ isSelected -> MaterialTheme .colorScheme.primaryContainer
715+ isRecommended && isAvailable -> MaterialTheme .colorScheme.tertiaryContainer.copy(alpha = 0.3f )
716+ else -> MaterialTheme .colorScheme.surfaceVariant.copy(alpha = alpha)
717+ }
718+ ),
719+ border = when {
720+ isSelected -> BorderStroke (2 .dp, MaterialTheme .colorScheme.primary)
721+ isRecommended && isAvailable -> BorderStroke (1 .dp, MaterialTheme .colorScheme.tertiary.copy(alpha = 0.5f ))
722+ else -> null
723+ }
724+ ) {
725+ Column (modifier = Modifier .padding(12 .dp)) {
726+ Row (
727+ verticalAlignment = Alignment .CenterVertically
728+ ) {
729+ Column (modifier = Modifier .weight(1f )) {
730+ Row (verticalAlignment = Alignment .CenterVertically ) {
731+ Text (
732+ text = model.displayName,
733+ style = MaterialTheme .typography.titleSmall,
734+ fontWeight = FontWeight .Medium ,
735+ color = MaterialTheme .colorScheme.onSurface.copy(alpha = alpha)
736+ )
737+ if (model.requiresNpu) {
738+ Spacer (modifier = Modifier .width(6 .dp))
739+ Surface (
740+ shape = MaterialTheme .shapes.small,
741+ color = MaterialTheme .colorScheme.tertiary.copy(alpha = 0.2f )
742+ ) {
743+ Text (
744+ text = " NPU" ,
745+ style = MaterialTheme .typography.labelSmall,
746+ color = MaterialTheme .colorScheme.tertiary,
747+ modifier = Modifier .padding(horizontal = 6 .dp, vertical = 2 .dp)
748+ )
749+ }
750+ }
751+ if (isRecommended && recommendedReason != null ) {
752+ Spacer (modifier = Modifier .width(6 .dp))
753+ Surface (
754+ shape = MaterialTheme .shapes.small,
755+ color = MaterialTheme .colorScheme.primary.copy(alpha = 0.15f )
756+ ) {
757+ Text (
758+ text = recommendedReason,
759+ style = MaterialTheme .typography.labelSmall,
760+ color = MaterialTheme .colorScheme.primary,
761+ modifier = Modifier .padding(horizontal = 6 .dp, vertical = 2 .dp)
762+ )
763+ }
764+ }
765+ }
766+ if (model.sizeMb > 0 ) {
767+ Text (
768+ text = " ${model.sizeMb} MB • ${model.quantization} " ,
769+ style = MaterialTheme .typography.labelSmall,
770+ color = MaterialTheme .colorScheme.onSurfaceVariant.copy(alpha = alpha)
771+ )
772+ }
773+ }
774+ when {
775+ isSelected -> Icon (
776+ imageVector = Icons .Default .CheckCircle ,
777+ contentDescription = " Selected" ,
778+ tint = MaterialTheme .colorScheme.primary
779+ )
780+ ! isAvailable -> Icon (
781+ imageVector = Icons .Default .Block ,
782+ contentDescription = " Unavailable" ,
783+ tint = MaterialTheme .colorScheme.onSurfaceVariant.copy(alpha = 0.5f )
784+ )
785+ showDownloadIcon -> Icon (
786+ imageVector = Icons .Default .Download ,
787+ contentDescription = " Download required" ,
788+ tint = MaterialTheme .colorScheme.primary
789+ )
790+ }
791+ }
792+
793+ Spacer (modifier = Modifier .height(4 .dp))
794+
795+ Text (
796+ text = if (! isAvailable && unavailableReason != null ) unavailableReason else model.description,
797+ style = MaterialTheme .typography.bodySmall,
798+ color = if (! isAvailable) MaterialTheme .colorScheme.error.copy(alpha = 0.7f )
799+ else MaterialTheme .colorScheme.onSurfaceVariant.copy(alpha = alpha)
800+ )
801+
802+ // Show capabilities for non-rule-based models
803+ if (model != AiModel .RULE_BASED && isAvailable) {
804+ Spacer (modifier = Modifier .height(6 .dp))
805+ Row (
806+ horizontalArrangement = Arrangement .spacedBy(4 .dp)
807+ ) {
808+ model.capabilities.take(3 ).forEach { cap ->
809+ Surface (
810+ shape = MaterialTheme .shapes.extraSmall,
811+ color = MaterialTheme .colorScheme.surfaceVariant
812+ ) {
813+ Text (
814+ text = cap,
815+ style = MaterialTheme .typography.labelSmall,
816+ color = MaterialTheme .colorScheme.onSurfaceVariant,
817+ modifier = Modifier .padding(horizontal = 4 .dp, vertical = 1 .dp)
818+ )
819+ }
820+ }
821+ }
822+ }
823+ }
824+ }
825+ }
826+
610827@Composable
611828private fun CapabilitiesCard (
612829 settings : AiSettings ,
0 commit comments