Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ import java.io.ByteArrayOutputStream
@Composable
fun AddItemSheet(
isSaving: Boolean,
hasApiKey: Boolean,
isAiAvailable: Boolean,
existingItem: ClothingItem? = null,
onSave: (
imageBytes: ByteArray, name: String, category: Category,
Expand All @@ -106,7 +106,7 @@ fun AddItemSheet(
) {
AddItemForm(
isSaving = isSaving,
hasApiKey = hasApiKey,
isAiAvailable = isAiAvailable,
existingItem = existingItem,
onSave = onSave,
)
Expand All @@ -116,7 +116,7 @@ fun AddItemSheet(
@Composable
internal fun AddItemForm(
isSaving: Boolean = false,
hasApiKey: Boolean = false,
isAiAvailable: Boolean = false,
existingItem: ClothingItem? = null,
onSave: (ByteArray, String, Category, List<String>, List<Season>, Subcategory?, Fit?, Material?) -> Unit =
{ _, _, _, _, _, _, _, _ -> },
Expand Down Expand Up @@ -215,7 +215,7 @@ internal fun AddItemForm(
canSave = formState.hasPhoto && formState.name.isNotBlank() && formState.selectedCategory != null,
isEditing = existingItem != null,
onPhotoClick = { formState.showSourceChooser = true },
onAiBadgeClick = { if (!hasApiKey) formState.showAiLockedSheet = true },
onAiBadgeClick = { if (!isAiAvailable) formState.showAiLockedSheet = true },
onSave = {
val cat = formState.selectedCategory ?: return@AddItemFormContent
val bytes = formState.photoBytes ?: ByteArray(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ fun GapsScreen(onTabSelected: (Tab) -> Unit) {
val gap = addItemPreFill!!
AddItemSheet(
isSaving = false,
hasApiKey = state.hasApiKey,
isAiAvailable = state.isAiAvailable,
existingItem = gap.toPreFilledItem(),
onSave = { _, _, _, _, _, _, _, _ -> showAddItemSheet = false },
onDismiss = { showAddItemSheet = false },
Expand Down Expand Up @@ -613,7 +613,7 @@ private fun GapsScreenPhonePreview() {
state = GapsState(
recommendations = com.github.worn.domain.model.capsuleWardrobeSuggestions.take(6),
isAiMode = false,
hasApiKey = false,
isAiAvailable = false,
),
)
}
Expand All @@ -627,7 +627,7 @@ private fun GapsScreenTabletPreview() {
state = GapsState(
recommendations = com.github.worn.domain.model.capsuleWardrobeSuggestions.take(6),
isAiMode = true,
hasApiKey = true,
isAiAvailable = true,
),
isCompact = false,
)
Expand All @@ -650,7 +650,7 @@ private fun GapsScreenErrorPreview() {
state = GapsState(
error = "Invalid API key. Check your key in Settings.",
isAiMode = true,
hasApiKey = true,
isAiAvailable = true,
),
)
}
Expand Down
105 changes: 103 additions & 2 deletions composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Checkroom
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
Expand All @@ -34,6 +35,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
Expand All @@ -58,6 +61,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.annotation.StringRes
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
Expand All @@ -69,8 +73,11 @@ import com.github.worn.domain.model.AgeRange
import com.github.worn.domain.model.BodyType
import com.github.worn.domain.model.Climate
import com.github.worn.domain.model.Lifestyle
import com.github.worn.domain.model.OnDeviceAiAvailability
import com.github.worn.domain.model.OnDeviceAiUnavailableReason
import com.github.worn.domain.model.StyleProfile
import com.github.worn.domain.model.UserProfile
import com.github.worn.domain.model.isUsable
import com.github.worn.presentation.viewmodel.SettingsEffect
import com.github.worn.presentation.viewmodel.SettingsIntent
import com.github.worn.presentation.viewmodel.SettingsState
Expand Down Expand Up @@ -111,6 +118,7 @@ fun SettingsScreen(onTabSelected: (Tab) -> Unit) {
onProfileClick = { showProfileSheet = true },
onApiKeyClick = { showApiKeySheet = true },
onYouCamClick = { showYouCamSheet = true },
onOnDeviceAiChange = { viewModel.onIntent(SettingsIntent.SetOnDeviceAi(it)) },
)

if (showProfileSheet) {
Expand Down Expand Up @@ -161,6 +169,7 @@ private fun SettingsScaffold(
onProfileClick: () -> Unit = {},
onApiKeyClick: () -> Unit = {},
onYouCamClick: () -> Unit = {},
onOnDeviceAiChange: (Boolean) -> Unit = {},
) {
val contentPadding = if (isCompact) 24.dp else 32.dp

Expand Down Expand Up @@ -198,6 +207,17 @@ private fun SettingsScaffold(
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.settings_section_ai))
Spacer(Modifier.height(10.dp))
// Listed before the key card: it is free and private, so it should be the first option.
SettingsToggleCard(
icon = { SettingsIcon(color = WornColors.AccentGreen, icon = Icons.Outlined.PhoneAndroid) },
title = stringResource(R.string.settings_on_device_ai_title),
subtitle = stringResource(state.onDeviceAiAvailability.subtitleRes()),
checked = state.onDeviceAiEnabled,
enabled = state.onDeviceAiAvailability.isUsable,
onCheckedChange = onOnDeviceAiChange,
modifier = Modifier.testTag("settings_on_device_ai_toggle"),
)
Spacer(Modifier.height(10.dp))
SettingsCard(
icon = { SettingsIcon(color = WornColors.AccentIndigo, icon = Icons.Outlined.AutoAwesome) },
title = stringResource(R.string.settings_api_key_title),
Expand Down Expand Up @@ -260,6 +280,67 @@ private fun SettingsIcon(color: Color, icon: androidx.compose.ui.graphics.vector
}
}

/**
* A [SettingsCard] whose trailing affordance is a switch instead of a chevron. The whole row is
* tappable so the target matches the other cards, and the switch is disabled — rather than hidden —
* when the capability is missing, so the subtitle can explain why.
*/
@Composable
private fun SettingsToggleCard(
icon: @Composable () -> Unit,
title: String,
subtitle: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = { onCheckedChange(!checked) },
enabled = enabled,
shape = RoundedCornerShape(16.dp),
color = WornColors.BgCard,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
icon()
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, color = WornColors.TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(subtitle, color = WornColors.TextSecondary, fontSize = 13.sp)
}
Spacer(Modifier.width(8.dp))
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = WornColors.AccentGreen,
),
)
}
}
}

@StringRes
private fun OnDeviceAiAvailability.subtitleRes(): Int = when (this) {
OnDeviceAiAvailability.Available -> R.string.settings_on_device_ai_available
OnDeviceAiAvailability.Downloadable -> R.string.settings_on_device_ai_downloading
is OnDeviceAiAvailability.Unavailable -> when (reason) {
OnDeviceAiUnavailableReason.UNSUPPORTED_DEVICE ->
R.string.settings_on_device_ai_unsupported_device
OnDeviceAiUnavailableReason.UNSUPPORTED_OS ->
R.string.settings_on_device_ai_unsupported_os
OnDeviceAiUnavailableReason.DISABLED_BY_USER ->
R.string.settings_on_device_ai_disabled_by_user
OnDeviceAiUnavailableReason.UNKNOWN -> R.string.settings_on_device_ai_unavailable
}
}

@Composable
private fun SettingsCard(
icon: @Composable () -> Unit,
Expand Down Expand Up @@ -919,14 +1000,34 @@ private const val LICENSE_URL = "https://github.com/jvsena42/worn/blob/main/LICE
@Composable
private fun SettingsScreenPhonePreview() {
WornTheme {
SettingsScaffold(state = SettingsState())
SettingsScaffold(
state = SettingsState(
onDeviceAiEnabled = true,
onDeviceAiAvailability = OnDeviceAiAvailability.Available,
),
)
}
}

@Preview(showSystemUi = true, device = "id:pixel_tablet")
@Composable
private fun SettingsScreenTabletPreview() {
WornTheme {
SettingsScaffold(state = SettingsState(), isCompact = false)
SettingsScaffold(
state = SettingsState(
onDeviceAiEnabled = true,
onDeviceAiAvailability = OnDeviceAiAvailability.Available,
),
isCompact = false,
)
}
}

/** The default [SettingsState] already reports on-device AI as unavailable. */
@Preview(showSystemUi = true, device = "id:pixel_8")
@Composable
private fun SettingsScreenOnDeviceAiUnavailablePreview() {
WornTheme {
SettingsScaffold(state = SettingsState())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private fun WardrobeAddItemSheet(
) {
AddItemSheet(
isSaving = state.isSaving,
hasApiKey = state.hasApiKey,
isAiAvailable = state.isAiAvailable,
existingItem = editItem,
onSave = { imageBytes, name, category, colors, seasons, subcategory, fit, material ->
if (editItem != null) {
Expand Down
9 changes: 8 additions & 1 deletion composeApp/src/main/res/values-pt-rBR/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@
<string name="settings_your_profile">Seu Perfil</string>
<string name="settings_profile_subtitle_empty">Toque para configurar</string>
<string name="settings_profile_help">Ajude a IA a dar melhores sugestões</string>
<string name="settings_on_device_ai_title">IA no dispositivo</string>
<string name="settings_on_device_ai_available">Roda de forma privada neste aparelho, sem chave</string>
<string name="settings_on_device_ai_downloading">O modelo é baixado no primeiro uso</string>
<string name="settings_on_device_ai_unsupported_device">Não compatível com este aparelho</string>
<string name="settings_on_device_ai_unsupported_os">Requer uma versão mais recente do Android</string>
<string name="settings_on_device_ai_disabled_by_user">Ative o Gemini Nano nos ajustes do sistema</string>
<string name="settings_on_device_ai_unavailable">Indisponível no momento</string>
<string name="settings_api_key_title">Chave da API Claude</string>
<string name="settings_api_key_connected">Conectado</string>
<string name="settings_api_key_required">Necessária para funções de IA</string>
Expand Down Expand Up @@ -173,7 +180,7 @@

<!-- AI Locked Sheet -->
<string name="ai_locked_title">Desbloquear funções de IA</string>
<string name="ai_locked_description">Adicione sua chave da API Claude nos Ajustes para habilitar.</string>
<string name="ai_locked_description">Ative a IA no dispositivo ou adicione sua chave da API Claude nos Ajustes.</string>
<string name="ai_locked_cta">Ir para Ajustes</string>

<!-- Filter -->
Expand Down
9 changes: 8 additions & 1 deletion composeApp/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@
<string name="settings_your_profile">Your Profile</string>
<string name="settings_profile_subtitle_empty">Tap to set up</string>
<string name="settings_profile_help">Help AI give better suggestions</string>
<string name="settings_on_device_ai_title">On-device AI</string>
<string name="settings_on_device_ai_available">Runs privately on this device, no key needed</string>
<string name="settings_on_device_ai_downloading">The model downloads the first time you use it</string>
<string name="settings_on_device_ai_unsupported_device">Not supported by this device</string>
<string name="settings_on_device_ai_unsupported_os">Needs a newer Android version</string>
<string name="settings_on_device_ai_disabled_by_user">Turn on Gemini Nano in system settings</string>
<string name="settings_on_device_ai_unavailable">Unavailable right now</string>
<string name="settings_api_key_title">Claude API Key</string>
<string name="settings_api_key_connected">Connected</string>
<string name="settings_api_key_required">Required for AI features</string>
Expand Down Expand Up @@ -173,7 +180,7 @@

<!-- AI Locked Sheet -->
<string name="ai_locked_title">Unlock AI features</string>
<string name="ai_locked_description">Add your Claude API key in Settings to enable this.</string>
<string name="ai_locked_description">Turn on on-device AI or add your Claude API key in Settings.</string>
<string name="ai_locked_cta">Go to Settings</string>

<!-- Filter -->
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ turbine = "1.2.1"
mockk = "1.14.11"
datastore = "1.2.1"
splashscreen = "1.2.0"
mlkitGenaiPrompt = "1.0.0-beta2"
mlkitSubjectSegmentation = "16.0.0-beta1"

[libraries]
Expand Down Expand Up @@ -64,6 +65,7 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }
mlkit-genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "mlkitGenaiPrompt" }
mlkit-subject-segmentation = { module = "com.google.android.gms:play-services-mlkit-subject-segmentation", version.ref = "mlkitSubjectSegmentation" }

[plugins]
Expand Down
7 changes: 6 additions & 1 deletion iosApp/Configuration/Config.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@ PRODUCT_NAME=Worn
PRODUCT_BUNDLE_IDENTIFIER=com.github.worn.Worn$(TEAM_ID)

CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
MARKETING_VERSION=1.0

// FoundationModels (Apple Intelligence) only exists from iOS 26, but the app deploys back to
// 18.2. Weak linking lets it launch on older systems, where OnDeviceAiService reports the
// feature as unavailable via its #available guard.
OTHER_LDFLAGS=$(inherited) -weak_framework FoundationModels
8 changes: 4 additions & 4 deletions iosApp/iosApp/Screens/AddItemSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Shared

struct AddItemSheet: View {
let isSaving: Bool
let hasApiKey: Bool
let isAiAvailable: Bool
var existingItem: ClothingItem?
let onSave: (Data, String, Category, [String], [Season], Subcategory?, Fit?, Material?) -> Void
let onDismiss: () -> Void
Expand Down Expand Up @@ -238,7 +238,7 @@ struct AddItemSheet: View {

private var aiBadge: some View {
Button {
if !hasApiKey { showAiLockedSheet = true }
if !isAiAvailable { showAiLockedSheet = true }
} label: {
HStack(spacing: 6) {
Text("✦")
Expand Down Expand Up @@ -565,10 +565,10 @@ struct AddItemSheet: View {
}

#Preview("iPhone") {
AddItemSheet(isSaving: false, hasApiKey: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {})
AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {})
}

#Preview("iPad Portrait") {
AddItemSheet(isSaving: false, hasApiKey: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {})
AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {})
.previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)"))
}
2 changes: 1 addition & 1 deletion iosApp/iosApp/Screens/GapsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ struct GapsScreen: View {
if let gap = addItemPreFill {
AddItemSheet(
isSaving: false,
hasApiKey: viewModel.state.hasApiKey,
isAiAvailable: viewModel.state.isAiAvailable,
existingItem: gap.toPreFilledItem(),
onSave: { _, _, _, _, _, _, _, _ in showAddItemSheet = false },
onDismiss: { showAddItemSheet = false }
Expand Down
Loading
Loading