From 4212bd7b322ed8d02431dfa5cfde787abdf915e6 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Sat, 28 Mar 2026 14:56:34 -0300 Subject: [PATCH 1/2] feat: try it screen --- .../androidMain/kotlin/com/github/worn/App.kt | 3 +- .../github/worn/ui/screen/ItemDetailSheet.kt | 32 +- .../com/github/worn/ui/screen/TryItScreen.kt | 850 ++++++++++++++++++ iosApp/iosApp/Screens/ItemDetailSheet.swift | 5 +- iosApp/iosApp/Screens/TryItScreen.swift | 453 ++++++++++ .../ViewModels/TryItViewModelWrapper.swift | 44 + iosApp/iosApp/iOSApp.swift | 2 + .../kotlin/com/github/worn/di/SharedModule.kt | 2 + .../presentation/viewmodel/TryItViewModel.kt | 74 ++ 9 files changed, 1452 insertions(+), 13 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt create mode 100644 iosApp/iosApp/Screens/TryItScreen.swift create mode 100644 iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift create mode 100644 shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/com/github/worn/App.kt b/composeApp/src/androidMain/kotlin/com/github/worn/App.kt index e5e2b2a..6fa8db6 100644 --- a/composeApp/src/androidMain/kotlin/com/github/worn/App.kt +++ b/composeApp/src/androidMain/kotlin/com/github/worn/App.kt @@ -9,6 +9,7 @@ import com.github.worn.ui.components.Tab import com.github.worn.ui.screen.GapsScreen import com.github.worn.ui.screen.OutfitsScreen import com.github.worn.ui.screen.SettingsScreen +import com.github.worn.ui.screen.TryItScreen import com.github.worn.ui.screen.WardrobeScreen import com.github.worn.ui.theme.WornTheme @@ -21,8 +22,8 @@ fun App() { Tab.WARDROBE -> WardrobeScreen(onTabSelected = { activeTab = it }) Tab.OUTFITS -> OutfitsScreen(onTabSelected = { activeTab = it }) Tab.GAPS -> GapsScreen(onTabSelected = { activeTab = it }) + Tab.TRY_IT -> TryItScreen(onTabSelected = { activeTab = it }) Tab.SETTINGS -> SettingsScreen(onTabSelected = { activeTab = it }) - else -> WardrobeScreen(onTabSelected = { activeTab = it }) } } } diff --git a/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt b/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt index 4adf7c8..102a6b9 100644 --- a/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt +++ b/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt @@ -66,9 +66,10 @@ import java.io.File fun ItemDetailSheet( item: ClothingItem, isCompact: Boolean, - onEdit: (ClothingItem) -> Unit, - onDelete: (String) -> Unit, + onEdit: (ClothingItem) -> Unit = {}, + onDelete: (String) -> Unit = {}, onDismiss: () -> Unit, + showActions: Boolean = true, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -79,7 +80,13 @@ fun ItemDetailSheet( shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), dragHandle = { DetailSheetDragHandle() }, ) { - ItemDetailContent(item = item, isCompact = isCompact, onEdit = onEdit, onDelete = onDelete) + ItemDetailContent( + item = item, + isCompact = isCompact, + onEdit = onEdit, + onDelete = onDelete, + showActions = showActions, + ) } } @@ -105,6 +112,7 @@ internal fun ItemDetailContent( isCompact: Boolean, onEdit: (ClothingItem) -> Unit = {}, onDelete: (String) -> Unit = {}, + showActions: Boolean = true, ) { val dims = ItemDetailDimens(isCompact) var showDeleteDialog by remember { mutableStateOf(false) } @@ -121,14 +129,16 @@ internal fun ItemDetailContent( ItemNameGroup(item = item, nameSize = dims.nameSize) HorizontalDivider() ItemProperties(item = item, fontSize = dims.propFontSize, gap = dims.propGap) - DetailActionButtons( - editLabel = "Edit Item", - deleteLabel = "Delete Item", - buttonHeight = dims.buttonHeight, - buttonFontSize = dims.buttonFontSize, - onEdit = { onEdit(item) }, - onDelete = { showDeleteDialog = true }, - ) + if (showActions) { + DetailActionButtons( + editLabel = "Edit Item", + deleteLabel = "Delete Item", + buttonHeight = dims.buttonHeight, + buttonFontSize = dims.buttonFontSize, + onEdit = { onEdit(item) }, + onDelete = { showDeleteDialog = true }, + ) + } } if (showDeleteDialog) { diff --git a/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt b/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt new file mode 100644 index 0000000..2363c51 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt @@ -0,0 +1,850 @@ +@file:Suppress("TooManyFunctions") + +package com.github.worn.ui.screen + +import android.Manifest +import android.graphics.BitmapFactory +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.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.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Checkroom +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material.icons.outlined.SmartToy +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.window.core.layout.WindowWidthSizeClass +import coil3.compose.AsyncImage +import com.github.worn.domain.model.ClothingItem +import com.github.worn.domain.model.TryItResult +import com.github.worn.presentation.viewmodel.TryItEffect +import com.github.worn.presentation.viewmodel.TryItIntent +import com.github.worn.presentation.viewmodel.TryItState +import com.github.worn.presentation.viewmodel.TryItViewModel +import com.github.worn.ui.components.Tab +import com.github.worn.ui.components.WornBottomBar +import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.WornTheme +import org.koin.compose.viewmodel.koinViewModel +import java.io.ByteArrayOutputStream +import java.io.File + +@Composable +fun TryItScreen(onTabSelected: (Tab) -> Unit) { + val viewModel: TryItViewModel = koinViewModel() + val state by viewModel.state.collectAsState() + val windowInfo = currentWindowAdaptiveInfo() + val isCompact = windowInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT + + var photoBitmap by remember { mutableStateOf(null) } + var photoBytes by remember { mutableStateOf(null) } + var showSourceChooser by remember { mutableStateOf(false) } + var selectedItem by remember { mutableStateOf(null) } + + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is TryItEffect.ShowError -> snackbarHostState.showSnackbar(effect.message) + } + } + } + + PhotoSourceChooser( + show = showSourceChooser, + onDismiss = { showSourceChooser = false }, + onPhoto = { bytes, bitmap -> + photoBytes = bytes + photoBitmap = bitmap + viewModel.onIntent(TryItIntent.Reset) + }, + ) + + TryItScaffold( + state = state, + isCompact = isCompact, + photoBitmap = photoBitmap, + hasPhoto = photoBytes != null, + snackbarHostState = snackbarHostState, + onPhotoClick = { showSourceChooser = true }, + onAnalyze = { photoBytes?.let { viewModel.onIntent(TryItIntent.AnalyzePhoto(it)) } }, + onItemClick = { selectedItem = it }, + onGoToSettings = { onTabSelected(Tab.SETTINGS) }, + onTabSelected = onTabSelected, + ) + + if (selectedItem != null) { + ItemDetailSheet( + item = selectedItem!!, + isCompact = isCompact, + onDismiss = { selectedItem = null }, + showActions = false, + ) + } +} + +@Composable +private fun PhotoSourceChooser( + show: Boolean, + onDismiss: () -> Unit, + onPhoto: (ByteArray, ImageBitmap) -> Unit, +) { + val context = LocalContext.current + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri -> + uri?.let { + val bytes = context.contentResolver.openInputStream(it)?.readBytes() + if (bytes != null) { + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bmp -> + onPhoto(bytes, bmp.asImageBitmap()) + } + } + } + } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicturePreview(), + ) { bitmap -> + bitmap?.let { + val stream = ByteArrayOutputStream() + it.compress(android.graphics.Bitmap.CompressFormat.JPEG, JPEG_QUALITY, stream) + onPhoto(stream.toByteArray(), it.asImageBitmap()) + } + } + + val cameraPermission = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) cameraLauncher.launch(null) + } + + if (show) { + PhotoSourceDialog( + onDismiss = onDismiss, + onCamera = { + onDismiss() + cameraPermission.launch(Manifest.permission.CAMERA) + }, + onGallery = { + onDismiss() + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + ) + } +} + +@Composable +private fun PhotoSourceDialog( + onDismiss: () -> Unit, + onCamera: () -> Unit, + onGallery: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add photo") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = onCamera, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.CameraAlt, contentDescription = null, modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(12.dp)) + Text("Take photo", fontSize = 16.sp) + } + } + TextButton(onClick = onGallery, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Icons.Outlined.PhotoLibrary, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(12.dp)) + Text("Choose from gallery", fontSize = 16.sp) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun TryItScaffold( + state: TryItState, + isCompact: Boolean, + photoBitmap: ImageBitmap?, + hasPhoto: Boolean, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onPhotoClick: () -> Unit = {}, + onAnalyze: () -> Unit = {}, + onItemClick: (ClothingItem) -> Unit = {}, + onGoToSettings: () -> Unit = {}, + onTabSelected: (Tab) -> Unit = {}, +) { + val contentPadding = if (isCompact) 24.dp else 32.dp + + Scaffold( + containerColor = WornColors.BgPage, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + WornBottomBar( + activeTab = Tab.TRY_IT, + onTabSelected = onTabSelected, + isCompact = isCompact, + ) + }, + ) { paddingValues -> + if (!state.hasApiKey) { + AiEmptyContent( + isCompact = isCompact, + onGoToSettings = onGoToSettings, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = contentPadding), + ) + } else { + TryItContent( + state = state, + isCompact = isCompact, + photoBitmap = photoBitmap, + hasPhoto = hasPhoto, + onPhotoClick = onPhotoClick, + onAnalyze = onAnalyze, + onItemClick = onItemClick, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = contentPadding), + ) + } + } +} + +@Composable +private fun AiEmptyContent( + isCompact: Boolean, + onGoToSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + val circleSize = if (isCompact) 130.dp else 150.dp + val iconSize = if (isCompact) 52.dp else 60.dp + val titleSize = if (isCompact) 24.sp else 26.sp + val descWidth = if (isCompact) 280.dp else 380.dp + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Surface( + shape = CircleShape, + color = WornColors.BgCard, + border = BorderStroke(1.dp, WornColors.BorderSubtle), + shadowElevation = 8.dp, + modifier = Modifier.size(circleSize), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + imageVector = Icons.Outlined.SmartToy, + contentDescription = null, + tint = WornColors.AccentIndigo, + modifier = Modifier.size(iconSize), + ) + } + } + Spacer(Modifier.height(24.dp)) + Text( + text = "AI powered analysis", + color = WornColors.TextPrimary, + fontSize = titleSize, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Connect your Claude API key in Settings to analyze items against your wardrobe.", + color = WornColors.TextSecondary, + fontSize = if (isCompact) 15.sp else 16.sp, + lineHeight = if (isCompact) 22.sp else 24.sp, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = descWidth), + ) + Spacer(Modifier.height(24.dp)) + IndigoCtaButton(text = "Connect Claude AI", onClick = onGoToSettings) + } +} + +@Composable +private fun IndigoCtaButton(text: String, onClick: () -> Unit) { + val gradient = Brush.verticalGradient( + listOf(WornColors.AccentIndigo, Color(0xFF556070)), + ) + Button( + onClick = onClick, + shape = RoundedCornerShape(28.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + contentPadding = PaddingValues(), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(gradient, RoundedCornerShape(28.dp)) + .padding(horizontal = 40.dp, vertical = 14.dp), + ) { + Text(text, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +private fun TryItContent( + state: TryItState, + isCompact: Boolean, + photoBitmap: ImageBitmap?, + hasPhoto: Boolean, + onPhotoClick: () -> Unit, + onAnalyze: () -> Unit, + onItemClick: (ClothingItem) -> Unit, + modifier: Modifier = Modifier, +) { + if (isCompact) { + TryItPhoneContent(state, photoBitmap, hasPhoto, onPhotoClick, onAnalyze, onItemClick, modifier) + } else { + TryItTabletContent(state, photoBitmap, hasPhoto, onPhotoClick, onAnalyze, onItemClick, modifier) + } +} + +@Composable +private fun TryItPhoneContent( + state: TryItState, + photoBitmap: ImageBitmap?, + hasPhoto: Boolean, + onPhotoClick: () -> Unit, + onAnalyze: () -> Unit, + onItemClick: (ClothingItem) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Spacer(Modifier.height(4.dp)) + TryItTitle(fontSize = 28.sp) + UploadZone(photoBitmap = photoBitmap, height = 200.dp, onClick = onPhotoClick) + if (hasPhoto && state.result == null && !state.isLoading) { + AnalyzeButton(onClick = onAnalyze) + } + if (state.isLoading) { + LoadingIndicator() + } + state.error?.let { errorMsg -> + if (!state.isLoading) ErrorMessage(errorMsg) + } + state.result?.let { result -> + ResultsSection(result = result, isCompact = true, onItemClick = onItemClick) + } + Spacer(Modifier.height(12.dp)) + } +} + +@Composable +private fun TryItTabletContent( + state: TryItState, + photoBitmap: ImageBitmap?, + hasPhoto: Boolean, + onPhotoClick: () -> Unit, + onAnalyze: () -> Unit, + onItemClick: (ClothingItem) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + Spacer(Modifier.height(4.dp)) + TryItTitle(fontSize = 32.sp) + Spacer(Modifier.height(28.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.weight(1f), + ) { + UploadZone(photoBitmap = photoBitmap, height = 300.dp, onClick = onPhotoClick) + if (hasPhoto && state.result == null && !state.isLoading) { + AnalyzeButton(onClick = onAnalyze) + } + if (state.isLoading) { + LoadingIndicator() + } + state.error?.let { errorMsg -> + if (!state.isLoading) ErrorMessage(errorMsg) + } + state.result?.let { result -> + PairsSection( + matchingItems = result.matchingItems, + thumbSize = 90.dp, + onItemClick = onItemClick, + ) + } + } + state.result?.let { result -> + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.weight(1f), + ) { + CombinationsCard(count = result.combinationsUnlocked, isCompact = false) + GapsFilledSection(gaps = result.gapsFilled, isCompact = false) + DecisionBanner(worthAdding = result.worthAdding, isCompact = false) + } + } + } + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun TryItTitle(fontSize: androidx.compose.ui.unit.TextUnit) { + Text( + text = "Would it fit your wardrobe?", + color = WornColors.TextPrimary, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.8).sp, + ) +} + +@Composable +private fun UploadZone( + photoBitmap: ImageBitmap?, + height: Dp, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(20.dp), + color = WornColors.BgCard, + border = BorderStroke(1.5.dp, WornColors.BorderStrong), + shadowElevation = 1.dp, + modifier = Modifier.fillMaxWidth().height(height), + ) { + if (photoBitmap != null) { + androidx.compose.foundation.Image( + bitmap = photoBitmap, + contentDescription = "Selected photo", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(20.dp)), + ) + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Icons.Outlined.CameraAlt, + contentDescription = null, + tint = WornColors.IconMuted, + modifier = Modifier.size(44.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Upload a photo of the item\nyou're considering", + color = WornColors.TextSecondary, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun AnalyzeButton(onClick: () -> Unit) { + val gradient = Brush.verticalGradient( + listOf(WornColors.AccentIndigo, Color(0xFF556070)), + ) + Button( + onClick = onClick, + shape = RoundedCornerShape(28.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + contentPadding = PaddingValues(), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .background(gradient, RoundedCornerShape(28.dp)) + .padding(vertical = 14.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.SmartToy, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + Text( + "Analyze with Claude", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun LoadingIndicator() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp), + ) { + CircularProgressIndicator(color = WornColors.AccentIndigo) + } +} + +@Composable +private fun ErrorMessage(message: String) { + Surface( + shape = RoundedCornerShape(12.dp), + color = WornColors.DeleteRed.copy(alpha = 0.1f), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = message, + color = WornColors.DeleteRed, + fontSize = 14.sp, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +private fun ResultsSection( + result: TryItResult, + isCompact: Boolean, + onItemClick: (ClothingItem) -> Unit, +) { + val thumbSize = if (isCompact) 80.dp else 90.dp + PairsSection(matchingItems = result.matchingItems, thumbSize = thumbSize, onItemClick = onItemClick) + CombinationsCard(count = result.combinationsUnlocked, isCompact = isCompact) + GapsFilledSection(gaps = result.gapsFilled, isCompact = isCompact) + DecisionBanner(worthAdding = result.worthAdding, isCompact = isCompact) +} + +@Composable +private fun PairsSection( + matchingItems: List, + thumbSize: Dp, + onItemClick: (ClothingItem) -> Unit, +) { + if (matchingItems.isEmpty()) return + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "It would pair with...", + color = WornColors.TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.2).sp, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(matchingItems, key = { it.id }) { item -> + ItemThumbnail( + item = item, + size = thumbSize, + onClick = { onItemClick(item) }, + ) + } + } + } +} + +@Composable +private fun ItemThumbnail(item: ClothingItem, size: Dp, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(16.dp), + color = WornColors.BgCard, + border = BorderStroke(1.dp, WornColors.BorderSubtle), + shadowElevation = 2.dp, + modifier = Modifier.size(size), + ) { + if (item.photoPath.isNotEmpty() && File(item.photoPath).exists()) { + AsyncImage( + model = File(item.photoPath), + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), + ) + } else { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + imageVector = Icons.Outlined.Checkroom, + contentDescription = item.name, + tint = WornColors.TextSecondary, + modifier = Modifier.size(28.dp), + ) + } + } + } +} + +@Composable +private fun CombinationsCard(count: Int, isCompact: Boolean) { + val cardHeight = if (isCompact) 90.dp else 110.dp + val valueSize = if (isCompact) 40.sp else 44.sp + + Surface( + shape = RoundedCornerShape(20.dp), + color = WornColors.BgCard, + border = BorderStroke(1.dp, WornColors.BorderSubtle), + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth().height(cardHeight), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text( + text = "Combinations unlocked", + color = WornColors.TextSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + ) + Text( + text = count.toString(), + color = WornColors.AccentGreen, + fontSize = valueSize, + fontWeight = FontWeight.Bold, + letterSpacing = (-1.2).sp, + ) + } + } +} + +@Composable +private fun GapsFilledSection(gaps: List, isCompact: Boolean) { + if (gaps.isEmpty()) return + + val fontSize = if (isCompact) 14.sp else 15.sp + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Wardrobe gaps it fills", + color = WornColors.TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.2).sp, + ) + gaps.forEach { gap -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(WornColors.AccentGreen), + ) + Text( + text = gap, + color = WornColors.TextPrimary, + fontSize = fontSize, + ) + } + } + } +} + +@Composable +private fun DecisionBanner(worthAdding: Boolean, isCompact: Boolean) { + val bannerHeight = if (isCompact) 56.dp else 60.dp + val gradient = if (worthAdding) { + Brush.verticalGradient(listOf(WornColors.AccentGreen, WornColors.AccentGreenDark)) + } else { + Brush.verticalGradient(listOf(Color(0xFF8B7D7D), Color(0xFF6B5E5E))) + } + val icon = if (worthAdding) Icons.Outlined.CheckCircle else Icons.Outlined.Cancel + val text = if (worthAdding) "Worth adding" else "Skip this one" + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .height(bannerHeight) + .clip(RoundedCornerShape(28.dp)) + .background(gradient), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp), + ) + Text( + text = text, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +private const val JPEG_QUALITY = 90 + +// region Previews + +private val previewResult = TryItResult( + matchingItems = listOf( + ClothingItem( + id = "1", name = "White T-Shirt", category = com.github.worn.domain.model.Category.TOP, + colors = listOf("White"), seasons = listOf(com.github.worn.domain.model.Season.SPRING), + photoPath = "", createdAt = 0, + ), + ClothingItem( + id = "2", name = "Blue Jeans", category = com.github.worn.domain.model.Category.BOTTOM, + colors = listOf("Navy"), seasons = listOf(com.github.worn.domain.model.Season.FALL), + photoPath = "", createdAt = 0, + ), + ClothingItem( + id = "3", name = "Sneakers", category = com.github.worn.domain.model.Category.SHOES, + colors = listOf("White"), seasons = listOf(com.github.worn.domain.model.Season.SPRING), + photoPath = "", createdAt = 0, + ), + ), + combinationsUnlocked = 24, + gapsFilled = listOf("Neutral layering piece", "Transitional outerwear", "Versatile neutral bottom"), + worthAdding = true, +) + +@Preview(showSystemUi = true, device = "id:pixel_8") +@Composable +private fun TryItResultsPhonePreview() { + WornTheme { + TryItScaffold( + state = TryItState(hasApiKey = true, result = previewResult), + isCompact = true, + photoBitmap = null, + hasPhoto = true, + ) + } +} + +@Preview(showSystemUi = true, device = "id:pixel_8") +@Composable +private fun TryItEmptyPhonePreview() { + WornTheme { + TryItScaffold( + state = TryItState(hasApiKey = false), + isCompact = true, + photoBitmap = null, + hasPhoto = false, + ) + } +} + +@Preview(showSystemUi = true, device = "id:pixel_tablet") +@Composable +private fun TryItResultsTabletPreview() { + WornTheme { + TryItScaffold( + state = TryItState(hasApiKey = true, result = previewResult), + isCompact = false, + photoBitmap = null, + hasPhoto = true, + ) + } +} + +// endregion diff --git a/iosApp/iosApp/Screens/ItemDetailSheet.swift b/iosApp/iosApp/Screens/ItemDetailSheet.swift index 5122b53..bd2f093 100644 --- a/iosApp/iosApp/Screens/ItemDetailSheet.swift +++ b/iosApp/iosApp/Screens/ItemDetailSheet.swift @@ -4,6 +4,7 @@ import Shared struct ItemDetailSheet: View { let item: ClothingItem var isCompact: Bool = true + var showActions: Bool = true let onEdit: (ClothingItem) -> Void let onDelete: (String) -> Void @@ -27,7 +28,9 @@ struct ItemDetailSheet: View { nameGroup divider properties - buttons + if showActions { + buttons + } } .padding(.horizontal, contentPadding) .padding(.bottom, 36) diff --git a/iosApp/iosApp/Screens/TryItScreen.swift b/iosApp/iosApp/Screens/TryItScreen.swift new file mode 100644 index 0000000..21ce742 --- /dev/null +++ b/iosApp/iosApp/Screens/TryItScreen.swift @@ -0,0 +1,453 @@ +import SwiftUI +import PhotosUI +import Shared + +struct TryItScreen: View { + @StateObject private var viewModel = TryItViewModelWrapper() + let onTabSelected: (WornTab) -> Void + + @State private var showSourceChooser = false + @State private var showPhotoPicker = false + @State private var showCamera = false + @State private var photoData: Data? + @State private var photoImage: UIImage? + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var selectedItem: ClothingItem? + + @Environment(\.horizontalSizeClass) private var sizeClass + private var isCompact: Bool { sizeClass == .compact } + private var contentPadding: CGFloat { isCompact ? 24 : 32 } + + var body: some View { + VStack(spacing: 0) { + if !viewModel.state.hasApiKey { + aiEmptyContent + } else { + tryItContent + } + + WornBottomBar(activeTab: .tryIt, onTabSelected: onTabSelected, isCompact: isCompact) + } + .background(WornColors.bgPage) + .confirmationDialog("Add photo", isPresented: $showSourceChooser) { + Button("Take Photo") { showCamera = true } + Button("Choose from Library") { showPhotoPicker = true } + Button("Cancel", role: .cancel) {} + } + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) + .fullScreenCover(isPresented: $showCamera) { + CameraView( + onImageCaptured: { image in + photoImage = image + photoData = image.jpegData(compressionQuality: 0.9) + viewModel.reset() + }, + onDismiss: { showCamera = false } + ) + .ignoresSafeArea() + } + .onChange(of: selectedPhotoItem) { _, newItem in + Task { + if let data = try? await newItem?.loadTransferable(type: Data.self) { + photoData = data + photoImage = UIImage(data: data) + viewModel.reset() + } + } + } + .sheet(item: $selectedItem) { item in + ItemDetailSheet( + item: item, + isCompact: isCompact, + showActions: false, + onEdit: { _ in }, + onDelete: { _ in } + ) + .presentationDetents([.large]) + } + } + + // MARK: - AI Empty State + + private var aiEmptyContent: some View { + VStack(spacing: 24) { + Spacer() + + ZStack { + Circle() + .fill(WornColors.bgCard) + .frame(width: isCompact ? 130 : 150, height: isCompact ? 130 : 150) + .overlay( + Circle().stroke(WornColors.borderSubtle, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.03), radius: 15) + .shadow(color: .black.opacity(0.03), radius: 4, y: 2) + + Image(systemName: "cpu") + .font(.system(size: isCompact ? 52 : 60)) + .foregroundColor(WornColors.accentIndigo) + } + + Text("AI powered analysis") + .font(.system(size: isCompact ? 24 : 26, weight: .medium)) + .foregroundColor(WornColors.textPrimary) + .multilineTextAlignment(.center) + + Text("Connect your Claude API key in Settings to analyze items against your wardrobe.") + .font(.system(size: isCompact ? 15 : 16)) + .foregroundColor(WornColors.textSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + .frame(maxWidth: isCompact ? 280 : 380) + + indigoCtaButton(text: "Connect Claude AI") { + onTabSelected(.settings) + } + + Spacer() + } + .padding(.horizontal, contentPadding) + } + + // MARK: - Try It Content + + private var tryItContent: some View { + ScrollView { + if isCompact { + phoneContent + } else { + tabletContent + } + } + .background(WornColors.bgPage) + } + + private var phoneContent: some View { + VStack(alignment: .leading, spacing: 20) { + tryItTitle(fontSize: 28) + uploadZone(height: 200) + + if photoData != nil && viewModel.state.result == nil && !viewModel.state.isLoading { + analyzeButton + } + + if viewModel.state.isLoading { + loadingIndicator + } + + if let error = viewModel.state.error, !viewModel.state.isLoading { + errorMessage(error) + } + + if let result = viewModel.state.result as? TryItResult { + resultsSection(result: result, thumbSize: 80) + } + + Spacer().frame(height: 12) + } + .padding(.horizontal, contentPadding) + } + + private var tabletContent: some View { + VStack(alignment: .leading, spacing: 28) { + tryItTitle(fontSize: 32) + + HStack(alignment: .top, spacing: 32) { + // Left column + VStack(alignment: .leading, spacing: 24) { + uploadZone(height: 300) + + if photoData != nil && viewModel.state.result == nil && !viewModel.state.isLoading { + analyzeButton + } + + if viewModel.state.isLoading { + loadingIndicator + } + + if let error = viewModel.state.error, !viewModel.state.isLoading { + errorMessage(error) + } + + if let result = viewModel.state.result as? TryItResult { + pairsSection(matchingItems: result.matchingItems as! [ClothingItem], thumbSize: 90) + } + } + .frame(maxWidth: .infinity) + + // Right column + if let result = viewModel.state.result as? TryItResult { + VStack(alignment: .leading, spacing: 24) { + combinationsCard(count: Int(result.combinationsUnlocked), isCompact: false) + gapsFilledSection(gaps: result.gapsFilled as! [String], isCompact: false) + decisionBanner(worthAdding: result.worthAdding, isCompact: false) + } + .frame(maxWidth: .infinity) + } + } + + Spacer().frame(height: 32) + } + .padding(.horizontal, contentPadding) + } + + // MARK: - Components + + private func tryItTitle(fontSize: CGFloat) -> some View { + Text("Would it fit your wardrobe?") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundColor(WornColors.textPrimary) + .tracking(-0.8) + } + + private func uploadZone(height: CGFloat) -> some View { + Button { showSourceChooser = true } label: { + ZStack { + if let image = photoImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity, maxHeight: height) + .clipped() + } else { + VStack(spacing: 12) { + Image(systemName: "camera") + .font(.system(size: 44)) + .foregroundColor(WornColors.iconMuted) + Text("Upload a photo of the item\nyou're considering") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(WornColors.textSecondary) + .multilineTextAlignment(.center) + } + } + } + .frame(maxWidth: .infinity) + .frame(height: height) + .background(WornColors.bgCard) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(WornColors.borderStrong, lineWidth: 1.5) + ) + .shadow(color: .black.opacity(0.03), radius: 2, y: 1) + } + .buttonStyle(.plain) + } + + private var analyzeButton: some View { + Button { + guard let data = photoData else { return } + viewModel.analyzePhoto(imageData: data) + } label: { + HStack(spacing: 8) { + Image(systemName: "cpu") + .font(.system(size: 18)) + Text("Analyze with Claude") + .font(.system(size: 16, weight: .semibold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + LinearGradient( + colors: [WornColors.accentIndigo, Color(hex: "556070")], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: WornColors.accentIndigo.opacity(0.15), radius: 10, y: 6) + } + .buttonStyle(.plain) + } + + private var loadingIndicator: some View { + HStack { + Spacer() + ProgressView() + .tint(WornColors.accentIndigo) + .padding(.vertical, 40) + Spacer() + } + } + + private func errorMessage(_ message: String) -> some View { + Text(message) + .font(.system(size: 14)) + .foregroundColor(WornColors.deleteRed) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(WornColors.deleteRed.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private func resultsSection(result: TryItResult, thumbSize: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 20) { + pairsSection(matchingItems: result.matchingItems as! [ClothingItem], thumbSize: thumbSize) + combinationsCard(count: Int(result.combinationsUnlocked), isCompact: true) + gapsFilledSection(gaps: result.gapsFilled as! [String], isCompact: true) + decisionBanner(worthAdding: result.worthAdding, isCompact: true) + } + } + + private func pairsSection(matchingItems: [ClothingItem], thumbSize: CGFloat) -> some View { + Group { + if !matchingItems.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("It would pair with...") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(WornColors.textPrimary) + .tracking(-0.2) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(matchingItems) { item in + Button { selectedItem = item } label: { + itemThumbnail(item: item, size: thumbSize) + } + .buttonStyle(.plain) + } + } + } + } + } + } + } + + private func itemThumbnail(item: ClothingItem, size: CGFloat) -> some View { + ZStack { + if FileManager.default.fileExists(atPath: item.photoPath) { + AsyncImage(url: URL(fileURLWithPath: item.photoPath)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Image(systemName: "tshirt") + .font(.system(size: 28)) + .foregroundColor(WornColors.textSecondary) + } + .frame(width: size, height: size) + .clipped() + } else { + Image(systemName: "tshirt") + .font(.system(size: 28)) + .foregroundColor(WornColors.textSecondary) + } + } + .frame(width: size, height: size) + .background(WornColors.bgCard) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(WornColors.borderSubtle, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.04), radius: 4, y: 2) + } + + private func combinationsCard(count: Int, isCompact: Bool) -> some View { + let cardHeight: CGFloat = isCompact ? 90 : 110 + let valueSize: CGFloat = isCompact ? 40 : 44 + + return VStack(alignment: .leading, spacing: 4) { + Text("Combinations unlocked") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(WornColors.textSecondary) + .tracking(0.5) + Text("\(count)") + .font(.system(size: valueSize, weight: .bold)) + .foregroundColor(WornColors.accentGreen) + .tracking(-1.2) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: cardHeight) + .background(WornColors.bgCard) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(WornColors.borderSubtle, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.04), radius: 4, y: 2) + } + + private func gapsFilledSection(gaps: [String], isCompact: Bool) -> some View { + Group { + if !gaps.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Wardrobe gaps it fills") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(WornColors.textPrimary) + .tracking(-0.2) + + ForEach(Array(gaps.enumerated()), id: \.offset) { _, gap in + HStack(spacing: 10) { + Circle() + .fill(WornColors.accentGreen) + .frame(width: 8, height: 8) + Text(gap) + .font(.system(size: isCompact ? 14 : 15)) + .foregroundColor(WornColors.textPrimary) + } + } + } + } + } + } + + private func decisionBanner(worthAdding: Bool, isCompact: Bool) -> some View { + let bannerHeight: CGFloat = isCompact ? 56 : 60 + let gradientColors: [Color] = worthAdding + ? [WornColors.accentGreen, WornColors.accentGreenDark] + : [Color(hex: "8B7D7D"), Color(hex: "6B5E5E")] + let iconName = worthAdding ? "checkmark.circle" : "xmark.circle" + let text = worthAdding ? "Worth adding" : "Skip this one" + + return HStack(spacing: 10) { + Image(systemName: iconName) + .font(.system(size: 22)) + .foregroundColor(.white) + Text(text) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .frame(height: bannerHeight) + .background( + LinearGradient(colors: gradientColors, startPoint: .top, endPoint: .bottom) + ) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: WornColors.accentGreen.opacity(0.15), radius: 10, y: 6) + } + + private func indigoCtaButton(text: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(text) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 40) + .padding(.vertical, 14) + .background( + LinearGradient( + colors: [WornColors.accentIndigo, Color(hex: "556070")], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: WornColors.accentIndigo.opacity(0.15), radius: 10, y: 6) + } + .buttonStyle(.plain) + } +} + +// MARK: - Previews + +#Preview("iPhone") { + TryItScreen(onTabSelected: { _ in }) +} + +#Preview("iPad") { + TryItScreen(onTabSelected: { _ in }) + .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch)")) +} diff --git a/iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift b/iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift new file mode 100644 index 0000000..781f4b0 --- /dev/null +++ b/iosApp/iosApp/ViewModels/TryItViewModelWrapper.swift @@ -0,0 +1,44 @@ +import Foundation +import Shared + +@MainActor +class TryItViewModelWrapper: ObservableObject { + private let viewModel: TryItViewModel + private var cancellable: Cancellable? + + @Published var state: TryItState + + init() { + let koin = KoinHelper.shared.koin + let vm = koin.get(objCClass: TryItViewModel.self) as! TryItViewModel + self.viewModel = vm + self.state = vm.state.value + + let adapter = FlowAdapter(flow: vm.state) + cancellable = adapter.subscribe { [weak self] newState in + guard let newState = newState as? TryItState else { return } + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.3)) { + self?.state = newState + } + } + } + } + + func analyzePhoto(imageData: Data) { + let bytes = [UInt8](imageData) + let kotlinBytes = KotlinByteArray(size: Int32(bytes.count)) + for (index, byte) in bytes.enumerated() { + kotlinBytes.set(index: Int32(index), value: Int8(bitPattern: byte)) + } + viewModel.onIntent(intent: TryItIntent.AnalyzePhoto(imageBytes: kotlinBytes)) + } + + func reset() { + viewModel.onIntent(intent: TryItIntent.Reset()) + } + + deinit { + cancellable?.cancel() + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 4c181fd..3039a77 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -18,6 +18,8 @@ struct iOSApp: App { OutfitsScreen(onTabSelected: { activeTab = $0 }) case .gaps: GapsScreen(onTabSelected: { activeTab = $0 }) + case .tryIt: + TryItScreen(onTabSelected: { activeTab = $0 }) case .settings: SettingsScreen(onTabSelected: { activeTab = $0 }) default: diff --git a/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt b/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt index 44cfd48..b07cbe6 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/di/SharedModule.kt @@ -11,6 +11,7 @@ import com.github.worn.domain.repository.WardrobeRepository import com.github.worn.presentation.viewmodel.GapsViewModel import com.github.worn.presentation.viewmodel.OutfitViewModel import com.github.worn.presentation.viewmodel.SettingsViewModel +import com.github.worn.presentation.viewmodel.TryItViewModel import com.github.worn.presentation.viewmodel.WardrobeViewModel import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf @@ -38,4 +39,5 @@ val sharedModule = module { factoryOf(::OutfitViewModel) factoryOf(::SettingsViewModel) factoryOf(::GapsViewModel) + factoryOf(::TryItViewModel) } diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt new file mode 100644 index 0000000..301e009 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/TryItViewModel.kt @@ -0,0 +1,74 @@ +package com.github.worn.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.worn.domain.model.TryItResult +import com.github.worn.domain.repository.WardrobeRepository +import com.github.worn.util.secret.SecretStore +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed interface TryItIntent { + data class AnalyzePhoto(val imageBytes: ByteArray) : TryItIntent + data object Reset : TryItIntent +} + +data class TryItState( + val isLoading: Boolean = false, + val hasApiKey: Boolean = false, + val result: TryItResult? = null, + val error: String? = null, +) + +sealed interface TryItEffect { + data class ShowError(val message: String) : TryItEffect +} + +class TryItViewModel( + private val wardrobeRepository: WardrobeRepository, + private val secretStore: SecretStore, +) : ViewModel() { + + private val _state = MutableStateFlow(TryItState()) + val state: StateFlow = _state.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + + init { + _state.update { it.copy(hasApiKey = secretStore.getApiKey() != null) } + } + + fun onIntent(intent: TryItIntent) { + when (intent) { + is TryItIntent.AnalyzePhoto -> analyzePhoto(intent.imageBytes) + is TryItIntent.Reset -> reset() + } + } + + private fun analyzePhoto(imageBytes: ByteArray) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, result = null) } + wardrobeRepository.analyzeProspectiveItem(imageBytes) + .onSuccess { result -> + _state.update { it.copy(result = result, isLoading = false) } + } + .onFailure { error -> + _state.update { it.copy(isLoading = false, error = error.message) } + _effects.send( + TryItEffect.ShowError(error.message ?: "Failed to analyze item"), + ) + } + } + } + + private fun reset() { + _state.update { it.copy(result = null, error = null) } + } +} From 00d00304bf8f5aa9556b5107585896fc04d25814 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Sat, 28 Mar 2026 15:00:45 -0300 Subject: [PATCH 2/2] fix: text cropping --- .../kotlin/com/github/worn/ui/screen/TryItScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt b/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt index 2363c51..cf48bda 100644 --- a/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/worn/ui/screen/TryItScreen.kt @@ -19,6 +19,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -682,11 +683,13 @@ private fun CombinationsCard(count: Int, isCompact: Boolean) { color = WornColors.BgCard, border = BorderStroke(1.dp, WornColors.BorderSubtle), shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth().height(cardHeight), + modifier = Modifier.fillMaxWidth(), ) { Column( verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + modifier = Modifier + .heightIn(min = cardHeight) + .padding(horizontal = 20.dp, vertical = 16.dp), ) { Text( text = "Combinations unlocked",