diff --git a/composeApp/src/main/kotlin/com/github/worn/App.kt b/composeApp/src/main/kotlin/com/github/worn/App.kt index 8e742a1..8d8aeea 100644 --- a/composeApp/src/main/kotlin/com/github/worn/App.kt +++ b/composeApp/src/main/kotlin/com/github/worn/App.kt @@ -4,8 +4,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -24,7 +27,6 @@ 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.WornColors import com.github.worn.ui.theme.WornTheme import com.github.worn.ui.util.SharedPhoto import com.github.worn.ui.util.ShortcutCommand @@ -72,16 +74,32 @@ fun App( } } - Box( + // Scaffold rather than a Box overlay: it measures the bar and hands the pager a bottom + // inset that already accounts for it, which is what lets every screen drop the hardcoded + // 95dp clearance they each used to subtract by hand. + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + bottomBar = { + WornBottomBar( + activeTab = tabs[pagerState.currentPage], + onTabSelected = onTabSelected, + isCompact = isCompact, + ) + }, modifier = Modifier .fillMaxSize() - .background(WornColors.BgPage) .semantics { testTagsAsResourceId = true }, - ) { + ) { innerPadding -> HorizontalPager( state = pagerState, beyondViewportPageCount = 1, - modifier = Modifier.fillMaxSize(), + // Bottom padding only. This Scaffold exists for the bottom bar; each screen owns + // its own top inset through its TopAppBar, and consuming innerPadding wholesale + // applied the status-bar inset twice — which showed up as a ~90dp dead band above + // every title that no app-bar height parameter could explain. + modifier = Modifier + .fillMaxSize() + .padding(bottom = innerPadding.calculateBottomPadding()), ) { page -> when (tabs[page]) { Tab.WARDROBE -> WardrobeScreen( @@ -99,13 +117,7 @@ fun App( Tab.SETTINGS -> SettingsScreen(onTabSelected = onTabSelected) } } - - WornBottomBar( - activeTab = tabs[pagerState.currentPage], - onTabSelected = onTabSelected, - isCompact = isCompact, - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), - ) } } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt b/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt index 9079f58..fc4b43d 100644 --- a/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt +++ b/composeApp/src/main/kotlin/com/github/worn/MainActivity.kt @@ -1,10 +1,12 @@ package com.github.worn import android.content.Intent +import android.graphics.Color import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -27,7 +29,13 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() - enableEdgeToEdge() + // Transparent bars with `auto` styles: the system picks light or dark bar icons from the + // current uiMode, so status-bar glyphs stay legible in both themes. The default overload + // assumes a light scrim and would leave dark icons on the dark page. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), + ) super.onCreate(savedInstanceState) handleIntent(intent) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/AddItemFields.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/AddItemFields.kt index 0b26e16..4937847 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/AddItemFields.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/AddItemFields.kt @@ -1,7 +1,10 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + @file:Suppress("TooManyFunctions") package com.github.worn.ui.components +import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -23,7 +26,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.CameraAlt @@ -34,6 +36,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults @@ -65,7 +68,7 @@ import com.github.worn.domain.model.Material import com.github.worn.domain.model.Season import com.github.worn.domain.model.Subcategory import com.github.worn.domain.model.subcategoriesFor -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.wornExtras val addItemColorPalette = listOf( "White" to Color(0xFFFFFFFF), @@ -93,9 +96,9 @@ fun PhotoUploadZone( ) { Surface( onClick = onClick, - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, color = Color.Transparent, - border = BorderStroke(1.5.dp, WornColors.BorderStrong), + border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.outline), modifier = modifier.fillMaxWidth().height(140.dp), ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { @@ -104,7 +107,7 @@ fun PhotoUploadZone( bitmap = bitmap, contentDescription = "Selected photo", contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), ) } else { Column( @@ -115,14 +118,14 @@ fun PhotoUploadZone( Icon( imageVector = Icons.Outlined.CameraAlt, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(32.dp), ) Spacer(Modifier.height(8.dp)) Text( text = stringResource(R.string.add_item_photo_hint), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, ) } @@ -132,10 +135,13 @@ fun PhotoUploadZone( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(16.dp)) - .background(WornColors.BgElevated.copy(alpha = 0.6f)), + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f)), ) { - CircularProgressIndicator(color = WornColors.AccentGreen, modifier = Modifier.size(28.dp)) + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) } } } @@ -155,8 +161,8 @@ fun RemoveBackgroundToggle( ) { Text( text = stringResource(R.string.add_item_remove_background), - color = WornColors.TextPrimary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) @@ -166,7 +172,7 @@ fun RemoveBackgroundToggle( enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = WornColors.AccentGreen, + checkedTrackColor = MaterialTheme.colorScheme.primary, ), ) } @@ -176,16 +182,21 @@ fun RemoveBackgroundToggle( fun AiBadge(onClick: () -> Unit = {}, modifier: Modifier = Modifier) { Surface( onClick = onClick, - shape = RoundedCornerShape(28.dp), - color = WornColors.AccentIndigo, + shape = MaterialTheme.shapes.extraLargeIncreased, + color = MaterialTheme.colorScheme.secondary, modifier = modifier, ) { Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) { - Text("✦ ", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text( + "✦ ", + color = Color.White, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) Text( stringResource(R.string.add_item_ai_badge), color = Color.White, - fontSize = 12.sp, + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, ) } @@ -200,18 +211,20 @@ fun ItemNameField(value: String, onValueChange: (String) -> Unit, modifier: Modi placeholder = { Text( stringResource(R.string.add_item_name_hint), - color = WornColors.IconMuted, - fontSize = 15.sp, + color = MaterialTheme.wornExtras.iconMuted, + style = MaterialTheme.typography.bodyMedium, ) }, colors = TextFieldDefaults.colors( - focusedContainerColor = WornColors.BgCard, - unfocusedContainerColor = WornColors.BgCard, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), - shape = RoundedCornerShape(12.dp), - modifier = modifier.fillMaxWidth().border(1.dp, WornColors.BorderSubtle, RoundedCornerShape(12.dp)), + shape = MaterialTheme.shapes.medium, + modifier = modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.medium), ) } @@ -219,9 +232,9 @@ fun ItemNameField(value: String, onValueChange: (String) -> Unit, modifier: Modi fun CategoryDropdown(selected: Category?, onSelected: (Category) -> Unit, modifier: Modifier = Modifier) { var expanded by remember { mutableStateOf(false) } Surface( - shape = RoundedCornerShape(12.dp), - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = modifier.fillMaxWidth(), ) { Column { @@ -237,22 +250,26 @@ fun CategoryDropdown(selected: Category?, onSelected: (Category) -> Unit, modifi Icon( painter = painterResource(selected.iconRes()), contentDescription = null, - tint = WornColors.TextSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) Spacer(Modifier.size(12.dp)) } Text( text = selected?.displayName() ?: stringResource(R.string.label_category), - color = if (selected != null) WornColors.TextPrimary else WornColors.IconMuted, - fontSize = 15.sp, + color = if (selected != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.wornExtras.iconMuted + }, + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) Icon( imageVector = if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(18.dp), ) } @@ -274,7 +291,7 @@ fun CategoryDropdown(selected: Category?, onSelected: (Category) -> Unit, modifi @Composable private fun CategoryOptionList(onSelected: (Category) -> Unit) { Column { - HorizontalDivider(color = WornColors.BorderSubtle) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Category.entries.forEach { category -> Surface( onClick = { onSelected(category) }, @@ -287,20 +304,20 @@ private fun CategoryOptionList(onSelected: (Category) -> Unit) { Icon( painter = painterResource(category.iconRes()), contentDescription = null, - tint = WornColors.TextSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) Spacer(Modifier.size(12.dp)) Text( text = category.displayName(), - color = WornColors.TextPrimary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, ) } } if (category != Category.entries.last()) { - HorizontalDivider(color = WornColors.BorderSubtle.copy(alpha = 0.5f)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) } } } @@ -312,8 +329,8 @@ fun ColorSection(selectedColors: Set, onToggle: (String) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( stringResource(R.string.label_color), - color = WornColors.TextPrimary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, ) FlowRow( @@ -326,7 +343,7 @@ fun ColorSection(selectedColors: Set, onToggle: (String) -> Unit) { onClick = { onToggle(name) }, shape = CircleShape, color = color, - border = if (isSelected) BorderStroke(2.dp, WornColors.AccentGreen) else null, + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, modifier = Modifier.size(28.dp), ) { if (isSelected) { @@ -355,8 +372,8 @@ fun SeasonSection(selectedSeasons: Set, onToggle: (Season) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( stringResource(R.string.label_season), - color = WornColors.TextPrimary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -396,7 +413,7 @@ internal fun Category.iconRes(): Int = when (this) { Category.TOP -> R.drawable.ic_shirt Category.BOTTOM -> R.drawable.ic_rectangle_horizontal Category.OUTERWEAR -> R.drawable.ic_wind - Category.SHOES -> R.drawable.ic_footprints + Category.SHOES -> R.drawable.ic_sneaker Category.ACCESSORY -> R.drawable.ic_glasses } @@ -414,9 +431,9 @@ fun SubcategoryDropdown(category: Category, selected: Subcategory?, onSelected: val options = subcategoriesFor(category) var expanded by remember { mutableStateOf(false) } Surface( - shape = RoundedCornerShape(12.dp), - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = Modifier.fillMaxWidth(), ) { Column { @@ -452,15 +469,15 @@ private fun DropdownHeader(text: String, hasSelection: Boolean, expanded: Boolea ) { Text( text = text, - color = if (hasSelection) WornColors.TextPrimary else WornColors.IconMuted, - fontSize = 15.sp, + color = if (hasSelection) MaterialTheme.colorScheme.onSurface else MaterialTheme.wornExtras.iconMuted, + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) Icon( imageVector = if (expanded) Icons.Outlined.KeyboardArrowUp else Icons.Outlined.KeyboardArrowDown, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(18.dp), ) } @@ -469,13 +486,13 @@ private fun DropdownHeader(text: String, hasSelection: Boolean, expanded: Boolea @Composable private fun SubcategoryOptionList(options: List, onSelected: (Subcategory) -> Unit) { Column { - HorizontalDivider(color = WornColors.BorderSubtle) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) options.forEach { subcategory -> Surface(onClick = { onSelected(subcategory) }, color = Color.Transparent) { Text( text = subcategory.displayName(), - color = WornColors.TextPrimary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, modifier = Modifier .fillMaxWidth() @@ -483,7 +500,7 @@ private fun SubcategoryOptionList(options: List, onSelected: (Subca ) } if (subcategory != options.last()) { - HorizontalDivider(color = WornColors.BorderSubtle.copy(alpha = 0.5f)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) } } } @@ -495,8 +512,8 @@ fun FitSection(selected: Fit?, onSelected: (Fit?) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( stringResource(R.string.label_fit), - color = WornColors.TextPrimary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, ) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -517,8 +534,8 @@ fun MaterialSection(selected: Material?, onSelected: (Material?) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( stringResource(R.string.label_material), - color = WornColors.TextPrimary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, ) FlowRow( diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/AiLockedSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/AiLockedSheet.kt index 97c51b4..d43d4e1 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/AiLockedSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/AiLockedSheet.kt @@ -1,11 +1,10 @@ package com.github.worn.ui.components -import androidx.compose.foundation.background import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -19,6 +18,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -28,19 +28,18 @@ 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.text.font.FontWeight import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.theme.PhonePreview import com.github.worn.ui.theme.SheetPreview -import com.github.worn.ui.theme.WornColors - -private val IndigoAccent = WornColors.AccentIndigo +import com.github.worn.ui.theme.TabletPreview +import com.github.worn.ui.theme.sheetShape @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -50,8 +49,8 @@ fun AiLockedSheet(onDismiss: () -> Unit, onGoToSettings: () -> Unit = {}) { ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, dragHandle = { SheetDragHandle() }, ) { AiLockedContent(onGoToSettings = onGoToSettings, onDismiss = onDismiss) @@ -76,7 +75,7 @@ internal fun AiLockedContent( contentAlignment = Alignment.Center, modifier = Modifier .size(44.dp) - .background(IndigoAccent, RoundedCornerShape(12.dp)), + .background(MaterialTheme.colorScheme.secondary, MaterialTheme.shapes.medium), ) { Icon( Icons.Outlined.SmartToy, @@ -88,15 +87,15 @@ internal fun AiLockedContent( Text( text = stringResource(R.string.ai_locked_title), - color = WornColors.TextPrimary, - fontSize = 22.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Medium, ) Text( text = stringResource(R.string.ai_locked_description), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, lineHeight = 21.sp, modifier = Modifier.widthIn(max = 280.dp), @@ -122,14 +121,16 @@ private fun SettingsCta(onClick: () -> Unit) { ) } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun AiLockedPhonePreview() { SheetPreview { AiLockedContent() } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun AiLockedTabletPreview() { SheetPreview { AiLockedContent() } } + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/CategoryFilterChips.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/CategoryFilterChips.kt index c2cc6cf..8d6cf14 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/CategoryFilterChips.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/CategoryFilterChips.kt @@ -12,13 +12,12 @@ 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.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R import com.github.worn.domain.model.Category -import com.github.worn.ui.theme.WornColors @Composable fun CategoryFilterChips( diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingCard.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingCard.kt index f238329..58cfe27 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingCard.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingCard.kt @@ -18,13 +18,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -32,9 +37,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.domain.model.Category import com.github.worn.domain.model.ClothingItem -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.wornExtras -private val photoShape = RoundedCornerShape(16.dp) +private val photoShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.large @OptIn(ExperimentalFoundationApi::class) @Composable @@ -47,10 +53,21 @@ fun ClothingCard( onClick: () -> Unit = {}, modifier: Modifier = Modifier, ) { + val haptics = LocalHapticFeedback.current Column( + // Deliberately no .clip() here and deliberately not a Card. The cell is a photo plus a + // caption sitting on the page background, so a Card would put the caption on a white + // container, and clipping the Column to a rounded shape cuts its own bottom-left corner — + // which is exactly where the category dot sits, rendering it as a teardrop. + // combinedClickable already bounds its ripple to the item. modifier = modifier.combinedClickable( onClick = onClick, - onLongClick = onLongPress, + onLongClick = { + // Long-press is the only way into selection mode and it has no visual affordance + // before it fires, so the tick is what tells you it worked. + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onLongPress() + }, ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -74,8 +91,8 @@ private fun PhotoArea( Box(modifier = Modifier.fillMaxWidth().height(height)) { Surface( shape = photoShape, - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 4.dp, modifier = Modifier.fillMaxSize(), ) { @@ -105,8 +122,8 @@ private fun ItemInfo(item: ClothingItem) { ) { Text( text = item.name, - color = WornColors.TextPrimary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, // AI-generated names can run long; left unbounded they push the category row down and // misalign the cards next to them in the grid row. @@ -125,17 +142,21 @@ private fun ItemInfo(item: ClothingItem) { ) Text( text = item.category.displayLabel(), - color = WornColors.TextMuted, - fontSize = 12.sp, + color = MaterialTheme.wornExtras.textMuted, + style = MaterialTheme.typography.labelMedium, ) } } } +@Composable +@ReadOnlyComposable internal fun Category.dotColor(): Color = when (this) { - Category.TOP -> WornColors.CategoryDotTop - Category.BOTTOM -> WornColors.CategoryDotBottom - Category.OUTERWEAR -> WornColors.CategoryDotOuterwear - Category.SHOES -> WornColors.CategoryDotShoes - Category.ACCESSORY -> WornColors.CategoryDotAccessory + Category.TOP -> MaterialTheme.wornExtras.categoryDotTop + Category.BOTTOM -> MaterialTheme.wornExtras.categoryDotBottom + Category.OUTERWEAR -> MaterialTheme.wornExtras.categoryDotOuterwear + Category.SHOES -> MaterialTheme.wornExtras.categoryDotShoes + Category.ACCESSORY -> MaterialTheme.wornExtras.categoryDotAccessory } + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingPhoto.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingPhoto.kt index 82d5e8d..2b79d69 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingPhoto.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/ClothingPhoto.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Checkroom import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -16,7 +17,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImage -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.wornExtras import java.io.File /** @@ -34,7 +35,7 @@ fun ClothingPhoto( shape: Shape, placeholderIconSize: Dp, modifier: Modifier = Modifier, - placeholderTint: Color = WornColors.IconMuted, + placeholderTint: Color = MaterialTheme.wornExtras.iconMuted, ) { val photoFile = remember(photoPath) { photoPath.takeIf { it.isNotEmpty() }?.let(::File) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/CropEditorDialog.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/CropEditorDialog.kt index 2f28166..e091b55 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/CropEditorDialog.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/CropEditorDialog.kt @@ -2,6 +2,7 @@ package com.github.worn.ui.components import android.graphics.Bitmap import android.widget.Toast +import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding 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 @@ -28,14 +30,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ClipOp import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext @@ -43,17 +46,16 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.compose.foundation.Canvas -import androidx.compose.ui.input.pointer.pointerInput import com.github.worn.R import com.github.worn.ui.exposeTestTagsAsResourceId -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme +import com.github.worn.ui.theme.wornExtras import com.github.worn.ui.util.cropToJpeg import com.github.worn.ui.util.decodeForEditing import com.github.worn.util.image.CropCorner @@ -203,7 +205,7 @@ private fun CropCanvas( Canvas(modifier = Modifier.fillMaxSize()) { drawCropOverlay(current) } } if (isProcessing) { - CircularProgressIndicator(color = WornColors.AccentGreen, modifier = Modifier.size(36.dp)) + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary, modifier = Modifier.size(36.dp)) } } } @@ -268,13 +270,16 @@ private fun CropEditorTopBar(canApply: Boolean, onCancel: () -> Unit, onApply: ( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), ) { TextButton(onClick = onCancel, modifier = Modifier.testTag("crop_editor_cancel")) { - Text(stringResource(R.string.common_cancel), color = Color.White, fontSize = 16.sp) + Text( + stringResource(R.string.common_cancel), + color = Color.White, + style = MaterialTheme.typography.titleSmall, + ) } Text( text = stringResource(R.string.crop_title), color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, ) TextButton( onClick = onApply, @@ -283,9 +288,8 @@ private fun CropEditorTopBar(canApply: Boolean, onCancel: () -> Unit, onApply: ( ) { Text( text = stringResource(R.string.crop_apply), - color = if (canApply) WornColors.AccentGreen else WornColors.TextMuted, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + color = if (canApply) MaterialTheme.colorScheme.primary else MaterialTheme.wornExtras.textMuted, + style = MaterialTheme.typography.titleSmall, ) } } @@ -298,7 +302,7 @@ private fun CropEditorBottomBar(enabled: Boolean, onReset: () -> Unit) { modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), ) { TextButton(onClick = onReset, enabled = enabled, modifier = Modifier.testTag("crop_editor_reset")) { - Text(stringResource(R.string.crop_reset), color = Color.White, fontSize = 15.sp) + Text(stringResource(R.string.crop_reset), color = Color.White, style = MaterialTheme.typography.bodyMedium) } } } @@ -367,22 +371,25 @@ private const val GUIDE_WIDTH_PX = 1.5f private const val HANDLE_WIDTH_PX = 8f private const val HANDLE_ARM_PX = 56f +// Preview-only stand-in for a real photo; the exact fill is irrelevant, so it stays a literal +// rather than a theme lookup that would force this helper to become @Composable. private fun previewBitmap(): ImageBitmap = Bitmap.createBitmap(PREVIEW_WIDTH, PREVIEW_HEIGHT, Bitmap.Config.ARGB_8888) - .apply { eraseColor(WornColors.AccentGreen.toArgb()) } + .apply { eraseColor(android.graphics.Color.rgb(0x7A, 0x94, 0x68)) } .asImageBitmap() private const val PREVIEW_WIDTH = 600 private const val PREVIEW_HEIGHT = 800 -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun CropEditorContentPhonePreview() { WornTheme { CropEditorContent(bitmap = previewBitmap()) } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun CropEditorContentTabletPreview() { WornTheme { CropEditorContent(bitmap = previewBitmap()) } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/CropPhotoButton.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/CropPhotoButton.kt index c37eca8..41e45be 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/CropPhotoButton.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/CropPhotoButton.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Crop import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -18,7 +19,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R -import com.github.worn.ui.theme.WornColors /** * Opens the crop editor for the photo shown above it. Rendered under a photo preview zone rather @@ -35,14 +35,14 @@ fun CropPhotoButton( Icon( imageVector = Icons.Outlined.Crop, contentDescription = null, - tint = WornColors.TextSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(8.dp)) Text( text = stringResource(R.string.crop_photo_button), - color = WornColors.TextPrimary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) } diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/DeleteConfirmationDialog.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/DeleteConfirmationDialog.kt index 403745a..6143827 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/DeleteConfirmationDialog.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/DeleteConfirmationDialog.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -11,11 +12,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme @Composable @@ -32,14 +33,14 @@ fun DeleteConfirmationDialog( Text( title, fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, + style = MaterialTheme.typography.titleLarge, ) }, text = { Text( message, - color = WornColors.TextSecondary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, lineHeight = 22.sp, ) }, @@ -47,8 +48,8 @@ fun DeleteConfirmationDialog( Button( onClick = onConfirm, enabled = !isDeleting, - colors = ButtonDefaults.buttonColors(containerColor = WornColors.DeleteRed), - shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.testTag("delete_dialog_confirm"), ) { Text( @@ -70,7 +71,7 @@ fun DeleteConfirmationDialog( ) } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun DeleteConfirmationDialogPhonePreview() { WornTheme { @@ -84,7 +85,7 @@ private fun DeleteConfirmationDialogPhonePreview() { } } -@Preview(showSystemUi = true, device = "spec:width=800dp,height=1280dp,dpi=240") +@TabletPreview @Composable private fun DeleteConfirmationDialogTabletPreview() { WornTheme { @@ -97,3 +98,5 @@ private fun DeleteConfirmationDialogTabletPreview() { ) } } + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/EmptyStateView.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/EmptyStateView.kt index d162d83..282e601 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/EmptyStateView.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/EmptyStateView.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,10 +18,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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.sp -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme @Composable @@ -39,8 +40,8 @@ fun EmptyStateView( Box( modifier = Modifier.size(130.dp) .shadow(15.dp, CircleShape) - .background(WornColors.BgCard, CircleShape) - .border(1.dp, WornColors.BorderSubtle, CircleShape), + .background(MaterialTheme.colorScheme.surfaceContainer, CircleShape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape), contentAlignment = Alignment.Center, ) { icon() @@ -48,16 +49,15 @@ fun EmptyStateView( Spacer(Modifier.height(24.dp)) Text( title, - color = WornColors.TextPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, letterSpacing = (-0.5).sp, ) Spacer(Modifier.height(24.dp)) Text( description, - color = WornColors.TextSecondary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, lineHeight = 22.sp, textAlign = TextAlign.Center, ) @@ -68,13 +68,13 @@ fun EmptyStateView( } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun EmptyStatePhonePreview() { WornTheme { EmptyStateView( icon = { - Text("👕", fontSize = 42.sp) + Text("👕", style = MaterialTheme.typography.displaySmall) }, title = "Your wardrobe is empty", description = "Add your first item to get started", @@ -82,16 +82,17 @@ private fun EmptyStatePhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun EmptyStateTabletPreview() { WornTheme { EmptyStateView( icon = { - Text("👕", fontSize = 42.sp) + Text("👕", style = MaterialTheme.typography.displaySmall) }, title = "Your wardrobe is empty", description = "Add your first item to get started", ) } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/ErrorContentView.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/ErrorContentView.kt index bac8661..c13a5a1 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/ErrorContentView.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/ErrorContentView.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,11 +24,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource 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.sp import com.github.worn.R -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme @Composable @@ -35,7 +36,7 @@ fun ErrorContentView( message: String, onRetry: () -> Unit, modifier: Modifier = Modifier, - retryButtonColor: Color = WornColors.AccentGreen, + retryButtonColor: Color = MaterialTheme.colorScheme.primary, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -46,33 +47,33 @@ fun ErrorContentView( modifier = Modifier .size(72.dp) .clip(CircleShape) - .background(WornColors.DeleteRed.copy(alpha = 0.1f)), + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.1f)), ) { Icon( imageVector = Icons.Outlined.ErrorOutline, contentDescription = null, - tint = WornColors.DeleteRed, + tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(32.dp), ) } Spacer(Modifier.height(24.dp)) Text( text = message, - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, lineHeight = 20.sp, textAlign = TextAlign.Center, ) Spacer(Modifier.height(20.dp)) Surface( onClick = onRetry, - shape = RoundedCornerShape(16.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, ) { Text( text = stringResource(R.string.common_retry), color = retryButtonColor, - fontSize = 15.sp, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), ) @@ -80,7 +81,7 @@ fun ErrorContentView( } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun ErrorContentViewPhonePreview() { WornTheme { @@ -92,7 +93,7 @@ private fun ErrorContentViewPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun ErrorContentViewTabletPreview() { WornTheme { @@ -100,7 +101,8 @@ private fun ErrorContentViewTabletPreview() { message = "Something went wrong. Please try again.", onRetry = {}, modifier = Modifier.padding(vertical = 60.dp), - retryButtonColor = WornColors.AccentIndigo, + retryButtonColor = MaterialTheme.colorScheme.secondary, ) } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt index d3a5a7e..5753829 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt @@ -1,3 +1,5 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + package com.github.worn.ui.components import androidx.compose.foundation.BorderStroke @@ -18,12 +20,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -33,21 +40,26 @@ import androidx.compose.ui.unit.sp import com.github.worn.R import com.github.worn.domain.model.Category import com.github.worn.domain.model.Outfit -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.wornExtras import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -private val cardShape = RoundedCornerShape(20.dp) +private val cardShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.largeIncreased -private val thumbnailShape = RoundedCornerShape(10.dp) -private val badgeShape = RoundedCornerShape(8.dp) +private val thumbnailShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.medium +private val badgeShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.small -private val badgeColors = listOf( - WornColors.AccentIndigo, - WornColors.AccentCoral, - WornColors.AccentGreen, -) +private val badgeColors: List + @Composable @ReadOnlyComposable + get() = listOf( + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.primary, + ) @OptIn(ExperimentalFoundationApi::class) @Composable @@ -60,18 +72,27 @@ fun OutfitCard( onClick: () -> Unit = {}, modifier: Modifier = Modifier, ) { + val haptics = LocalHapticFeedback.current Surface( shape = cardShape, - color = WornColors.BgCard, + color = MaterialTheme.colorScheme.surfaceContainer, border = BorderStroke( 1.dp, - if (isSelected) WornColors.AccentGreen else WornColors.BorderSubtle, + if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, ), shadowElevation = 4.dp, modifier = modifier .fillMaxWidth() .height(170.dp) - .combinedClickable(onClick = onClick, onLongClick = onLongPress), + .combinedClickable( + onClick = onClick, + onLongClick = { + // Long-press is the only way into selection mode and it has no visual + // affordance before it fires, so the tick is what tells you it worked. + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onLongPress() + }, + ), ) { Row(modifier = Modifier.padding(20.dp)) { if (isSelectionMode) { @@ -112,14 +133,14 @@ private fun ItemThumbnailRow( private fun ItemThumbnail(category: Category?) { Surface( shape = thumbnailShape, - color = WornColors.BgElevated, + color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier.size(40.dp), ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { Icon( painter = painterResource(id = (category ?: Category.TOP).iconRes()), contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(20.dp), ) } @@ -132,8 +153,8 @@ private fun ItemCountBadge(outfit: Outfit) { Surface(shape = badgeShape, color = badgeColor) { Text( text = stringResource(R.string.outfit_detail_items_count, outfit.itemIds.size), - color = WornColors.TextOnColor, - fontSize = 11.sp, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), ) @@ -150,23 +171,22 @@ private fun BottomRow(outfit: Outfit) { Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { Text( text = outfit.name, - color = WornColors.TextPrimary, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, // Auto-generated names concatenate every item, so they can outgrow the card. maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = formatDate(outfit.createdAt), - color = WornColors.TextSecondary, - fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, ) } Icon( imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(20.dp), ) } @@ -180,3 +200,4 @@ private val dateFormat = SimpleDateFormat("MMM d", Locale.getDefault()) private fun formatDate(epochMillis: Long): String { return dateFormat.format(Date(epochMillis)) } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/PropertyRow.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/PropertyRow.kt index 5405d0a..295978e 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/PropertyRow.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/PropertyRow.kt @@ -5,23 +5,23 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +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.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme @Composable fun PropertyRow( label: String, value: String, - fontSize: TextUnit, + textStyle: TextStyle, modifier: Modifier = Modifier, ) { Row( @@ -29,29 +29,40 @@ fun PropertyRow( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(label, color = WornColors.TextSecondary, fontSize = fontSize, fontWeight = FontWeight.Medium) - Text(value, color = WornColors.TextPrimary, fontSize = fontSize, fontWeight = FontWeight.Medium) + Text( + label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = textStyle, + fontWeight = FontWeight.Medium, + ) + Text( + value, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, + fontWeight = FontWeight.Medium, + ) } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun PropertyRowPhonePreview() { WornTheme { Column(modifier = Modifier.padding(16.dp)) { - PropertyRow(label = "Season", value = "Summer", fontSize = 15.sp) - PropertyRow(label = "Fit", value = "Regular", fontSize = 15.sp) + PropertyRow(label = "Season", value = "Summer", textStyle = MaterialTheme.typography.bodyMedium) + PropertyRow(label = "Fit", value = "Regular", textStyle = MaterialTheme.typography.bodyMedium) } } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun PropertyRowTabletPreview() { WornTheme { Column(modifier = Modifier.padding(16.dp)) { - PropertyRow(label = "Season", value = "Summer", fontSize = 15.sp) - PropertyRow(label = "Fit", value = "Regular", fontSize = 15.sp) + PropertyRow(label = "Season", value = "Summer", textStyle = MaterialTheme.typography.bodyMedium) + PropertyRow(label = "Fit", value = "Regular", textStyle = MaterialTheme.typography.bodyMedium) } } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionHeader.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionHeader.kt index 3807ac5..ab0a766 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionHeader.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionHeader.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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 @@ -22,11 +23,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme @Composable @@ -45,15 +46,15 @@ fun SelectionHeader( ) { Text( text = pluralStringResource(R.plurals.selected_count, count, count), - color = WornColors.TextPrimary, - fontSize = 28.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Medium, letterSpacing = (-0.8).sp, ) Button( onClick = onDelete, - colors = ButtonDefaults.buttonColors(containerColor = WornColors.DeleteRed), - shape = RoundedCornerShape(22.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + shape = MaterialTheme.shapes.extraLarge, ) { Icon(Icons.Outlined.Delete, contentDescription = null, tint = Color.White) Spacer(Modifier.width(6.dp)) @@ -61,22 +62,22 @@ fun SelectionHeader( stringResource(R.string.common_delete), color = Color.White, fontWeight = FontWeight.SemiBold, - fontSize = 15.sp, + style = MaterialTheme.typography.bodyMedium, ) } } Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.common_cancel), - color = WornColors.TextSecondary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.clickable(onClick = onCancel), ) } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun SelectionHeaderPhonePreview() { WornTheme { @@ -84,10 +85,11 @@ private fun SelectionHeaderPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun SelectionHeaderTabletPreview() { WornTheme { SelectionHeader(count = 5, onCancel = {}, onDelete = {}) } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionIndicator.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionIndicator.kt index 7455398..97f6a0b 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionIndicator.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/SelectionIndicator.kt @@ -11,15 +11,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme @Composable @@ -31,8 +32,8 @@ fun SelectionIndicator( ) { Surface( shape = RoundedCornerShape(size / 2), - color = if (isSelected) WornColors.AccentGreen else WornColors.BgCard, - border = if (isSelected) null else BorderStroke(1.5.dp, WornColors.BorderSubtle), + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainer, + border = if (isSelected) null else BorderStroke(1.5.dp, MaterialTheme.colorScheme.outlineVariant), modifier = modifier.size(size), ) { if (isSelected) { @@ -48,7 +49,7 @@ fun SelectionIndicator( } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun SelectionIndicatorPhonePreview() { WornTheme { @@ -65,7 +66,7 @@ private fun SelectionIndicatorPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun SelectionIndicatorTabletPreview() { WornTheme { @@ -77,3 +78,4 @@ private fun SelectionIndicatorTabletPreview() { } } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/SheetDragHandle.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/SheetDragHandle.kt index cdf47bb..a911416 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/SheetDragHandle.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/SheetDragHandle.kt @@ -7,20 +7,22 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme +import com.github.worn.ui.theme.wornExtras @Composable fun SheetDragHandle( modifier: Modifier = Modifier, - color: Color = WornColors.IconMuted, + color: Color = MaterialTheme.wornExtras.iconMuted, ) { Box( contentAlignment = Alignment.Center, @@ -36,22 +38,23 @@ fun SheetDragHandle( } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun SheetDragHandlePhonePreview() { WornTheme { - Box(modifier = Modifier.background(WornColors.BgElevated)) { + Box(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh)) { SheetDragHandle() } } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun SheetDragHandleTabletPreview() { WornTheme { - Box(modifier = Modifier.background(WornColors.BgElevated)) { + Box(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh)) { SheetDragHandle() } } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt index c9a5af6..08d0f55 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornBottomBar.kt @@ -1,20 +1,24 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + package com.github.worn.ui.components import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight 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.widthIn +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Extension @@ -22,24 +26,29 @@ import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R -import com.github.worn.ui.theme.WornColors enum class Tab( @StringRes val labelRes: Int, @@ -73,9 +82,9 @@ fun WornBottomBar( ), ) { Surface( - shape = RoundedCornerShape(36.dp), - color = WornColors.BgElevated, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + shape = MaterialTheme.shapes.extraExtraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = Modifier .then( if (isCompact) Modifier.fillMaxWidth() @@ -88,12 +97,10 @@ fun WornBottomBar( modifier = Modifier.padding(4.dp), ) { Tab.entries.forEach { tab -> - val isActive = tab == activeTab TabItem( tab = tab, - isActive = isActive, + isActive = tab == activeTab, onClick = { onTabSelected(tab) }, - modifier = Modifier.weight(1f).fillMaxHeight(), ) } } @@ -101,24 +108,67 @@ fun WornBottomBar( } } +/** + * One tab: the app's full-width pill, with the touch feedback and semantics it was missing. + * + * Deliberately *not* [NavigationBarItem]. That gives a ripple and an animated indicator for free, + * but its indicator only ever wraps the icon — the label sits outside it. Worn's pill wraps icon + * and label together, so the M3 item left the selected label stranded on the bar background in + * `onPrimary`, which is dark-green-on-dark in the dark scheme and nearly unreadable. + * + * So the pill container stays hand-built, and the two things that actually needed fixing are + * addressed directly: [selectable] supplies a ripple bounded to the pill plus proper + * selected/Tab semantics for TalkBack, and the fill animates between tabs rather than snapping. + * + * An earlier comment here blamed the ripple for repainting the bar for ~1s after each tap and + * removed indication entirely. The cost was actually the pager recomposing the destination page, + * which `beyondViewportPageCount` and the snap-scroll in App.kt already address — suppressing + * touch feedback only hid it. + */ @Composable -private fun TabItem( +private fun RowScope.TabItem( tab: Tab, isActive: Boolean, onClick: () -> Unit, - modifier: Modifier = Modifier, ) { + val label = stringResource(tab.labelRes) + val haptics = LocalHapticFeedback.current + val containerColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + label = "tabContainer", + ) + val contentColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + label = "tabContent", + ) + Surface( - shape = RoundedCornerShape(26.dp), - color = if (isActive) WornColors.AccentGreen else WornColors.BgElevated, - // No ripple: it repaints the bar for ~1s after each tap, which reads as a slow page - // switch. The active tab's fill already signals selection. - modifier = modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, + shape = MaterialTheme.shapes.extraLargeIncreased, + color = containerColor, + contentColor = contentColor, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(MaterialTheme.shapes.extraLargeIncreased) + .selectable( + selected = isActive, + onClick = { + // SegmentTick, not LongPress: this is a discrete position change in a row of + // segments, which is exactly what that constant is for. + if (!isActive) haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + onClick() + }, role = Role.Tab, - onClick = onClick, + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, ) .testTag(tab.testTag), ) { @@ -127,34 +177,23 @@ private fun TabItem( verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight(), ) { - val tint = if (isActive) WornColors.TextOnColor else WornColors.TextSecondary - val label = stringResource(tab.labelRes) + // contentDescription is null: the label below already names the tab, and TalkBack + // would otherwise announce it twice. if (tab.iconRes != null) { - Icon( - painter = painterResource(id = tab.iconRes), - contentDescription = label, - tint = tint, - modifier = Modifier.size(18.dp), - ) + Icon(painterResource(id = tab.iconRes), null, Modifier.size(18.dp)) } else if (tab.icon != null) { - Icon( - imageVector = tab.icon, - contentDescription = label, - tint = tint, - modifier = Modifier.size(18.dp), - ) + Icon(tab.icon, null, Modifier.size(18.dp)) } Text( text = label, - style = TextStyle( - color = if (isActive) WornColors.TextOnColor else WornColors.TextSecondary, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.5.sp, - ), + // labelSmall already carries the 10sp/SemiBold/+0.5sp tracking these labels used. + style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } + + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornChip.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornChip.kt index 77dab60..ba121d0 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornChip.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornChip.kt @@ -1,23 +1,33 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + package com.github.worn.ui.components +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme -private val chipShape = RoundedCornerShape(20.dp) +private val chipShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.largeIncreased @Composable fun WornChip( @@ -26,24 +36,46 @@ fun WornChip( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val haptics = LocalHapticFeedback.current + // Filtering swaps the whole grid underneath, so easing the chip's own fill gives the eye + // something continuous to hold on to; snapping both at once reads as a flash. + val containerColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + label = "chipContainer", + ) + val labelColor by animateColorAsState( + targetValue = if (isActive) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + label = "chipLabel", + ) + Surface( - onClick = onClick, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + onClick() + }, shape = chipShape, - color = if (isActive) WornColors.AccentGreen else WornColors.BgCard, - border = if (isActive) null else BorderStroke(1.dp, WornColors.BorderSubtle), + color = containerColor, + border = if (isActive) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = modifier, ) { Text( text = label, - color = if (isActive) WornColors.TextOnColor else WornColors.TextSecondary, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = labelColor, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun WornChipPhonePreview() { WornTheme { @@ -55,7 +87,7 @@ private fun WornChipPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun WornChipTabletPreview() { WornTheme { @@ -66,3 +98,5 @@ private fun WornChipTabletPreview() { } } } + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornGradientButton.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornGradientButton.kt index f30043d..3847118 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornGradientButton.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornGradientButton.kt @@ -12,27 +12,47 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.font.FontWeight -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 com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.LocalWornExtras +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme +import com.github.worn.ui.theme.wornExtras +/** + * Gradient stops, resolved per theme. + * + * These are `@Composable` getters rather than top-level `val`s: captured in a `val` the stops + * would freeze to whichever theme was active at class-init time and never follow dark mode. + */ object WornGradients { - val Save = listOf(WornColors.SaveGradientStart, WornColors.SaveGradientEnd) - val Green = listOf(WornColors.AccentGreen, WornColors.AccentGreenDark) - val GreenCta = listOf(WornColors.AccentGreen, WornColors.AccentGreenEnd) - val Indigo = listOf(WornColors.AccentIndigo, Color(0xFF556070)) - val Disabled = listOf(WornColors.TextMuted, WornColors.IconMuted) + val Save: List + @Composable @ReadOnlyComposable + get() = LocalWornExtras.current.let { listOf(it.saveGradientStart, it.saveGradientEnd) } + val Green: List + @Composable @ReadOnlyComposable + get() = LocalWornExtras.current.let { listOf(it.greenCtaStart, it.accentGreenDark) } + val GreenCta: List + @Composable @ReadOnlyComposable + get() = LocalWornExtras.current.let { listOf(it.greenCtaStart, it.greenCtaEnd) } + val Indigo: List + @Composable @ReadOnlyComposable + get() = LocalWornExtras.current.let { listOf(it.indigoGradientStart, it.indigoGradientEnd) } + val Disabled: List + @Composable @ReadOnlyComposable + get() = listOf(MaterialTheme.wornExtras.textMuted, MaterialTheme.wornExtras.iconMuted) } @Composable @@ -43,7 +63,7 @@ fun WornGradientButton( enabled: Boolean = true, gradientColors: List = WornGradients.Save, disabledGradientColors: List = WornGradients.Disabled, - shape: Shape = RoundedCornerShape(16.dp), + shape: Shape = MaterialTheme.shapes.large, elevation: Dp = 0.dp, icon: (@Composable () -> Unit)? = null, fillMaxWidth: Boolean = true, @@ -83,16 +103,16 @@ fun WornGradientButton( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { icon() - Text(text, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(text, color = Color.White, style = MaterialTheme.typography.titleSmall) } } else { - Text(text, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(text, color = Color.White, style = MaterialTheme.typography.titleSmall) } } } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun WornGradientButtonPhonePreview() { WornTheme { @@ -102,7 +122,7 @@ private fun WornGradientButtonPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun WornGradientButtonTabletPreview() { WornTheme { @@ -111,3 +131,4 @@ private fun WornGradientButtonTabletPreview() { } } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/WornTopAppBar.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornTopAppBar.kt new file mode 100644 index 0000000..14c5c04 --- /dev/null +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/WornTopAppBar.kt @@ -0,0 +1,71 @@ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) + +package com.github.worn.ui.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * The screen-level app bar: one title, an optional subtitle, optional actions. + * + * Medium rather than Large, with heights tightened below the M3 defaults. The default large bar + * reserves 152dp expanded, of which the top 64dp is the row that would hold a navigation icon — + * and none of these screens have one, so it reads as a large empty gap above the title. These + * screens are tab destinations with nowhere to navigate back to. + * + * [COLLAPSED_HEIGHT] is still tall enough for the Outfits "Create" action, which is the only + * thing that ever occupies that row. + */ +@Composable +fun WornTopAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, + subtitle: String? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + MediumFlexibleTopAppBar( + title = { Text(title, modifier = TITLE_GUTTER_NUDGE) }, + subtitle = subtitle?.let { { Text(it, modifier = TITLE_GUTTER_NUDGE) } }, + actions = actions, + collapsedHeight = COLLAPSED_HEIGHT, + expandedHeight = if (subtitle != null) EXPANDED_HEIGHT else EXPANDED_HEIGHT_NO_SUBTITLE, + // Same colour for the container and its scrolled state: the screens are a single tinted + // sheet, so a distinct bar surface would cut them in half, and M3's elevation tint on + // scroll would introduce a colour the palette does not contain. + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + subtitleContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + scrollBehavior = scrollBehavior, + modifier = modifier, + ) +} + +// The bar bottom-aligns its title block, so the space above the title is +// expandedHeight minus the text's own height. These are sized so that gap lands near the ~24dp +// the hand-built headers used to have, rather than the ~96dp the M3 large-bar defaults produce. +private val COLLAPSED_HEIGHT = 48.dp +private val EXPANDED_HEIGHT = 88.dp +private val EXPANDED_HEIGHT_NO_SUBTITLE = 68.dp + +/** + * Nudges the title out to the screens' 24dp content gutter. + * + * M3 indents it 16dp, which leaves it 8dp left of the chips and cards below. The bar exposes no + * title-padding parameter, so the offset goes on the title content itself. + */ +private val TITLE_GUTTER_NUDGE = Modifier.padding(start = 8.dp) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt index 037dbb0..3fce386 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.outlined.PhotoLibrary import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -44,7 +45,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R @@ -55,9 +55,7 @@ import com.github.worn.domain.model.Fit import com.github.worn.domain.model.Material import com.github.worn.domain.model.Season import com.github.worn.domain.model.Subcategory -import com.github.worn.ui.exposeTestTagsAsResourceId import com.github.worn.ui.components.AiBadge -import com.github.worn.ui.components.SheetDragHandle import com.github.worn.ui.components.AiLockedSheet import com.github.worn.ui.components.CategoryDropdown import com.github.worn.ui.components.ColorSection @@ -70,13 +68,17 @@ import com.github.worn.ui.components.PhotoUploadZone import com.github.worn.ui.components.RemoveBackgroundToggle import com.github.worn.ui.components.SaveButton import com.github.worn.ui.components.SeasonSection +import com.github.worn.ui.components.SheetDragHandle import com.github.worn.ui.components.SubcategoryDropdown +import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.theme.PhonePreview import com.github.worn.ui.theme.SheetPreview +import com.github.worn.ui.theme.TabletPreview +import com.github.worn.ui.theme.sheetShape import com.github.worn.ui.util.decodePreviewImage import com.github.worn.ui.util.readImageBytes import com.github.worn.ui.util.rememberCameraCapture import com.github.worn.ui.util.rememberDecodedImage -import com.github.worn.ui.theme.WornColors import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -99,8 +101,8 @@ fun AddItemSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, dragHandle = { SheetDragHandle() }, ) { AddItemForm( @@ -355,9 +357,8 @@ private fun AddItemFormContent( ) { Text( text = stringResource(if (isEditing) R.string.add_item_title_edit else R.string.add_item_title), - color = WornColors.TextPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, letterSpacing = (-0.5).sp, ) PhotoUploadZone( @@ -440,7 +441,7 @@ private fun PhotoSourceDialog( ) { Icon(Icons.Outlined.CameraAlt, contentDescription = null, modifier = Modifier.size(24.dp)) Spacer(Modifier.width(12.dp)) - Text(stringResource(R.string.add_item_take_photo), fontSize = 16.sp) + Text(stringResource(R.string.add_item_take_photo), style = MaterialTheme.typography.titleSmall) } } TextButton( @@ -454,7 +455,10 @@ private fun PhotoSourceDialog( ) { Icon(Icons.Outlined.PhotoLibrary, contentDescription = null, modifier = Modifier.size(24.dp)) Spacer(Modifier.width(12.dp)) - Text(stringResource(R.string.add_item_choose_gallery), fontSize = 16.sp) + Text( + stringResource(R.string.add_item_choose_gallery), + style = MaterialTheme.typography.titleSmall, + ) } } } @@ -470,14 +474,16 @@ private inline fun toggleInSet(item: T, current: Set, update: (Set) -> update(if (item in current) current - item else current + item) } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun AddItemFormPhonePreview() { SheetPreview { AddItemForm() } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun AddItemFormTabletPreview() { SheetPreview { AddItemForm() } } + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt index 1074c4a..dee7159 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -36,6 +37,7 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,26 +47,29 @@ 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.Shape import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R import com.github.worn.domain.model.Category import com.github.worn.domain.model.ClothingItem import com.github.worn.domain.model.Outfit -import com.github.worn.ui.exposeTestTagsAsResourceId import com.github.worn.ui.components.CategoryFilterChips +import com.github.worn.ui.components.ClothingPhoto import com.github.worn.ui.components.SelectionIndicator import com.github.worn.ui.components.SheetDragHandle import com.github.worn.ui.components.WornGradientButton import com.github.worn.ui.components.WornGradients +import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.theme.PhonePreview import com.github.worn.ui.theme.SheetPreview -import com.github.worn.ui.components.ClothingPhoto -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.TabletPreview +import com.github.worn.ui.theme.sheetShape +import com.github.worn.ui.theme.wornExtras @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -84,8 +89,8 @@ fun CreateOutfitSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, dragHandle = { SheetDragHandle() }, ) { CreateOutfitForm( @@ -128,9 +133,8 @@ internal fun CreateOutfitForm( ) { Text( text = stringResource(if (isEditing) R.string.create_outfit_title_edit else R.string.create_outfit_title), - color = WornColors.TextPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, letterSpacing = (-0.5).sp, ) OutfitNameField( @@ -163,20 +167,20 @@ private fun OutfitNameField(name: String, onNameChange: (String) -> Unit, modifi placeholder = { Text( stringResource(R.string.create_outfit_name_hint), - color = WornColors.IconMuted, - fontSize = 15.sp, + color = MaterialTheme.wornExtras.iconMuted, + style = MaterialTheme.typography.bodyMedium, ) }, colors = TextFieldDefaults.colors( - focusedContainerColor = WornColors.BgCard, - unfocusedContainerColor = WornColors.BgCard, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, modifier = modifier .fillMaxWidth() - .border(1.dp, WornColors.BorderSubtle, RoundedCornerShape(12.dp)), + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.medium), ) } @@ -189,16 +193,14 @@ private fun SelectItemsHeader(selectedCount: Int) { ) { Text( text = stringResource(R.string.create_outfit_select_items), - color = WornColors.TextPrimary, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, ) if (selectedCount > 0) { Text( text = pluralStringResource(R.plurals.selected_count, selectedCount, selectedCount), - color = WornColors.AccentGreen, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, ) } } @@ -227,7 +229,8 @@ private fun ItemSelectionGrid( } } -private val cellShape = RoundedCornerShape(16.dp) +private val cellShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.large // The cell is the photo alone: a name label here only ever sat on top of the garment, where it was // unreadable. The photo carries the item's name as its content description for screen readers. @@ -246,7 +249,7 @@ private fun SelectableItemCell( .clip(cellShape) .border( width = if (isSelected) 2.dp else 1.dp, - color = if (isSelected) WornColors.AccentGreen else WornColors.BorderSubtle, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, shape = cellShape, ) .clickable(onClick = onClick), @@ -258,7 +261,12 @@ private fun SelectableItemCell( @Composable private fun ItemThumbnail(item: ClothingItem) { - Surface(shape = cellShape, color = WornColors.BgCard, shadowElevation = 4.dp, modifier = Modifier.fillMaxSize()) { + Surface( + shape = cellShape, + color = MaterialTheme.colorScheme.surfaceContainer, + shadowElevation = 4.dp, + modifier = Modifier.fillMaxSize(), + ) { ClothingPhoto( photoPath = item.photoPath, contentDescription = item.name, @@ -299,7 +307,7 @@ private val previewItems = listOf( ClothingItem("6", "Chinos", Category.BOTTOM, listOf("khaki"), photoPath = "", createdAt = 0), ) -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun CreateOutfitFormPhonePreview() { SheetPreview { @@ -310,7 +318,7 @@ private fun CreateOutfitFormPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun CreateOutfitFormTabletPreview() { SheetPreview { @@ -320,3 +328,6 @@ private fun CreateOutfitFormTabletPreview() { ) } } + + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt index a72d99a..8311272 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt @@ -1,12 +1,16 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) + @file:Suppress("TooManyFunctions") package com.github.worn.ui.screen import android.widget.Toast +import androidx.annotation.StringRes 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 @@ -24,39 +28,41 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.AutoAwesome -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable 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.annotation.StringRes import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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.sp -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.window.core.layout.WindowWidthSizeClass import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowWidthSizeClass import com.github.worn.R import com.github.worn.domain.model.Category import com.github.worn.domain.model.GapRecommendation @@ -65,20 +71,26 @@ import com.github.worn.presentation.viewmodel.GapsEffect import com.github.worn.presentation.viewmodel.GapsIntent import com.github.worn.presentation.viewmodel.GapsState import com.github.worn.presentation.viewmodel.GapsViewModel -import com.github.worn.ui.exposeTestTagsAsResourceId import com.github.worn.ui.components.AiLockedSheet import com.github.worn.ui.components.ErrorContentView import com.github.worn.ui.components.SheetDragHandle -import com.github.worn.ui.components.WornGradientButton import com.github.worn.ui.components.Tab +import com.github.worn.ui.components.WornGradientButton +import com.github.worn.ui.components.WornTopAppBar import com.github.worn.ui.components.displayLabel import com.github.worn.ui.components.displayName import com.github.worn.ui.components.iconRes -import com.github.worn.ui.theme.WornColors -import com.github.worn.ui.theme.WornDimens +import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme +import com.github.worn.ui.theme.sheetShape +import com.github.worn.ui.theme.wornExtras import org.koin.compose.viewmodel.koinViewModel +/** Gap between the app-bar header and the first card, matched to the iOS Gaps header. */ +private val HEADER_CONTENT_GAP = 20.dp + @Composable fun GapsScreen(onTabSelected: (Tab) -> Unit) { val viewModel: GapsViewModel = koinViewModel() @@ -174,32 +186,32 @@ private fun GapsScaffold( ) { val contentPadding = if (isCompact) 24.dp else 32.dp + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( - modifier = Modifier.testTag("gaps_screen"), - containerColor = WornColors.BgPage, + modifier = Modifier + .testTag("gaps_screen") + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + // Title and subtitle unchanged: journeys/gaps-common-suggestions.xml asserts on them. + WornTopAppBar( + title = stringResource(R.string.gaps_title), + subtitle = stringResource(R.string.gaps_subtitle), + scrollBehavior = scrollBehavior, + ) + }, ) { paddingValues -> LazyColumn( modifier = Modifier .fillMaxSize() .padding(paddingValues) .padding(horizontal = contentPadding), + // Restores the gap the old inline header carried as a trailing Spacer before it moved + // into the app bar. contentPadding rather than a Spacer item so it scrolls away with + // the content and matches the 20pt the iOS Gaps header uses. + contentPadding = PaddingValues(top = HEADER_CONTENT_GAP), ) { - item(key = "header") { - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.gaps_title), - color = WornColors.TextPrimary, - fontSize = 28.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.5).sp, - ) - Text( - text = stringResource(R.string.gaps_subtitle), - color = WornColors.TextSecondary, - fontSize = 14.sp, - ) - Spacer(Modifier.height(20.dp)) - } when { state.isLoading -> item(key = "loading") { LoadingContent() } @@ -219,7 +231,6 @@ private fun GapsScaffold( } item(key = "bottom_clearance") { - Spacer(Modifier.height(WornDimens.BottomBarClearance)) } } } @@ -231,7 +242,7 @@ private fun LoadingContent() { contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().padding(vertical = 80.dp), ) { - CircularProgressIndicator(color = WornColors.AccentGreen) + LoadingIndicator(color = MaterialTheme.colorScheme.primary) } } @@ -246,27 +257,26 @@ private fun CompleteContent() { modifier = Modifier .size(72.dp) .clip(CircleShape) - .background(WornColors.BgElevated), + .background(MaterialTheme.colorScheme.surfaceContainerHigh), ) { Icon( imageVector = Icons.Default.Check, contentDescription = null, - tint = WornColors.AccentGreen, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp), ) } Spacer(Modifier.height(24.dp)) Text( text = stringResource(R.string.gaps_complete_title), - color = WornColors.TextPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, ) Spacer(Modifier.height(8.dp)) Text( text = stringResource(R.string.gaps_complete_description), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, lineHeight = 20.sp, ) } @@ -302,10 +312,10 @@ private fun LazyListScope.gapsContent( @Composable private fun GapsBanner(isAiMode: Boolean, onClick: () -> Unit) { - val bgColor = if (isAiMode) WornColors.AccentGreen else WornColors.AccentGreenDark + val bgColor = if (isAiMode) MaterialTheme.colorScheme.primary else MaterialTheme.wornExtras.accentGreenDark Surface( onClick = onClick, - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, color = bgColor, modifier = Modifier.testTag("gaps_banner"), ) { @@ -321,13 +331,12 @@ private fun GapsBanner(isAiMode: Boolean, onClick: () -> Unit) { Text( text = stringResource(titleRes), color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, ) Text( text = stringResource(subtitleRes), color = Color.White.copy(alpha = 0.8f), - fontSize = 13.sp, + style = MaterialTheme.typography.labelLarge, ) } Spacer(Modifier.width(12.dp)) @@ -345,9 +354,8 @@ private fun GapsBanner(isAiMode: Boolean, onClick: () -> Unit) { private fun SectionLabel(text: String) { Text( text = text.uppercase(), - color = WornColors.TextSecondary, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, letterSpacing = 0.5.sp, ) } @@ -360,8 +368,8 @@ private fun GapCard( ) { Surface( onClick = onClick, - shape = RoundedCornerShape(12.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -372,8 +380,8 @@ private fun GapCard( Column(modifier = Modifier.weight(1f)) { Text( text = recommendation.itemName, - color = WornColors.TextPrimary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) Text( @@ -382,14 +390,14 @@ private fun GapCard( } else { stringResource(R.string.gaps_pairing_common) }, - color = WornColors.TextSecondary, - fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, ) } Icon( imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(18.dp), ) } @@ -403,7 +411,7 @@ private fun CategoryIcon(category: Category) { contentAlignment = Alignment.Center, modifier = Modifier .size(36.dp) - .clip(RoundedCornerShape(10.dp)) + .clip(MaterialTheme.shapes.medium) .background(color), ) { Icon( @@ -415,12 +423,14 @@ private fun CategoryIcon(category: Category) { } } +@Composable +@ReadOnlyComposable private fun Category.dotColor(): Color = when (this) { - Category.TOP -> WornColors.CategoryDotTop - Category.BOTTOM -> WornColors.CategoryDotBottom - Category.OUTERWEAR -> WornColors.CategoryDotOuterwear - Category.SHOES -> WornColors.CategoryDotShoes - Category.ACCESSORY -> WornColors.CategoryDotAccessory + Category.TOP -> MaterialTheme.wornExtras.categoryDotTop + Category.BOTTOM -> MaterialTheme.wornExtras.categoryDotBottom + Category.OUTERWEAR -> MaterialTheme.wornExtras.categoryDotOuterwear + Category.SHOES -> MaterialTheme.wornExtras.categoryDotShoes + Category.ACCESSORY -> MaterialTheme.wornExtras.categoryDotAccessory } // region Detail Sheet @@ -438,9 +448,9 @@ private fun GapDetailSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), - dragHandle = { SheetDragHandle(color = WornColors.BorderStrong) }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, + dragHandle = { SheetDragHandle(color = MaterialTheme.colorScheme.outline) }, ) { GapDetailContent( recommendation = recommendation, @@ -481,22 +491,21 @@ private fun DetailHeader(recommendation: GapRecommendation) { modifier = Modifier .fillMaxWidth() .height(140.dp) - .clip(RoundedCornerShape(16.dp)) - .background(WornColors.BgCard), + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainer), ) { Icon( painter = painterResource(recommendation.mappedCategory.iconRes()), contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(48.dp), ) } Spacer(Modifier.height(16.dp)) Text( text = recommendation.itemName, - color = WornColors.TextPrimary, - fontSize = 22.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, ) Spacer(Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -509,8 +518,8 @@ private fun DetailHeader(recommendation: GapRecommendation) { Spacer(Modifier.width(6.dp)) Text( text = recommendation.mappedCategory.displayLabel(), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, ) } } @@ -518,8 +527,8 @@ private fun DetailHeader(recommendation: GapRecommendation) { @Composable private fun DetailPairingInfo(recommendation: GapRecommendation, isAiMode: Boolean) { Surface( - shape = RoundedCornerShape(8.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.fillMaxWidth(), ) { Row( @@ -529,7 +538,7 @@ private fun DetailPairingInfo(recommendation: GapRecommendation, isAiMode: Boole Icon( imageVector = Icons.Outlined.AutoAwesome, contentDescription = null, - tint = WornColors.AccentGreen, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp), ) Spacer(Modifier.width(8.dp)) @@ -539,8 +548,8 @@ private fun DetailPairingInfo(recommendation: GapRecommendation, isAiMode: Boole } else { stringResource(R.string.gaps_pairing_common) }, - color = WornColors.TextSecondary, - fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, ) } } @@ -581,8 +590,13 @@ private fun DetailRow(label: String, value: String) { modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(label, color = WornColors.TextSecondary, fontSize = 14.sp) - Text(value, color = WornColors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + Text( + value, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) } } @@ -596,8 +610,8 @@ private fun DetailActions(onAddToWardrobe: () -> Unit, onDismiss: () -> Unit) { Spacer(Modifier.height(8.dp)) Surface( onClick = onDismiss, - shape = RoundedCornerShape(16.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.fillMaxWidth().testTag("gap_dismiss"), ) { Box( @@ -606,8 +620,8 @@ private fun DetailActions(onAddToWardrobe: () -> Unit, onDismiss: () -> Unit) { ) { Text( stringResource(R.string.gaps_dismiss), - color = WornColors.TextSecondary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) } @@ -638,7 +652,7 @@ private fun GapRecommendation.toPreFilledItem() = createdAt = 0L, ) -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun GapsScreenPhonePreview() { WornTheme { @@ -652,7 +666,7 @@ private fun GapsScreenPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun GapsScreenTabletPreview() { WornTheme { @@ -667,7 +681,7 @@ private fun GapsScreenTabletPreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun GapsScreenCompletePreview() { WornTheme { @@ -675,7 +689,7 @@ private fun GapsScreenCompletePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun GapsScreenErrorPreview() { WornTheme { @@ -688,3 +702,8 @@ private fun GapsScreenErrorPreview() { ) } } + + + + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt index 527a2b9..e9f0fb1 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/ItemDetailSheet.kt @@ -24,7 +24,9 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -39,30 +41,31 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.R import com.github.worn.domain.model.Category import com.github.worn.domain.model.ClothingItem import com.github.worn.domain.model.Fit import com.github.worn.domain.model.Material import com.github.worn.domain.model.Season import com.github.worn.domain.model.Subcategory -import androidx.compose.ui.res.stringResource -import com.github.worn.R +import com.github.worn.ui.components.ClothingPhoto import com.github.worn.ui.components.PropertyRow import com.github.worn.ui.components.SheetDragHandle import com.github.worn.ui.components.addItemColorPalette import com.github.worn.ui.components.displayLabel import com.github.worn.ui.components.displayName import com.github.worn.ui.components.dotColor +import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.theme.PhonePreview import com.github.worn.ui.theme.SheetPreview -import com.github.worn.ui.components.ClothingPhoto -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.TabletPreview +import com.github.worn.ui.theme.sheetShape @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -79,9 +82,9 @@ fun ItemDetailSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), - dragHandle = { SheetDragHandle(color = WornColors.BorderStrong) }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, + dragHandle = { SheetDragHandle(color = MaterialTheme.colorScheme.outline) }, ) { ItemDetailContent( item = item, @@ -101,7 +104,7 @@ internal fun ItemDetailContent( onDelete: (String) -> Unit = {}, showActions: Boolean = true, ) { - val dims = ItemDetailDimens(isCompact) + val dims = itemDetailDimens(isCompact) var showDeleteDialog by remember { mutableStateOf(false) } Column( @@ -115,15 +118,15 @@ internal fun ItemDetailContent( verticalArrangement = Arrangement.spacedBy(dims.sectionGap), ) { ItemPhoto(item = item, dims = dims) - ItemNameGroup(item = item, nameSize = dims.nameSize) + ItemNameGroup(item = item, nameStyle = dims.nameStyle) HorizontalDivider() - ItemProperties(item = item, fontSize = dims.propFontSize, gap = dims.propGap) + ItemProperties(item = item, textStyle = dims.propStyle, gap = dims.propGap) if (showActions) { DetailActionButtons( editLabel = stringResource(R.string.item_detail_edit), deleteLabel = stringResource(R.string.item_detail_delete), buttonHeight = dims.buttonHeight, - buttonFontSize = dims.buttonFontSize, + buttonStyle = dims.buttonStyle, onEdit = { onEdit(item) }, onDelete = { showDeleteDialog = true }, editTestTag = "item_detail_edit", @@ -141,25 +144,55 @@ internal fun ItemDetailContent( } } -private data class ItemDetailDimens(val isCompact: Boolean) { - val contentPadding: Dp = if (isCompact) 24.dp else 32.dp - val sectionGap: Dp = if (isCompact) 20.dp else 24.dp - val photoHeight: Dp = if (isCompact) 280.dp else 360.dp - val photoRadius: Dp = if (isCompact) 20.dp else 24.dp - val nameSize: TextUnit = if (isCompact) 22.sp else 26.sp - val propFontSize: TextUnit = if (isCompact) 14.sp else 15.sp - val propGap: Dp = if (isCompact) 14.dp else 16.dp - val buttonHeight: Dp = if (isCompact) 48.dp else 52.dp - val buttonFontSize: TextUnit = if (isCompact) 15.sp else 16.sp - val placeholderIconSize: Dp = if (isCompact) 64.dp else 80.dp -} +private data class ItemDetailDimens( + val contentPadding: Dp, + val sectionGap: Dp, + val photoHeight: Dp, + val photoRadius: Dp, + val nameStyle: TextStyle, + val propStyle: TextStyle, + val propGap: Dp, + val buttonHeight: Dp, + val buttonStyle: TextStyle, + val placeholderIconSize: Dp, +) + +/** + * Composable rather than a plain constructor so the text styles come from the shared type scale + * instead of loose `sp` literals; only the spacing still varies by raw dimension. + */ +@Composable +private fun itemDetailDimens(isCompact: Boolean): ItemDetailDimens = ItemDetailDimens( + contentPadding = if (isCompact) 24.dp else 32.dp, + sectionGap = if (isCompact) 20.dp else 24.dp, + photoHeight = if (isCompact) 280.dp else 360.dp, + photoRadius = if (isCompact) 20.dp else 24.dp, + nameStyle = if (isCompact) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.headlineSmall + }, + propStyle = if (isCompact) { + MaterialTheme.typography.bodySmall + } else { + MaterialTheme.typography.bodyMedium + }, + propGap = if (isCompact) 14.dp else 16.dp, + buttonHeight = if (isCompact) 48.dp else 52.dp, + buttonStyle = if (isCompact) { + MaterialTheme.typography.bodyMedium + } else { + MaterialTheme.typography.titleSmall + }, + placeholderIconSize = if (isCompact) 64.dp else 80.dp, +) @Composable private fun ItemPhoto(item: ClothingItem, dims: ItemDetailDimens) { Surface( shape = RoundedCornerShape(dims.photoRadius), - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 8.dp, modifier = Modifier.fillMaxWidth().height(dims.photoHeight), ) { @@ -173,12 +206,12 @@ private fun ItemPhoto(item: ClothingItem, dims: ItemDetailDimens) { } @Composable -private fun ItemNameGroup(item: ClothingItem, nameSize: TextUnit) { +private fun ItemNameGroup(item: ClothingItem, nameStyle: TextStyle) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = item.name, - color = WornColors.TextPrimary, - fontSize = nameSize, + color = MaterialTheme.colorScheme.onSurface, + style = nameStyle, fontWeight = FontWeight.SemiBold, ) Row( @@ -193,8 +226,8 @@ private fun ItemNameGroup(item: ClothingItem, nameSize: TextUnit) { ) Text( text = item.category.displayLabel(), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, ) } } @@ -206,15 +239,15 @@ private fun HorizontalDivider() { modifier = Modifier .fillMaxWidth() .height(1.dp) - .background(WornColors.BorderSubtle), + .background(MaterialTheme.colorScheme.outlineVariant), ) } @Composable -private fun ItemProperties(item: ClothingItem, fontSize: TextUnit, gap: Dp) { +private fun ItemProperties(item: ClothingItem, textStyle: TextStyle, gap: Dp) { Column(verticalArrangement = Arrangement.spacedBy(gap)) { if (item.colors.isNotEmpty()) { - ColorPropertyRow(item = item, fontSize = fontSize) + ColorPropertyRow(item = item, textStyle = textStyle) } if (item.seasons.isNotEmpty()) { val seasonText = if (item.seasons.size == Season.entries.size) { @@ -222,26 +255,30 @@ private fun ItemProperties(item: ClothingItem, fontSize: TextUnit, gap: Dp) { } else { item.seasons.map { it.displayName() }.joinToString(", ") } - PropertyRow(label = stringResource(R.string.label_season), value = seasonText, fontSize = fontSize) + PropertyRow(label = stringResource(R.string.label_season), value = seasonText, textStyle = textStyle) } item.fit?.let { - PropertyRow(label = stringResource(R.string.label_fit), value = it.displayName(), fontSize = fontSize) + PropertyRow(label = stringResource(R.string.label_fit), value = it.displayName(), textStyle = textStyle) } item.subcategory?.let { PropertyRow( label = stringResource(R.string.label_subcategory), value = it.displayName(), - fontSize = fontSize, + textStyle = textStyle, ) } item.material?.let { - PropertyRow(label = stringResource(R.string.label_material), value = it.displayName(), fontSize = fontSize) + PropertyRow( + label = stringResource(R.string.label_material), + value = it.displayName(), + textStyle = textStyle, + ) } } } @Composable -private fun ColorPropertyRow(item: ClothingItem, fontSize: TextUnit) { +private fun ColorPropertyRow(item: ClothingItem, textStyle: TextStyle) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -249,21 +286,21 @@ private fun ColorPropertyRow(item: ClothingItem, fontSize: TextUnit) { ) { Text( stringResource(R.string.label_color), - color = WornColors.TextSecondary, - fontSize = fontSize, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = textStyle, fontWeight = FontWeight.Medium, ) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Surface( shape = CircleShape, color = colorForName(item.colors.first()), - border = BorderStroke(1.dp, WornColors.BorderSubtle), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = Modifier.size(14.dp), ) {} Text( text = item.colors.joinToString(", ") { it.replaceFirstChar(Char::uppercase) }, - color = WornColors.TextPrimary, - fontSize = fontSize, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, fontWeight = FontWeight.Medium, ) } @@ -276,36 +313,36 @@ internal fun DetailActionButtons( editLabel: String, deleteLabel: String, buttonHeight: Dp, - buttonFontSize: TextUnit, + buttonStyle: TextStyle, onEdit: () -> Unit, onDelete: () -> Unit, editTestTag: String, deleteTestTag: String, ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( + // Edit is the primary action, so it takes the filled button. Delete was previously the + // filled one — a full-width solid red block ranked *below* a plain white Edit — which gave + // the destructive action more visual weight than the thing people actually came to do. + Button( onClick = onEdit, - shape = RoundedCornerShape(24.dp), - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.fillMaxWidth().height(buttonHeight).testTag(editTestTag), ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Text( - editLabel, color = WornColors.TextPrimary, - fontSize = buttonFontSize, fontWeight = FontWeight.SemiBold, - ) - } + Text(editLabel, style = buttonStyle, fontWeight = FontWeight.SemiBold) } - Surface( + // Outlined rather than filled: still unmistakably destructive through the error colour, + // without shouting. Using `error` for content instead of a hardcoded white-on-red also + // survives dark mode, where `error` is a light #F2B8AC and white on it is unreadable. + OutlinedButton( onClick = onDelete, - shape = RoundedCornerShape(24.dp), - color = WornColors.DeleteRed, + shape = MaterialTheme.shapes.extraLarge, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth().height(buttonHeight).testTag(deleteTestTag), ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Text(deleteLabel, color = Color.White, fontSize = buttonFontSize, fontWeight = FontWeight.SemiBold) - } + Text(deleteLabel, style = buttonStyle, fontWeight = FontWeight.SemiBold) } } } @@ -318,20 +355,22 @@ private fun DeleteItemDialog(itemName: String, onConfirm: () -> Unit, onDismiss: Text( stringResource(R.string.item_detail_delete_dialog_title), fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, + style = MaterialTheme.typography.titleLarge, ) }, text = { Text( stringResource(R.string.item_detail_delete_dialog_message, itemName), - color = WornColors.TextSecondary, fontSize = 15.sp, lineHeight = 22.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + lineHeight = 22.sp, ) }, confirmButton = { Button( onClick = onConfirm, - colors = ButtonDefaults.buttonColors(containerColor = WornColors.DeleteRed), - shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + shape = MaterialTheme.shapes.extraLarge, ) { Text(stringResource(R.string.common_delete), fontWeight = FontWeight.SemiBold) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, @@ -350,14 +389,17 @@ private val previewItem = ClothingItem( photoPath = "", createdAt = 0, ) -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun ItemDetailSheetPhonePreview() { SheetPreview { ItemDetailContent(item = previewItem, isCompact = true) } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun ItemDetailSheetTabletPreview() { SheetPreview { ItemDetailContent(item = previewItem, isCompact = false) } } + + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitDetailSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitDetailSheet.kt index d0933cb..8582865 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitDetailSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitDetailSheet.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -36,27 +37,28 @@ 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.platform.LocalContext import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource import com.github.worn.R import com.github.worn.domain.model.Category import com.github.worn.domain.model.ClothingItem import com.github.worn.domain.model.Outfit import com.github.worn.domain.model.Season -import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.components.ClothingPhoto import com.github.worn.ui.components.PropertyRow import com.github.worn.ui.components.SheetDragHandle +import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.theme.PhonePreview import com.github.worn.ui.theme.SheetPreview -import com.github.worn.ui.components.ClothingPhoto -import com.github.worn.ui.theme.WornColors +import com.github.worn.ui.theme.TabletPreview +import com.github.worn.ui.theme.sheetShape @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,9 +75,9 @@ fun OutfitDetailSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), - dragHandle = { SheetDragHandle(color = WornColors.BorderStrong) }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, + dragHandle = { SheetDragHandle(color = MaterialTheme.colorScheme.outline) }, ) { OutfitDetailContent( outfit = outfit, @@ -111,12 +113,20 @@ internal fun OutfitDetailContent( .padding(bottom = 36.dp), verticalArrangement = Arrangement.spacedBy(sectionGap), ) { - OutfitTitle(name = outfit.name, nameSize = if (isCompact) 22.sp else 26.sp, padding = contentPadding) + OutfitTitle( + name = outfit.name, + nameStyle = if (isCompact) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.headlineSmall + }, + padding = contentPadding, + ) OutfitItemsPreview(items = outfitItems, isCompact = isCompact, contentPadding = contentPadding) if (!isCompact) { Box( modifier = Modifier.fillMaxWidth().padding(horizontal = contentPadding) - .height(1.dp).background(WornColors.BorderSubtle), + .height(1.dp).background(MaterialTheme.colorScheme.outlineVariant), ) } OutfitProperties(outfit = outfit, items = outfitItems, isCompact = isCompact, padding = contentPadding) @@ -125,7 +135,11 @@ internal fun OutfitDetailContent( editLabel = stringResource(R.string.outfit_detail_edit), deleteLabel = stringResource(R.string.outfit_detail_delete), buttonHeight = if (isCompact) 48.dp else 52.dp, - buttonFontSize = if (isCompact) 15.sp else 16.sp, + buttonStyle = if (isCompact) { + MaterialTheme.typography.bodyMedium + } else { + MaterialTheme.typography.titleSmall + }, onEdit = { onEdit(outfit) }, onDelete = { showDeleteDialog = true }, editTestTag = "outfit_detail_edit", @@ -144,11 +158,11 @@ internal fun OutfitDetailContent( } @Composable -private fun OutfitTitle(name: String, nameSize: TextUnit, padding: Dp) { +private fun OutfitTitle(name: String, nameStyle: TextStyle, padding: Dp) { Text( text = name, - color = WornColors.TextPrimary, - fontSize = nameSize, + color = MaterialTheme.colorScheme.onSurface, + style = nameStyle, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = padding), ) @@ -172,7 +186,11 @@ private fun OutfitItemsPreview(items: List, isCompact: Boolean, co @Composable private fun OutfitProperties(outfit: Outfit, items: List, isCompact: Boolean, padding: Dp) { - val propFontSize = if (isCompact) 14.sp else 15.sp + val propStyle = if (isCompact) { + MaterialTheme.typography.bodySmall + } else { + MaterialTheme.typography.bodyMedium + } val propGap = if (isCompact) 14.dp else 16.dp Column( @@ -182,12 +200,12 @@ private fun OutfitProperties(outfit: Outfit, items: List, isCompac PropertyRow( label = stringResource(R.string.label_items), value = stringResource(R.string.outfit_detail_items_count, outfit.itemIds.size), - fontSize = propFontSize, + textStyle = propStyle, ) PropertyRow( label = stringResource(R.string.label_season), value = deriveSeasonText(items), - fontSize = propFontSize, + textStyle = propStyle, ) } } @@ -200,20 +218,22 @@ private fun DeleteOutfitDialog(outfitName: String, onConfirm: () -> Unit, onDism Text( stringResource(R.string.outfit_detail_delete_dialog_title), fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, + style = MaterialTheme.typography.titleLarge, ) }, text = { Text( stringResource(R.string.outfit_detail_delete_dialog_message, outfitName), - color = WornColors.TextSecondary, fontSize = 15.sp, lineHeight = 22.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + lineHeight = 22.sp, ) }, confirmButton = { Button( onClick = onConfirm, - colors = ButtonDefaults.buttonColors(containerColor = WornColors.DeleteRed), - shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + shape = MaterialTheme.shapes.extraLarge, ) { Text(stringResource(R.string.common_delete), fontWeight = FontWeight.SemiBold) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, @@ -232,8 +252,8 @@ private fun OutfitItemCard( ) { Surface( shape = RoundedCornerShape(cornerRadius), - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 8.dp, modifier = Modifier.size(size), ) { @@ -246,9 +266,8 @@ private fun OutfitItemCard( } Text( text = item.name, - color = WornColors.TextPrimary, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, ) } } @@ -289,7 +308,7 @@ private val previewOutfit = Outfit( createdAt = 1_710_460_800_000, ) -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun OutfitDetailSheetPhonePreview() { SheetPreview { @@ -301,7 +320,7 @@ private fun OutfitDetailSheetPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun OutfitDetailSheetTabletPreview() { SheetPreview { @@ -312,3 +331,5 @@ private fun OutfitDetailSheetTabletPreview() { ) } } + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitsScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitsScreen.kt index 703a75b..aea0bb7 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitsScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/OutfitsScreen.kt @@ -1,3 +1,5 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + package com.github.worn.ui.screen import androidx.compose.foundation.background @@ -6,8 +8,8 @@ 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.Row 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 @@ -26,14 +28,20 @@ import androidx.compose.material.icons.outlined.Layers import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,12 +51,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource 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.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -60,14 +69,15 @@ import com.github.worn.presentation.viewmodel.OutfitIntent import com.github.worn.presentation.viewmodel.OutfitState import com.github.worn.presentation.viewmodel.OutfitViewModel import com.github.worn.ui.components.DeleteConfirmationDialog -import com.github.worn.ui.components.SelectionHeader import com.github.worn.ui.components.EmptyStateView import com.github.worn.ui.components.OutfitCard +import com.github.worn.ui.components.SelectionHeader +import com.github.worn.ui.components.Tab import com.github.worn.ui.components.WornGradientButton import com.github.worn.ui.components.WornGradients -import com.github.worn.ui.components.Tab -import com.github.worn.ui.theme.WornColors -import com.github.worn.ui.theme.WornDimens +import com.github.worn.ui.components.WornTopAppBar +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme import org.koin.compose.viewmodel.koinViewModel @@ -177,9 +187,30 @@ private fun OutfitsScaffold( val sectionGap = if (isCompact) 24.dp else 28.dp var showDeleteDialog by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( - modifier = Modifier.testTag("outfits_screen"), - containerColor = WornColors.BgPage, + modifier = Modifier + .testTag("outfits_screen") + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + floatingActionButton = { + if (!isSelectionMode && state.outfits.isNotEmpty()) { + CreateOutfitFab(onCreateClick, Modifier.testTag("outfits_create_button")) + } + }, + topBar = { + if (isSelectionMode) { + SelectionHeader( + count = state.selectedIds.size, + onCancel = onClearSelection, + onDelete = { showDeleteDialog = true }, + modifier = Modifier.padding(horizontal = contentPadding), + ) + } else { + OutfitsTopBar(outfitCount = state.outfits.size, scrollBehavior = scrollBehavior) + } + }, ) { paddingValues -> val isEmpty = !state.isLoading && state.outfits.isEmpty() @@ -189,20 +220,11 @@ private fun OutfitsScaffold( .padding(paddingValues) .padding(horizontal = contentPadding), ) { - if (isSelectionMode) { - SelectionHeader( - count = state.selectedIds.size, - onCancel = onClearSelection, - onDelete = { showDeleteDialog = true }, - ) - } else { - OutfitsHeader(outfitCount = state.outfits.size, onCreateClick = onCreateClick) - } if (isEmpty) { EmptyState(onCreateClick = onCreateClick) } else if (state.isLoading && state.outfits.isEmpty()) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(color = WornColors.AccentGreen) + LoadingIndicator(color = MaterialTheme.colorScheme.primary) } } else { Spacer(modifier = Modifier.height(sectionGap)) @@ -225,40 +247,41 @@ private fun OutfitsScaffold( } @Composable -private fun OutfitsHeader(outfitCount: Int, onCreateClick: () -> Unit = {}) { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, +private fun OutfitsTopBar(outfitCount: Int, scrollBehavior: TopAppBarScrollBehavior) { + // Title unchanged: journeys/create-first-outfit.xml asserts on it. + WornTopAppBar( + title = stringResource(R.string.outfits_title), + subtitle = if (outfitCount > 0) { + pluralStringResource(R.plurals.saved_combinations, outfitCount, outfitCount) + } else { + null + }, + scrollBehavior = scrollBehavior, + ) +} + +/** + * Create as a FAB rather than a top-bar action. + * + * As an action it sat alone in the app bar's otherwise empty leading row, reading as a button + * floating above the title. A FAB also matches Wardrobe's "Add item", so the two list screens + * now offer their primary action in the same place. + */ +@Composable +private fun CreateOutfitFab(onClick: () -> Unit, modifier: Modifier = Modifier) { + ExtendedFloatingActionButton( + onClick = onClick, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = MaterialTheme.shapes.extraLargeIncreased, + modifier = modifier, ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) Text( - text = stringResource(R.string.outfits_title), - color = WornColors.TextPrimary, - fontSize = if (outfitCount == 0) 22.sp else 28.sp, + text = stringResource(R.string.outfits_button_create), + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.5).sp, - ) - if (outfitCount > 0) { - Button( - onClick = onCreateClick, - colors = ButtonDefaults.buttonColors(containerColor = WornColors.AccentGreen), - shape = RoundedCornerShape(20.dp), - modifier = Modifier.testTag("outfits_create_button"), - ) { - Icon(Icons.Default.Add, contentDescription = null, Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.outfits_button_create), fontWeight = FontWeight.SemiBold, fontSize = 14.sp) - } - } - } - if (outfitCount > 0) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = pluralStringResource(R.plurals.saved_combinations, outfitCount, outfitCount), - color = WornColors.TextSecondary, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, ) } } @@ -274,7 +297,6 @@ private fun OutfitsContent( LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(bottom = WornDimens.BottomBarClearance), modifier = Modifier.fillMaxSize(), ) { items(state.outfits, key = { it.id }) { outfit -> @@ -295,8 +317,8 @@ private fun OutfitsContent( } } -private val CtaShape = RoundedCornerShape(28.dp) -private val CtaGradient = Brush.verticalGradient(listOf(WornColors.AccentGreen, WornColors.AccentGreenEnd)) +private val CtaShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.extraLargeIncreased @Composable private fun EmptyState(onCreateClick: () -> Unit = {}) { @@ -306,7 +328,7 @@ private fun EmptyState(onCreateClick: () -> Unit = {}) { imageVector = Icons.Outlined.Layers, contentDescription = null, modifier = Modifier.size(52.dp), - tint = WornColors.TextSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, title = stringResource(R.string.outfits_empty_title), @@ -323,7 +345,12 @@ private fun EmptyState(onCreateClick: () -> Unit = {}) { fixedHeight = null, contentPadding = PaddingValues(horizontal = 36.dp, vertical = 16.dp), icon = { - Icon(Icons.Default.Add, contentDescription = null, Modifier.size(18.dp), WornColors.BgPage) + Icon( + Icons.Default.Add, + contentDescription = null, + Modifier.size(18.dp), + MaterialTheme.colorScheme.surface, + ) }, ) }, @@ -337,7 +364,7 @@ private val previewOutfits = listOf( Outfit("3", "Evening Out", listOf("i1", "i2", "i3", "i4", "i5"), 1_709_856_000_000), ) -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun OutfitsPhonePreview() { WornTheme { @@ -348,7 +375,7 @@ private fun OutfitsPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun OutfitsSelectionPreview() { WornTheme { @@ -359,7 +386,7 @@ private fun OutfitsSelectionPreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun OutfitsEmptyPhonePreview() { WornTheme { @@ -370,7 +397,7 @@ private fun OutfitsEmptyPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun OutfitsTabletPreview() { WornTheme { @@ -381,7 +408,7 @@ private fun OutfitsTabletPreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun OutfitsEmptyTabletPreview() { WornTheme { @@ -391,3 +418,8 @@ private fun OutfitsEmptyTabletPreview() { ) } } + + + + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt index 3309357..6342270 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/SettingsScreen.kt @@ -1,7 +1,10 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) + @file:Suppress("TooManyFunctions") package com.github.worn.ui.screen +import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -32,6 +35,7 @@ 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.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -40,6 +44,8 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -49,25 +55,23 @@ 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.platform.testTag -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource 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 -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.window.core.layout.WindowWidthSizeClass import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowWidthSizeClass import com.github.worn.R import com.github.worn.domain.model.AgeRange import com.github.worn.domain.model.BodyType @@ -82,14 +86,17 @@ import com.github.worn.presentation.viewmodel.SettingsEffect import com.github.worn.presentation.viewmodel.SettingsIntent import com.github.worn.presentation.viewmodel.SettingsState import com.github.worn.presentation.viewmodel.SettingsViewModel -import com.github.worn.ui.exposeTestTagsAsResourceId import com.github.worn.ui.components.SheetDragHandle +import com.github.worn.ui.components.Tab import com.github.worn.ui.components.WornChip import com.github.worn.ui.components.WornGradientButton -import com.github.worn.ui.components.Tab -import com.github.worn.ui.theme.WornColors -import com.github.worn.ui.theme.WornDimens +import com.github.worn.ui.components.WornTopAppBar +import com.github.worn.ui.exposeTestTagsAsResourceId +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme +import com.github.worn.ui.theme.sheetShape +import com.github.worn.ui.theme.wornExtras import org.koin.compose.viewmodel.koinViewModel @Suppress("UnusedParameter") @@ -173,9 +180,19 @@ private fun SettingsScaffold( ) { val contentPadding = if (isCompact) 24.dp else 32.dp + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( - modifier = Modifier.testTag("settings_screen"), - containerColor = WornColors.BgPage, + modifier = Modifier + .testTag("settings_screen") + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + WornTopAppBar( + title = stringResource(R.string.settings_title), + scrollBehavior = scrollBehavior, + ) + }, ) { paddingValues -> Column( modifier = Modifier @@ -184,20 +201,11 @@ private fun SettingsScaffold( .padding(horizontal = contentPadding) .verticalScroll(rememberScrollState()), ) { - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.settings_title), - color = WornColors.TextPrimary, - fontSize = 28.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.5).sp, - ) - - Spacer(Modifier.height(28.dp)) + Spacer(Modifier.height(20.dp)) SectionLabel(stringResource(R.string.settings_section_profile)) Spacer(Modifier.height(10.dp)) SettingsCard( - icon = { SettingsIcon(color = WornColors.AccentGreen, icon = Icons.Outlined.Person) }, + icon = { SettingsIcon(color = MaterialTheme.colorScheme.primary, icon = Icons.Outlined.Person) }, title = stringResource(R.string.settings_your_profile), subtitle = state.userProfile.summaryText(), onClick = onProfileClick, @@ -209,7 +217,7 @@ private fun SettingsScaffold( 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) }, + icon = { SettingsIcon(color = MaterialTheme.colorScheme.primary, icon = Icons.Outlined.PhoneAndroid) }, title = stringResource(R.string.settings_on_device_ai_title), subtitle = stringResource(state.onDeviceAiAvailability.subtitleRes()), checked = state.onDeviceAiEnabled, @@ -219,7 +227,7 @@ private fun SettingsScaffold( ) Spacer(Modifier.height(10.dp)) SettingsCard( - icon = { SettingsIcon(color = WornColors.AccentIndigo, icon = Icons.Outlined.AutoAwesome) }, + icon = { SettingsIcon(color = MaterialTheme.colorScheme.secondary, icon = Icons.Outlined.AutoAwesome) }, title = stringResource(R.string.settings_api_key_title), subtitle = stringResource( if (state.hasApiKey) R.string.settings_api_key_connected else R.string.settings_api_key_required, @@ -229,7 +237,7 @@ private fun SettingsScaffold( ) Spacer(Modifier.height(10.dp)) SettingsCard( - icon = { SettingsIcon(color = WornColors.AccentIndigo, icon = Icons.Outlined.Checkroom) }, + icon = { SettingsIcon(color = MaterialTheme.colorScheme.secondary, icon = Icons.Outlined.Checkroom) }, title = stringResource(R.string.settings_youcam_title), subtitle = stringResource( if (state.hasYouCamKey) R.string.settings_youcam_connected else R.string.settings_youcam_required, @@ -246,7 +254,6 @@ private fun SettingsScaffold( Spacer(Modifier.height(24.dp)) DonationCard() - Spacer(Modifier.height(WornDimens.BottomBarClearance)) } } } @@ -255,9 +262,8 @@ private fun SettingsScaffold( private fun SectionLabel(text: String) { Text( text = text, - color = WornColors.TextSecondary, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, letterSpacing = 0.5.sp, ) } @@ -268,7 +274,7 @@ private fun SettingsIcon(color: Color, icon: androidx.compose.ui.graphics.vector contentAlignment = Alignment.Center, modifier = Modifier .size(40.dp) - .clip(RoundedCornerShape(12.dp)) + .clip(MaterialTheme.shapes.medium) .background(color), ) { Icon( @@ -298,8 +304,8 @@ private fun SettingsToggleCard( Surface( onClick = { onCheckedChange(!checked) }, enabled = enabled, - shape = RoundedCornerShape(16.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, modifier = modifier, ) { Row( @@ -309,8 +315,17 @@ private fun SettingsToggleCard( 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) + Text( + title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + Text( + subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + ) } Spacer(Modifier.width(8.dp)) Switch( @@ -319,7 +334,7 @@ private fun SettingsToggleCard( enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = WornColors.AccentGreen, + checkedTrackColor = MaterialTheme.colorScheme.primary, ), ) } @@ -351,8 +366,8 @@ private fun SettingsCard( ) { Surface( onClick = onClick, - shape = RoundedCornerShape(16.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, modifier = modifier, ) { Row( @@ -362,13 +377,22 @@ private fun SettingsCard( 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) + Text( + title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + Text( + subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + ) } Icon( imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(20.dp), ) } @@ -386,8 +410,8 @@ private fun AboutCard() { val uriHandler = LocalUriHandler.current Surface( - shape = RoundedCornerShape(16.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, ) { Column { Row( @@ -396,15 +420,19 @@ private fun AboutCard() { ) { Text( stringResource(R.string.settings_version), - color = WornColors.TextPrimary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - Text(versionName ?: "1.0", color = WornColors.TextSecondary, fontSize = 15.sp) + Text( + versionName ?: "1.0", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) } - HorizontalDivider(color = WornColors.BorderSubtle.copy(alpha = 0.5f)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) AboutLinkRow(stringResource(R.string.settings_suggestions_bugs)) { uriHandler.openUri(FEEDBACK_URL) } - HorizontalDivider(color = WornColors.BorderSubtle.copy(alpha = 0.5f)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) AboutLinkRow(stringResource(R.string.settings_licenses)) { uriHandler.openUri(LICENSE_URL) } } } @@ -417,11 +445,16 @@ private fun AboutLinkRow(label: String, onClick: () -> Unit) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(16.dp), ) { - Text(label, color = WornColors.TextPrimary, fontSize = 15.sp, modifier = Modifier.weight(1f)) + Text( + label, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) Icon( imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(20.dp), ) } @@ -435,21 +468,21 @@ private fun DonationCard() { var showCopied by remember { mutableStateOf(false) } Surface( - shape = RoundedCornerShape(16.dp), - color = WornColors.BgCard, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, ) { Column(modifier = Modifier.padding(16.dp)) { Text( stringResource(R.string.settings_donate_title), - color = WornColors.TextPrimary, - fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) Spacer(Modifier.height(4.dp)) Text( stringResource(R.string.settings_donate_subtitle), - color = WornColors.TextSecondary, - fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, ) Spacer(Modifier.height(12.dp)) Surface( @@ -457,9 +490,9 @@ private fun DonationCard() { clipboardManager.setText(AnnotatedString(DONATION_LN_ADDRESS)) showCopied = true }, - shape = RoundedCornerShape(12.dp), - color = WornColors.BgElevated, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -467,17 +500,15 @@ private fun DonationCard() { ) { Text( DONATION_LN_ADDRESS, - color = WornColors.AccentGreen, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.weight(1f), ) Spacer(Modifier.width(8.dp)) Text( if (showCopied) copiedText else stringResource(R.string.settings_donate_copy), - color = WornColors.TextSecondary, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, ) } } @@ -504,8 +535,8 @@ private fun ProfileSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, dragHandle = { SheetDragHandle() }, ) { ProfileSheetContent(state = state, onIntent = onIntent, onSave = onSave) @@ -538,14 +569,13 @@ private fun ProfileSheetContent(state: SettingsState, onIntent: (SettingsIntent) ) { Text( text = stringResource(R.string.settings_your_profile), - color = WornColors.TextPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, ) Text( text = stringResource(R.string.settings_profile_help), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, ) ChipGroup( title = stringResource(R.string.label_body_type), @@ -601,8 +631,8 @@ private fun ApiKeySheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, dragHandle = { SheetDragHandle() }, ) { ApiKeySheetContent(hasApiKey = hasApiKey, onSave = onSave, onClear = onClear) @@ -653,8 +683,8 @@ private fun ApiKeySheetContent( ) { Text( text = stringResource(R.string.settings_remove_key), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, modifier = Modifier.padding(vertical = 8.dp), ) @@ -668,20 +698,18 @@ private fun ApiKeySheetContent( private fun ApiKeySheetHeader() { Text( text = stringResource(R.string.settings_connect_claude), - color = WornColors.TextPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, ) Text( text = stringResource(R.string.settings_api_description), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, ) Text( text = stringResource(R.string.settings_api_get_key), - color = WornColors.AccentGreen, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, ) } @@ -715,19 +743,19 @@ private fun ApiKeyTextField( contentDescription = stringResource( if (passwordVisible) R.string.settings_api_hide else R.string.settings_api_show, ), - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, ) } }, colors = TextFieldDefaults.colors( - focusedContainerColor = WornColors.BgCard, - unfocusedContainerColor = WornColors.BgCard, - disabledContainerColor = WornColors.BgCard, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, ), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, modifier = modifier.fillMaxWidth(), singleLine = true, ) @@ -751,8 +779,8 @@ private fun YouCamCredentialsSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = WornColors.BgElevated, - shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.sheetShape, dragHandle = { SheetDragHandle() }, ) { YouCamCredentialsSheetContent( @@ -788,20 +816,18 @@ private fun YouCamCredentialsSheetContent( ) { Text( text = stringResource(R.string.settings_youcam_title), - color = WornColors.TextPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, ) Text( text = stringResource(R.string.settings_youcam_description), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, ) Text( text = stringResource(R.string.settings_youcam_get_key), - color = WornColors.AccentGreen, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, ) FieldLabel(stringResource(R.string.settings_youcam_client_id_hint)) ApiKeyTextField( @@ -832,8 +858,8 @@ private fun YouCamCredentialsSheetContent( if (errorMessage != null && !verifying) { Text( text = errorMessage, - color = WornColors.DeleteRed, - fontSize = 13.sp, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.testTag("youcam_error"), ) } @@ -846,8 +872,8 @@ private fun YouCamCredentialsSheetContent( ) { Text( text = stringResource(R.string.settings_youcam_remove), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, modifier = Modifier.padding(vertical = 8.dp), ) @@ -859,7 +885,12 @@ private fun YouCamCredentialsSheetContent( @Composable private fun FieldLabel(text: String) { - Text(text, color = WornColors.TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text( + text, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) } // endregion @@ -885,7 +916,12 @@ private fun ChipGroup( onSelected: (T?) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(title, color = WornColors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Text( + title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -911,9 +947,18 @@ private fun MultiChipGroup( ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row { - Text(title, color = WornColors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Text( + title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.settings_multi_select), color = WornColors.TextMuted, fontSize = 12.sp) + Text( + stringResource(R.string.settings_multi_select), + color = MaterialTheme.wornExtras.textMuted, + style = MaterialTheme.typography.labelMedium, + ) } FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -996,7 +1041,7 @@ private const val DONATION_LN_ADDRESS = "jvsena42@blink.sv" private const val FEEDBACK_URL = "https://github.com/jvsena42/worn/issues/new" private const val LICENSE_URL = "https://github.com/jvsena42/worn/blob/main/LICENSE" -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun SettingsScreenPhonePreview() { WornTheme { @@ -1009,7 +1054,7 @@ private fun SettingsScreenPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun SettingsScreenTabletPreview() { WornTheme { @@ -1024,10 +1069,14 @@ private fun SettingsScreenTabletPreview() { } /** The default [SettingsState] already reports on-device AI as unavailable. */ -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun SettingsScreenOnDeviceAiUnavailablePreview() { WornTheme { SettingsScaffold(state = SettingsState()) } } + + + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/TryItScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/TryItScreen.kt index 951a5a1..10484c5 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/TryItScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/TryItScreen.kt @@ -1,3 +1,5 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + @file:Suppress("TooManyFunctions") package com.github.worn.ui.screen @@ -46,6 +48,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -76,12 +79,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource 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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowWidthSizeClass import com.github.worn.R import com.github.worn.domain.model.ClothingItem import com.github.worn.domain.model.GarmentCategory @@ -91,20 +93,21 @@ 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.ClothingPhoto import com.github.worn.ui.components.CropEditorDialog import com.github.worn.ui.components.CropPhotoButton import com.github.worn.ui.components.ErrorContentView import com.github.worn.ui.components.Tab import com.github.worn.ui.components.WornGradientButton import com.github.worn.ui.components.WornGradients -import com.github.worn.ui.components.ClothingPhoto +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview +import com.github.worn.ui.theme.WornTheme +import com.github.worn.ui.theme.wornExtras import com.github.worn.ui.util.SharedPhoto import com.github.worn.ui.util.readImageBytes import com.github.worn.ui.util.rememberCameraCapture import com.github.worn.ui.util.rememberDecodedImage -import com.github.worn.ui.theme.WornColors -import com.github.worn.ui.theme.WornDimens -import com.github.worn.ui.theme.WornTheme import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel @@ -301,7 +304,7 @@ private fun PhotoSourceDialog( ) { Icon(Icons.Outlined.CameraAlt, contentDescription = null, modifier = Modifier.size(24.dp)) Spacer(Modifier.width(12.dp)) - Text(stringResource(R.string.add_item_take_photo), fontSize = 16.sp) + Text(stringResource(R.string.add_item_take_photo), style = MaterialTheme.typography.titleSmall) } } TextButton(onClick = onGallery, modifier = Modifier.fillMaxWidth()) { @@ -316,7 +319,10 @@ private fun PhotoSourceDialog( modifier = Modifier.size(24.dp), ) Spacer(Modifier.width(12.dp)) - Text(stringResource(R.string.add_item_choose_gallery), fontSize = 16.sp) + Text( + stringResource(R.string.add_item_choose_gallery), + style = MaterialTheme.typography.titleSmall, + ) } } } @@ -423,7 +429,7 @@ private fun FeatureChoiceRow( ) { Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp)) Spacer(Modifier.width(12.dp)) - Text(label, fontSize = 16.sp) + Text(label, style = MaterialTheme.typography.titleSmall) } } } @@ -452,7 +458,7 @@ private fun TryItScaffold( Scaffold( modifier = Modifier.testTag("try_it_screen"), - containerColor = WornColors.BgPage, + containerColor = MaterialTheme.colorScheme.surface, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { paddingValues -> if (!state.hasApiKey && !state.hasYouCamKey) { @@ -500,7 +506,11 @@ private fun AiEmptyContent( ) { 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 titleStyle = if (isCompact) { + MaterialTheme.typography.headlineSmall + } else { + MaterialTheme.typography.headlineLarge + } val descWidth = if (isCompact) 280.dp else 380.dp Column( @@ -510,8 +520,8 @@ private fun AiEmptyContent( ) { Surface( shape = CircleShape, - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 8.dp, modifier = Modifier.size(circleSize), ) { @@ -519,7 +529,7 @@ private fun AiEmptyContent( Icon( imageVector = Icons.Outlined.SmartToy, contentDescription = null, - tint = WornColors.AccentIndigo, + tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.size(iconSize), ) } @@ -527,16 +537,20 @@ private fun AiEmptyContent( Spacer(Modifier.height(24.dp)) Text( text = stringResource(R.string.tryit_locked_title), - color = WornColors.TextPrimary, - fontSize = titleSize, + color = MaterialTheme.colorScheme.onSurface, + style = titleStyle, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, ) Spacer(Modifier.height(12.dp)) Text( text = stringResource(R.string.tryit_locked_description), - color = WornColors.TextSecondary, - fontSize = if (isCompact) 15.sp else 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = if (isCompact) { + MaterialTheme.typography.bodyMedium + } else { + MaterialTheme.typography.bodyLarge + }, lineHeight = if (isCompact) 22.sp else 24.sp, textAlign = TextAlign.Center, modifier = Modifier.widthIn(max = descWidth), @@ -557,7 +571,7 @@ private fun IndigoCtaButton(text: String, onClick: () -> Unit, modifier: Modifie onClick = onClick, modifier = modifier, gradientColors = WornGradients.Indigo, - shape = RoundedCornerShape(28.dp), + shape = MaterialTheme.shapes.extraLargeIncreased, elevation = 6.dp, fillMaxWidth = false, fixedHeight = null, @@ -642,7 +656,7 @@ private fun TryItPhoneContent( verticalArrangement = Arrangement.spacedBy(20.dp), ) { Spacer(Modifier.height(4.dp)) - TryItTitle(fontSize = 28.sp) + TryItTitle(style = MaterialTheme.typography.headlineMedium) UploadZone(photoBitmap = photoBitmap, height = 200.dp, onClick = onPhotoClick) if (hasPhoto) { CropPhotoButton(onClick = onCropClick, modifier = Modifier.testTag("try_it_crop_button")) @@ -660,7 +674,7 @@ private fun TryItPhoneContent( message = errorMsg, onRetry = onAnalyze, modifier = Modifier.padding(vertical = 40.dp), - retryButtonColor = WornColors.AccentIndigo, + retryButtonColor = MaterialTheme.colorScheme.secondary, ) } } @@ -680,7 +694,6 @@ private fun TryItPhoneContent( onPositioned = onTryOnSectionPositioned, ) } - Spacer(Modifier.height(WornDimens.BottomBarClearance)) } } @@ -703,7 +716,7 @@ private fun TryItTabletContent( ) { Column(modifier = modifier.verticalScroll(scrollState)) { Spacer(Modifier.height(4.dp)) - TryItTitle(fontSize = 32.sp) + TryItTitle(style = MaterialTheme.typography.headlineLarge) Spacer(Modifier.height(28.dp)) Row( horizontalArrangement = Arrangement.spacedBy(32.dp), @@ -730,7 +743,7 @@ private fun TryItTabletContent( message = errorMsg, onRetry = onAnalyze, modifier = Modifier.padding(vertical = 40.dp), - retryButtonColor = WornColors.AccentIndigo, + retryButtonColor = MaterialTheme.colorScheme.secondary, ) } } @@ -768,16 +781,15 @@ private fun TryItTabletContent( } } } - Spacer(Modifier.height(WornDimens.BottomBarClearance)) } } @Composable -private fun TryItTitle(fontSize: androidx.compose.ui.unit.TextUnit) { +private fun TryItTitle(style: androidx.compose.ui.text.TextStyle) { Text( text = stringResource(R.string.tryit_title), - color = WornColors.TextPrimary, - fontSize = fontSize, + color = MaterialTheme.colorScheme.onSurface, + style = style, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.8).sp, ) @@ -791,9 +803,9 @@ private fun UploadZone( ) { Surface( onClick = onClick, - shape = RoundedCornerShape(20.dp), - color = WornColors.BgCard, - border = BorderStroke(1.5.dp, WornColors.BorderStrong), + shape = MaterialTheme.shapes.largeIncreased, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.outline), shadowElevation = 1.dp, modifier = Modifier.fillMaxWidth().height(height).testTag("try_it_upload_zone"), ) { @@ -802,7 +814,7 @@ private fun UploadZone( bitmap = photoBitmap, contentDescription = "Selected photo", contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(20.dp)), + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.largeIncreased), ) } else { Column( @@ -813,15 +825,14 @@ private fun UploadZone( Icon( imageVector = Icons.Outlined.CameraAlt, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(44.dp), ) Spacer(Modifier.height(12.dp)) Text( text = stringResource(R.string.tryit_upload_hint), - color = WornColors.TextSecondary, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center, ) } @@ -836,7 +847,7 @@ private fun AnalyzeButton(onClick: () -> Unit) { onClick = onClick, modifier = Modifier.testTag("try_it_analyze_button"), gradientColors = WornGradients.Indigo, - shape = RoundedCornerShape(28.dp), + shape = MaterialTheme.shapes.extraLargeIncreased, elevation = 6.dp, fixedHeight = null, contentPadding = PaddingValues(vertical = 14.dp), @@ -857,7 +868,7 @@ private fun LoadingIndicator() { contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp), ) { - CircularProgressIndicator(color = WornColors.AccentIndigo) + CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary) } } @@ -885,9 +896,8 @@ private fun PairsSection( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = stringResource(R.string.tryit_pairs_with), - color = WornColors.TextPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, letterSpacing = (-0.2).sp, ) LazyRow( @@ -908,18 +918,18 @@ private fun PairsSection( 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), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 2.dp, modifier = Modifier.size(size), ) { ClothingPhoto( photoPath = item.photoPath, contentDescription = item.name, - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, placeholderIconSize = 28.dp, - placeholderTint = WornColors.TextSecondary, + placeholderTint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -927,12 +937,12 @@ private fun ItemThumbnail(item: ClothingItem, size: Dp, onClick: () -> Unit) { @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 + val valueStyle = MaterialTheme.typography.displaySmall Surface( - shape = RoundedCornerShape(20.dp), - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + shape = MaterialTheme.shapes.largeIncreased, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth(), ) { @@ -944,15 +954,15 @@ private fun CombinationsCard(count: Int, isCompact: Boolean) { ) { Text( text = stringResource(R.string.tryit_combinations_unlocked), - color = WornColors.TextSecondary, - fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, ) Text( text = count.toString(), - color = WornColors.AccentGreen, - fontSize = valueSize, + color = MaterialTheme.colorScheme.primary, + style = valueStyle, fontWeight = FontWeight.Bold, letterSpacing = (-1.2).sp, ) @@ -964,14 +974,17 @@ private fun CombinationsCard(count: Int, isCompact: Boolean) { private fun GapsFilledSection(gaps: List, isCompact: Boolean) { if (gaps.isEmpty()) return - val fontSize = if (isCompact) 14.sp else 15.sp + val bodyStyle = if (isCompact) { + MaterialTheme.typography.bodySmall + } else { + MaterialTheme.typography.bodyMedium + } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = stringResource(R.string.tryit_gaps_filled), - color = WornColors.TextPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, letterSpacing = (-0.2).sp, ) gaps.forEach { gap -> @@ -984,12 +997,12 @@ private fun GapsFilledSection(gaps: List, isCompact: Boolean) { modifier = Modifier .size(8.dp) .clip(CircleShape) - .background(WornColors.AccentGreen), + .background(MaterialTheme.colorScheme.primary), ) Text( text = gap, - color = WornColors.TextPrimary, - fontSize = fontSize, + color = MaterialTheme.colorScheme.onSurface, + style = bodyStyle, ) } } @@ -1000,7 +1013,7 @@ private fun GapsFilledSection(gaps: List, isCompact: Boolean) { 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)) + Brush.verticalGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.wornExtras.accentGreenDark)) } else { Brush.verticalGradient(listOf(Color(0xFF8B7D7D), Color(0xFF6B5E5E))) } @@ -1012,7 +1025,7 @@ private fun DecisionBanner(worthAdding: Boolean, isCompact: Boolean) { modifier = Modifier .fillMaxWidth() .height(bannerHeight) - .clip(RoundedCornerShape(28.dp)) + .clip(MaterialTheme.shapes.extraLargeIncreased) .background(gradient), ) { Row( @@ -1028,8 +1041,7 @@ private fun DecisionBanner(worthAdding: Boolean, isCompact: Boolean) { Text( text = text, color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, ) } } @@ -1055,9 +1067,8 @@ private fun TryOnSection( ) { Text( text = stringResource(R.string.tryit_your_photo_title), - color = WornColors.TextPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, letterSpacing = (-0.2).sp, ) PersonPhotoZone( @@ -1073,9 +1084,8 @@ private fun TryOnSection( } Text( text = stringResource(R.string.tryit_tryon_category_title), - color = WornColors.TextPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, letterSpacing = (-0.2).sp, ) GarmentCategorySelector(selected = state.selectedCategory, onSelect = onSelectCategory) @@ -1093,7 +1103,7 @@ private fun TryOnSection( message = errorMsg, onRetry = onGenerateTryOn, modifier = Modifier.padding(vertical = 24.dp), - retryButtonColor = WornColors.AccentIndigo, + retryButtonColor = MaterialTheme.colorScheme.secondary, ) } } @@ -1102,8 +1112,8 @@ private fun TryOnSection( } Text( text = stringResource(R.string.tryit_tryon_cost_note), - color = WornColors.TextSecondary, - fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, ) } } @@ -1113,9 +1123,9 @@ private fun PersonPhotoZone(personImage: ByteArray?, height: Dp, onClick: () -> val bitmap = rememberDecodedImage(personImage) Surface( onClick = onClick, - shape = RoundedCornerShape(20.dp), - color = WornColors.BgCard, - border = BorderStroke(1.5.dp, WornColors.BorderStrong), + shape = MaterialTheme.shapes.largeIncreased, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.outline), shadowElevation = 1.dp, modifier = Modifier.fillMaxWidth().height(height).testTag("try_on_person_zone"), ) { @@ -1124,7 +1134,7 @@ private fun PersonPhotoZone(personImage: ByteArray?, height: Dp, onClick: () -> bitmap = bitmap, contentDescription = stringResource(R.string.tryit_your_photo_title), contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(20.dp)), + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.largeIncreased), ) } else { Column( @@ -1135,15 +1145,14 @@ private fun PersonPhotoZone(personImage: ByteArray?, height: Dp, onClick: () -> Icon( imageVector = Icons.Outlined.CameraAlt, contentDescription = null, - tint = WornColors.IconMuted, + tint = MaterialTheme.wornExtras.iconMuted, modifier = Modifier.size(44.dp), ) Spacer(Modifier.height(12.dp)) Text( text = stringResource(R.string.tryit_your_photo_hint), - color = WornColors.TextSecondary, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center, ) } @@ -1167,7 +1176,7 @@ private fun GarmentCategorySelector( onClick = { onSelect(category) }, label = { Text(stringResource(categoryLabelRes(category))) }, colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = WornColors.AccentIndigo, + selectedContainerColor = MaterialTheme.colorScheme.secondary, selectedLabelColor = Color.White, ), ) @@ -1189,7 +1198,7 @@ private fun SeeItOnMeButton(onClick: () -> Unit) { onClick = onClick, modifier = Modifier.testTag("try_on_generate_button"), gradientColors = WornGradients.Indigo, - shape = RoundedCornerShape(28.dp), + shape = MaterialTheme.shapes.extraLargeIncreased, elevation = 6.dp, fixedHeight = null, contentPadding = PaddingValues(vertical = 14.dp), @@ -1211,11 +1220,11 @@ private fun TryOnLoadingIndicator() { verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), ) { - CircularProgressIndicator(color = WornColors.AccentIndigo) + CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary) Text( text = stringResource(R.string.tryit_tryon_generating), - color = WornColors.TextSecondary, - fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, ) } } @@ -1226,15 +1235,14 @@ private fun TryOnResultView(imageBytes: ByteArray, height: Dp) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = stringResource(R.string.tryit_tryon_result_title), - color = WornColors.TextPrimary, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, letterSpacing = (-0.2).sp, ) Surface( - shape = RoundedCornerShape(20.dp), - color = WornColors.BgCard, - border = BorderStroke(1.dp, WornColors.BorderSubtle), + shape = MaterialTheme.shapes.largeIncreased, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth().height(height).testTag("try_on_result"), ) { @@ -1243,7 +1251,7 @@ private fun TryOnResultView(imageBytes: ByteArray, height: Dp) { bitmap = bitmap, contentDescription = stringResource(R.string.tryit_tryon_result_title), contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(20.dp)), + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.largeIncreased), ) } } @@ -1276,7 +1284,7 @@ private val previewResult = TryItResult( worthAdding = true, ) -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun TryItResultsPhonePreview() { WornTheme { @@ -1289,7 +1297,7 @@ private fun TryItResultsPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun TryItEmptyPhonePreview() { WornTheme { @@ -1302,7 +1310,7 @@ private fun TryItEmptyPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun TryItResultsTabletPreview() { WornTheme { @@ -1315,7 +1323,7 @@ private fun TryItResultsTabletPreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun TryItShareChooserPhonePreview() { WornTheme { @@ -1329,7 +1337,7 @@ private fun TryItShareChooserPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun TryItShareChooserTabletPreview() { WornTheme { @@ -1343,7 +1351,7 @@ private fun TryItShareChooserTabletPreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun TryItTryOnPhonePreview() { WornTheme { @@ -1360,3 +1368,4 @@ private fun TryItTryOnPhonePreview() { } // endregion + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt index 21b5a95..c01723a 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/WardrobeScreen.kt @@ -1,3 +1,5 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + package com.github.worn.ui.screen import androidx.compose.foundation.background @@ -6,9 +8,9 @@ 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.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -26,14 +28,20 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,19 +51,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.window.core.layout.WindowWidthSizeClass -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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowWidthSizeClass import com.github.worn.R import com.github.worn.domain.model.Category import com.github.worn.domain.model.ClothingItem @@ -64,15 +72,16 @@ import com.github.worn.presentation.viewmodel.WardrobeIntent import com.github.worn.presentation.viewmodel.WardrobeState import com.github.worn.presentation.viewmodel.WardrobeViewModel import com.github.worn.ui.components.CategoryFilterChips +import com.github.worn.ui.components.ClothingCard import com.github.worn.ui.components.DeleteConfirmationDialog -import com.github.worn.ui.components.SelectionHeader import com.github.worn.ui.components.EmptyStateView +import com.github.worn.ui.components.SelectionHeader +import com.github.worn.ui.components.Tab import com.github.worn.ui.components.WornGradientButton import com.github.worn.ui.components.WornGradients -import com.github.worn.ui.components.ClothingCard -import com.github.worn.ui.components.Tab -import com.github.worn.ui.theme.WornColors -import com.github.worn.ui.theme.WornDimens +import com.github.worn.ui.components.WornTopAppBar +import com.github.worn.ui.theme.PhonePreview +import com.github.worn.ui.theme.TabletPreview import com.github.worn.ui.theme.WornTheme import com.github.worn.ui.util.ShortcutCommand import org.koin.compose.viewmodel.koinViewModel @@ -204,9 +213,27 @@ private fun WardrobeScaffold( val sectionGap = if (isCompact) 24.dp else 28.dp var showDeleteDialog by remember { mutableStateOf(false) } + // exitUntilCollapsed: the title shrinks to a compact bar as the grid scrolls up and only + // returns once the user scrolls back to the top, which is the standard large-app-bar feel. + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( - modifier = Modifier.testTag("wardrobe_screen"), - containerColor = WornColors.BgPage, + modifier = Modifier + .testTag("wardrobe_screen") + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + if (isSelectionMode) { + SelectionHeader( + count = state.selectedIds.size, + onCancel = onClearSelection, + onDelete = { showDeleteDialog = true }, + modifier = Modifier.padding(horizontal = contentPadding), + ) + } else { + WardrobeTopBar(itemCount = state.totalItemCount, scrollBehavior = scrollBehavior) + } + }, floatingActionButton = { val isWardrobeEmpty = !state.isLoading && state.totalItemCount == 0 if (!isSelectionMode && !isWardrobeEmpty) { @@ -214,7 +241,6 @@ private fun WardrobeScaffold( onAddItemClick, Modifier .testTag("wardrobe_add_fab") - .padding(bottom = WornDimens.BottomBarClearance), ) } }, @@ -228,15 +254,6 @@ private fun WardrobeScaffold( .padding(paddingValues) .padding(horizontal = contentPadding), ) { - if (isSelectionMode) { - SelectionHeader( - count = state.selectedIds.size, - onCancel = onClearSelection, - onDelete = { showDeleteDialog = true }, - ) - } else { - WardrobeHeader(itemCount = state.totalItemCount) - } if (isWardrobeEmpty) { EmptyState(onAddItemClick = onAddItemClick) } else { @@ -270,28 +287,22 @@ private fun WardrobeScaffold( } @Composable -private fun WardrobeHeader(itemCount: Int) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (itemCount == 0) { +private fun WardrobeTopBar(itemCount: Int, scrollBehavior: TopAppBarScrollBehavior) { + // Title strings are unchanged: journeys/bottom-navigation.xml and add-first-item.xml assert + // on the visible heading text. + WornTopAppBar( + title = if (itemCount == 0) { stringResource(R.string.wardrobe_title_empty) } else { stringResource(R.string.wardrobe_title) }, - color = WornColors.TextPrimary, - fontSize = if (itemCount == 0) 22.sp else 28.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.5).sp, + subtitle = if (itemCount > 0) { + stringResource(R.string.wardrobe_subtitle, itemCount) + } else { + null + }, + scrollBehavior = scrollBehavior, ) - if (itemCount > 0) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.wardrobe_subtitle, itemCount), - color = WornColors.TextSecondary, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - } } @@ -308,14 +319,13 @@ private fun WardrobeContent( if (state.isLoading && state.items.isEmpty()) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(color = WornColors.AccentGreen) + LoadingIndicator(color = MaterialTheme.colorScheme.primary) } } else { LazyVerticalGrid( columns = GridCells.Adaptive(minSize = GRID_MIN_CELL_WIDTH), horizontalArrangement = Arrangement.spacedBy(gridGap), verticalArrangement = Arrangement.spacedBy(gridGap), - contentPadding = PaddingValues(bottom = WornDimens.BottomBarClearance), modifier = Modifier.fillMaxSize(), ) { items(state.items, key = { it.id }) { item -> @@ -337,8 +347,8 @@ private fun WardrobeContent( } } -private val CtaShape = RoundedCornerShape(28.dp) -private val CtaGradient = Brush.verticalGradient(listOf(WornColors.AccentGreen, WornColors.AccentGreenEnd)) +private val CtaShape: Shape + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes.extraLargeIncreased @Composable private fun CategoryEmptyState() { @@ -351,13 +361,13 @@ private fun CategoryEmptyState() { painter = painterResource(id = R.drawable.ic_shirt), contentDescription = null, modifier = Modifier.size(48.dp), - tint = WornColors.TextSecondary.copy(alpha = 0.5f), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) Spacer(Modifier.height(16.dp)) Text( stringResource(R.string.wardrobe_category_empty), - color = WornColors.TextSecondary, - fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, ) } @@ -371,7 +381,7 @@ private fun EmptyState(onAddItemClick: () -> Unit) { painter = painterResource(id = R.drawable.ic_shirt), contentDescription = null, modifier = Modifier.size(52.dp), - tint = WornColors.TextSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, title = stringResource(R.string.wardrobe_empty_title), @@ -388,7 +398,12 @@ private fun EmptyState(onAddItemClick: () -> Unit) { fixedHeight = null, contentPadding = PaddingValues(horizontal = 36.dp, vertical = 16.dp), icon = { - Icon(Icons.Default.Add, contentDescription = null, Modifier.size(18.dp), WornColors.BgPage) + Icon( + Icons.Default.Add, + contentDescription = null, + Modifier.size(18.dp), + MaterialTheme.colorScheme.surface, + ) }, ) }, @@ -399,14 +414,18 @@ private fun EmptyState(onAddItemClick: () -> Unit) { private fun AddItemFab(onClick: () -> Unit, modifier: Modifier = Modifier) { ExtendedFloatingActionButton( onClick = onClick, - containerColor = WornColors.AccentGreen, - contentColor = WornColors.TextOnColor, - shape = RoundedCornerShape(30.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = MaterialTheme.shapes.extraLargeIncreased, modifier = modifier, ) { Icon(Icons.Default.Add, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text(text = stringResource(R.string.wardrobe_fab_add), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) + Text( + text = stringResource(R.string.wardrobe_fab_add), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) } } @@ -420,7 +439,7 @@ private val previewItems = listOf( ClothingItem("6", "Chinos", Category.BOTTOM, listOf("khaki"), photoPath = "", createdAt = 0), ) -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun WardrobeScreenPhonePreview() { WornTheme { @@ -432,7 +451,7 @@ private fun WardrobeScreenPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun WardrobeSelectModePreview() { WornTheme { @@ -448,7 +467,7 @@ private fun WardrobeSelectModePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun WardrobeEmptyPhonePreview() { WornTheme { @@ -460,7 +479,7 @@ private fun WardrobeEmptyPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun WardrobeScreenTabletPreview() { WornTheme { @@ -472,7 +491,7 @@ private fun WardrobeScreenTabletPreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun WardrobeEmptyTabletPreview() { WornTheme { @@ -484,7 +503,7 @@ private fun WardrobeEmptyTabletPreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_8") +@PhonePreview @Composable private fun WardrobeEmptyCategoryPhonePreview() { WornTheme { @@ -499,7 +518,7 @@ private fun WardrobeEmptyCategoryPhonePreview() { } } -@Preview(showSystemUi = true, device = "id:pixel_tablet") +@TabletPreview @Composable private fun WardrobeEmptyCategoryTabletPreview() { WornTheme { @@ -513,3 +532,7 @@ private fun WardrobeEmptyCategoryTabletPreview() { ) } } + + + + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/theme/SheetPreview.kt b/composeApp/src/main/kotlin/com/github/worn/ui/theme/SheetPreview.kt index db6200a..403f545 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/theme/SheetPreview.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/theme/SheetPreview.kt @@ -8,11 +8,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import com.github.worn.ui.theme.sheetShape +import com.github.worn.ui.theme.wornExtras @Composable fun SheetPreview(content: @Composable () -> Unit) { @@ -21,20 +24,20 @@ fun SheetPreview(content: @Composable () -> Unit) { contentAlignment = Alignment.BottomCenter, modifier = Modifier .fillMaxSize() - .background(WornColors.BgPage.copy(alpha = 0.5f)), + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)), ) { Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)) - .background(WornColors.BgElevated), + .clip(MaterialTheme.sheetShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), ) { Spacer(modifier = Modifier.height(12.dp)) Box( modifier = Modifier .align(Alignment.CenterHorizontally) .clip(RoundedCornerShape(2.dp)) - .background(WornColors.IconMuted) + .background(MaterialTheme.wornExtras.iconMuted) .height(4.dp) .fillMaxWidth(0.1f), ) @@ -44,3 +47,4 @@ fun SheetPreview(content: @Composable () -> Unit) { } } } + diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornColorScheme.kt b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornColorScheme.kt new file mode 100644 index 0000000..d61bdb9 --- /dev/null +++ b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornColorScheme.kt @@ -0,0 +1,221 @@ +package com.github.worn.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Worn's palette in two layers. + * + * The M3 [androidx.compose.material3.ColorScheme] below is the real source of truth: every + * Material component (sheets, dialogs, ripples, snackbars) reads it, so filling in *all* the roles + * — not just the handful the app names — is what keeps stray baseline-purple out of the UI. + * + * [WornExtras] carries the few brand tokens M3 has no role for: gradient stops, the muted + * text/icon greys and the category dots. It hangs off [MaterialTheme] as [wornExtras], so call + * sites read `MaterialTheme.wornExtras.iconMuted` right next to + * `MaterialTheme.colorScheme.primary` and every colour comes from one place. + */ + +// --------------------------------------------------------------------------------------------- +// Light — the established warm beige + sage brand, unchanged in hue. +// --------------------------------------------------------------------------------------------- + +internal val WornLightColorScheme = lightColorScheme( + primary = Color(0xFF7A9468), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFDCE6D2), + onPrimaryContainer = Color(0xFF2A3A20), + inversePrimary = Color(0xFFA8C295), + + secondary = Color(0xFF6B7B8E), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFDCE3EA), + onSecondaryContainer = Color(0xFF22303E), + + tertiary = Color(0xFFA87560), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFF2DED4), + onTertiaryContainer = Color(0xFF3D2318), + + error = Color(0xFFC45B4A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF7DAD4), + onErrorContainer = Color(0xFF43110A), + + background = Color(0xFFF5F0EB), + onBackground = Color(0xFF2C2924), + surface = Color(0xFFF5F0EB), + onSurface = Color(0xFF2C2924), + surfaceVariant = Color(0xFFEDE8E1), + onSurfaceVariant = Color(0xFF7D776F), + surfaceTint = Color(0xFF7A9468), + + surfaceDim = Color(0xFFE4DDD3), + surfaceBright = Color(0xFFFFFBF7), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFFFFFFF), + // Cards and tiles. White here, one step *above* the beige page — see the dark scheme for why + // this role rather than surfaceContainerLowest. + surfaceContainer = Color(0xFFFFFFFF), + // Chrome that sits behind content: the bottom bar and the filter chips. + surfaceContainerHigh = Color(0xFFEDE8E1), + surfaceContainerHighest = Color(0xFFE7E0D7), + + // M3 defines outline as the stronger of the pair; the previous scheme had these two swapped. + outline = Color(0xFFC8C0B5), + outlineVariant = Color(0xFFE0D9D0), + + scrim = Color(0xFF000000), + inverseSurface = Color(0xFF322F2A), + inverseOnSurface = Color(0xFFF5F0EB), +) + +internal val WornLightExtras = WornExtras( + textMuted = Color(0xFFB5AFA8), + iconMuted = Color(0xFFA09A92), + accentGreenDark = Color(0xFF5C6E50), + saveGradientStart = Color(0xFF8FA47D), + saveGradientEnd = Color(0xFF6B7F5E), + greenCtaStart = Color(0xFF7A9468), + greenCtaEnd = Color(0xFF6B8A58), + indigoGradientStart = Color(0xFF6B7B8E), + indigoGradientEnd = Color(0xFF556070), + categoryDotTop = Color(0xFF444444), + categoryDotBottom = Color(0xFF2B4570), + categoryDotDress = Color(0xFFA87560), + categoryDotOuterwear = Color(0xFF7A9468), + categoryDotShoes = Color(0xFF8B6914), + categoryDotAccessory = Color(0xFFB59D6E), +) + +// --------------------------------------------------------------------------------------------- +// Dark — the same hues on a warm dark axis. Two things this deliberately avoids: +// +// * Near-black. A #121212-style base reads cold next to the sage and drops the brand's warmth +// entirely, so the page sits at #211D18 — a brown-grey that still reads as "Worn". +// * A compressed ramp. The steps are spaced widely enough to be visible: page #211D18 against +// cards #1A1713 is a step you can actually see, where a 2% difference just looks like one +// flat black sheet no matter how correct the token names are. +// --------------------------------------------------------------------------------------------- + +internal val WornDarkColorScheme = darkColorScheme( + primary = Color(0xFFA8C295), + onPrimary = Color(0xFF1B2913), + primaryContainer = Color(0xFF3D5230), + onPrimaryContainer = Color(0xFFC4DEB0), + inversePrimary = Color(0xFF7A9468), + + secondary = Color(0xFFA9BACE), + onSecondary = Color(0xFF1B2733), + secondaryContainer = Color(0xFF3A4756), + onSecondaryContainer = Color(0xFFC7D6E6), + + tertiary = Color(0xFFD8A88F), + onTertiary = Color(0xFF2C1509), + tertiaryContainer = Color(0xFF59392A), + onTertiaryContainer = Color(0xFFF2DED4), + + error = Color(0xFFF2B8AC), + onError = Color(0xFF4E1509), + errorContainer = Color(0xFF7A2A1E), + onErrorContainer = Color(0xFFFFDAD3), + + background = Color(0xFF211D18), + onBackground = Color(0xFFEFE8DD), + surface = Color(0xFF211D18), + onSurface = Color(0xFFEFE8DD), + surfaceVariant = Color(0xFF363029), + onSurfaceVariant = Color(0xFFD2C9BC), + surfaceTint = Color(0xFFA8C295), + + surfaceDim = Color(0xFF211D18), + surfaceBright = Color(0xFF4A443C), + surfaceContainerLowest = Color(0xFF17140F), + surfaceContainerLow = Color(0xFF262119), + // Cards and tiles — one step *above* the page, so they still read as standing forward the way + // the white-on-beige light scheme does. Mapping them to surfaceContainerLowest instead (the + // darkest step, which is what "card = white = brightest" naively translates to) sinks them + // into the background and reads as black holes. + surfaceContainer = Color(0xFF2B261F), + surfaceContainerHigh = Color(0xFF363029), + surfaceContainerHighest = Color(0xFF423B32), + + outline = Color(0xFFA0988B), + outlineVariant = Color(0xFF554E45), + + scrim = Color(0xFF000000), + inverseSurface = Color(0xFFEDE6DC), + inverseOnSurface = Color(0xFF322F2A), +) + +internal val WornDarkExtras = WornExtras( + textMuted = Color(0xFF8A8378), + iconMuted = Color(0xFF9B9488), + accentGreenDark = Color(0xFF4E6641), + saveGradientStart = Color(0xFF6E8A5C), + saveGradientEnd = Color(0xFF546B45), + greenCtaStart = Color(0xFF6E8A5C), + greenCtaEnd = Color(0xFF5C7A4B), + indigoGradientStart = Color(0xFF5E6E80), + indigoGradientEnd = Color(0xFF4A5462), + // These do double duty: 8dp dots on the page surface, and 36dp tile fills behind *white* + // icons in Gaps. So they can only be lifted far enough to be visible on #14120F, not so far + // that white stops reading on top — which rules out simply using the light-scheme tints. + // Only the two darkest needed moving; the rest already clear both bars unchanged. + categoryDotTop = Color(0xFF6E6862), + categoryDotBottom = Color(0xFF42618F), + categoryDotDress = Color(0xFFA87560), + categoryDotOuterwear = Color(0xFF7A9468), + categoryDotShoes = Color(0xFF8B6914), + categoryDotAccessory = Color(0xFFB59D6E), +) + +/** + * Brand tokens with no equivalent M3 role. + * + * The gradient stops are deliberately *not* derived from `primary` / `secondary`. Those roles + * invert between light and dark (sage goes from #7A9468 to a much lighter #A8C295), and the + * gradient buttons always draw white label text — deriving them would silently drop the label to + * roughly 1.8:1 in dark mode. These stay saturated enough for white in both themes. + */ +@Immutable +data class WornExtras( + val textMuted: Color, + val iconMuted: Color, + /** Banner and gradient-end fills that always carry white text — dark in *both* themes. */ + val accentGreenDark: Color, + val saveGradientStart: Color, + val saveGradientEnd: Color, + val greenCtaStart: Color, + val greenCtaEnd: Color, + val indigoGradientStart: Color, + val indigoGradientEnd: Color, + val categoryDotTop: Color, + val categoryDotBottom: Color, + val categoryDotDress: Color, + val categoryDotOuterwear: Color, + val categoryDotShoes: Color, + val categoryDotAccessory: Color, +) + +internal val LocalWornExtras = staticCompositionLocalOf { WornLightExtras } + +/** + * Extension point for the brand tokens that have no M3 role. + * + * This is the standard Compose way to widen a design system: hang the extra tokens off + * [MaterialTheme] so a call site reads `MaterialTheme.wornExtras.iconMuted` right beside + * `MaterialTheme.colorScheme.primary`. Everything colour-related then comes from one object, and + * the M3 role in play is visible at the point of use instead of hidden behind an alias. + * + * Reading it outside a composition is a compile error by design — a top-level `val` capturing a + * colour would freeze it to whichever theme was active at class-init time. + */ +val MaterialTheme.wornExtras: WornExtras + @Composable @ReadOnlyComposable get() = LocalWornExtras.current diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornPreviews.kt b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornPreviews.kt new file mode 100644 index 0000000..ce2f781 --- /dev/null +++ b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornPreviews.kt @@ -0,0 +1,27 @@ +package com.github.worn.ui.theme + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview + +/** + * Multipreview annotations for the two form factors the project targets. + * + * Each carries its light and dark variant, so a composable annotated with [PhonePreview] renders + * both without the file having to repeat the annotation. Compose resolves a "multipreview" by + * expanding every `@Preview` on the annotation class onto the annotated function. + * + * Prefer these over a bare `@Preview`: they are the only thing keeping dark mode covered in the + * IDE, and dark is exactly where a missed colour role shows up. + */ +@Preview(name = "Phone", showSystemUi = true, device = "id:pixel_8") +@Preview(name = "Phone · Dark", showSystemUi = true, device = "id:pixel_8", uiMode = UI_MODE_NIGHT_YES) +annotation class PhonePreview + +@Preview(name = "Tablet", showSystemUi = true, device = "id:pixel_tablet") +@Preview( + name = "Tablet · Dark", + showSystemUi = true, + device = "id:pixel_tablet", + uiMode = UI_MODE_NIGHT_YES, +) +annotation class TabletPreview diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornShapes.kt b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornShapes.kt new file mode 100644 index 0000000..8ce75f9 --- /dev/null +++ b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornShapes.kt @@ -0,0 +1,46 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.github.worn.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.unit.dp + +/** + * Worn's corner-radius scale. + * + * The radii are the ones already scattered across the UI as `RoundedCornerShape(n.dp)`, collapsed + * onto the eight M3 slots. Naming them does two things: Material components pick the right corner + * on their own, and the handful of near-duplicate one-offs (10/22/26/30dp) fold into the nearest + * step so the app stops shipping four radii that differ by 2dp and read as the same curve. + */ +internal val WornShapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + // Chips, small tiles, input fields. + medium = RoundedCornerShape(12.dp), + // Cards and photo frames. + large = RoundedCornerShape(16.dp), + largeIncreased = RoundedCornerShape(20.dp), + // Sheets and dialogs. + extraLarge = RoundedCornerShape(24.dp), + // Pill buttons and the FAB. + extraLargeIncreased = RoundedCornerShape(28.dp), + // The bottom bar. + extraExtraLarge = RoundedCornerShape(36.dp), +) + +/** + * Top-rounded shape for bottom sheets, matching [Shapes.extraLarge] on the corners that show. + * + * M3 has no slot for a partly-rounded shape, so this hangs off [MaterialTheme] the same way + * [wornExtras] does, keeping every sheet on one radius instead of ten copies of the literal. + */ +val MaterialTheme.sheetShape: RoundedCornerShape + @Composable @ReadOnlyComposable get() = SheetShape + +private val SheetShape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornTheme.kt b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornTheme.kt index 1fd8a6c..13da8d4 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornTheme.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornTheme.kt @@ -1,72 +1,32 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.github.worn.ui.theme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import androidx.compose.ui.graphics.Color - -object WornDimens { - val BottomBarClearance = 95.dp -} - -object WornColors { - // Backgrounds - val BgPage = Color(0xFFF5F0EB) - val BgCard = Color(0xFFFFFFFF) - val BgElevated = Color(0xFFEDE8E1) - - // Borders - val BorderSubtle = Color(0xFFE0D9D0) - val BorderStrong = Color(0xFFC8C0B5) - - // Accents - val AccentGreen = Color(0xFF7A9468) - val AccentGreenEnd = Color(0xFF6B8A58) - val AccentGreenDark = Color(0xFF5C6E50) - val AccentIndigo = Color(0xFF6B7B8E) - val AccentCoral = Color(0xFFA87560) - val DeleteRed = Color(0xFFC45B4A) - - // Gradients - val SaveGradientStart = Color(0xFF8FA47D) - val SaveGradientEnd = Color(0xFF6B7F5E) - - // Text - val TextPrimary = Color(0xFF2C2924) - val TextSecondary = Color(0xFF7D776F) - val TextMuted = Color(0xFFB5AFA8) - val TextOnColor = Color(0xFFFFFFFF) - - // Icons - val IconMuted = Color(0xFFA09A92) - - // Category dots - val CategoryDotTop = Color(0xFF444444) - val CategoryDotBottom = Color(0xFF2B4570) - val CategoryDotDress = Color(0xFFA87560) - val CategoryDotOuterwear = Color(0xFF7A9468) - val CategoryDotShoes = Color(0xFF8B6914) - val CategoryDotAccessory = Color(0xFFB59D6E) -} - -private val WornLightColorScheme = lightColorScheme( - primary = WornColors.AccentGreen, - onPrimary = WornColors.TextOnColor, - background = WornColors.BgPage, - onBackground = WornColors.TextPrimary, - surface = WornColors.BgCard, - onSurface = WornColors.TextPrimary, - surfaceVariant = WornColors.BgElevated, - onSurfaceVariant = WornColors.TextSecondary, - outline = WornColors.BorderSubtle, - outlineVariant = WornColors.BorderStrong, -) +import androidx.compose.runtime.CompositionLocalProvider @Composable -fun WornTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = WornLightColorScheme, - content = content, - ) +fun WornTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) WornDarkColorScheme else WornLightColorScheme + val extras = if (darkTheme) WornDarkExtras else WornLightExtras + + CompositionLocalProvider(LocalWornExtras provides extras) { + // MaterialExpressiveTheme rather than MaterialTheme: it is the only way to supply a + // MotionScheme, and `expressive()` gives every Material component spring-based motion + // instead of the flat easing curves. Nothing else about the theme changes. + MaterialExpressiveTheme( + colorScheme = colorScheme, + motionScheme = MotionScheme.expressive(), + typography = WornTypography, + shapes = WornShapes, + content = content, + ) + } } diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornTypography.kt b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornTypography.kt new file mode 100644 index 0000000..4574518 --- /dev/null +++ b/composeApp/src/main/kotlin/com/github/worn/ui/theme/WornTypography.kt @@ -0,0 +1,99 @@ +package com.github.worn.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * Worn's type scale. + * + * The sizes are the ones the app was already using at ~145 scattered `fontSize =` call sites, so + * adopting this changes no pixels — it just gives each one a name, a matching line height and a + * single place to change. The M3 role names are kept (rather than invented ones) so Material + * components that reach for `typography` on their own land on the right style too. + * + * `letterSpacing` on [Typography.headlineMedium] is the -0.5sp the screen titles already carried; + * [Typography.labelSmall] keeps the +0.5sp of the uppercase tab labels. + */ +internal val WornTypography = Typography( + // Large stat numbers. + displaySmall = TextStyle( + fontSize = 42.sp, + lineHeight = 50.sp, + fontWeight = FontWeight.Normal, + ), + + headlineLarge = TextStyle( + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.SemiBold, + ), + // Screen titles: "Worn", "Your outfits", "What's missing", "Settings". + headlineMedium = TextStyle( + fontSize = 28.sp, + lineHeight = 36.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.5).sp, + ), + headlineSmall = TextStyle( + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.SemiBold, + ), + + // Empty-state and sheet titles. + titleLarge = TextStyle( + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.SemiBold, + ), + // Section headers. + titleMedium = TextStyle( + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.SemiBold, + ), + // Row titles and button labels. + titleSmall = TextStyle( + fontSize = 16.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + ), + + bodyLarge = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + ), + bodyMedium = TextStyle( + fontSize = 15.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Normal, + ), + // Card names and descriptions — the most common style in the app. + bodySmall = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Normal, + ), + + labelLarge = TextStyle( + fontSize = 13.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Medium, + ), + // Category labels and captions. + labelMedium = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + ), + // Bottom-bar tab labels. + labelSmall = TextStyle( + fontSize = 10.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + ), +) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/util/CameraCapture.kt b/composeApp/src/main/kotlin/com/github/worn/ui/util/CameraCapture.kt index 0013816..0aae6af 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/util/CameraCapture.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/util/CameraCapture.kt @@ -9,10 +9,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.core.content.FileProvider +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File /** * Launches the camera and hands back the captured photo at full resolution. diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/util/RememberDecodedImage.kt b/composeApp/src/main/kotlin/com/github/worn/ui/util/RememberDecodedImage.kt index d89dae2..5722888 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/util/RememberDecodedImage.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/util/RememberDecodedImage.kt @@ -5,9 +5,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File /** * Decodes [bytes] into a preview-sized [ImageBitmap] off the main thread. diff --git a/composeApp/src/main/res/drawable/ic_footprints.xml b/composeApp/src/main/res/drawable/ic_footprints.xml deleted file mode 100644 index 48f6c9a..0000000 --- a/composeApp/src/main/res/drawable/ic_footprints.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - diff --git a/composeApp/src/main/res/drawable/ic_sneaker.xml b/composeApp/src/main/res/drawable/ic_sneaker.xml new file mode 100644 index 0000000..7836cbf --- /dev/null +++ b/composeApp/src/main/res/drawable/ic_sneaker.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/composeApp/src/main/res/values-night/colors.xml b/composeApp/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..35f1511 --- /dev/null +++ b/composeApp/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #FF211D18 + diff --git a/composeApp/src/main/res/values-night/themes.xml b/composeApp/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..e0c9414 --- /dev/null +++ b/composeApp/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/composeApp/src/main/res/values/colors.xml b/composeApp/src/main/res/values/colors.xml new file mode 100644 index 0000000..1d03bff --- /dev/null +++ b/composeApp/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #FF7A9468 + + #FFF5F0EB + diff --git a/composeApp/src/main/res/values/themes.xml b/composeApp/src/main/res/values/themes.xml index 00eb052..409d098 100644 --- a/composeApp/src/main/res/values/themes.xml +++ b/composeApp/src/main/res/values/themes.xml @@ -1,9 +1,21 @@ + - - #FF7A9468 diff --git a/detekt.yml b/detekt.yml index b3f02b6..0529d27 100644 --- a/detekt.yml +++ b/detekt.yml @@ -8,8 +8,10 @@ naming: complexity: TooManyFunctions: - # Preview composables inflate count but are not real logic - ignoreAnnotatedFunctions: ['Preview'] + # Preview composables inflate count but are not real logic. PhonePreview/TabletPreview are the + # project's multipreview annotations (see ui/theme/WornPreviews.kt) — detekt matches on the + # annotation name written at the call site, so the aliases have to be listed too. + ignoreAnnotatedFunctions: ['Preview', 'PhonePreview', 'TabletPreview'] LongParameterList: # Composable functions pass many state + callback params with defaults ignoreDefaultParameters: true diff --git a/iosApp/iosApp/Components/AiLockedSheet.swift b/iosApp/iosApp/Components/AiLockedSheet.swift index 82f1bcf..adbdb08 100644 --- a/iosApp/iosApp/Components/AiLockedSheet.swift +++ b/iosApp/iosApp/Components/AiLockedSheet.swift @@ -17,7 +17,7 @@ struct AiLockedSheet: View { } ZStack { - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .fill(WornColors.accentIndigo) .frame(width: 44, height: 44) Image(systemName: "cpu") @@ -26,11 +26,11 @@ struct AiLockedSheet: View { } Text(String(localized: "ai_locked_title")) - .font(.system(size: 22, weight: .medium)) + .font(.title2.weight(.medium)) .foregroundColor(WornColors.textPrimary) Text(String(localized: "ai_locked_description")) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) .multilineTextAlignment(.center) .lineSpacing(7) diff --git a/iosApp/iosApp/Components/ClothingCard.swift b/iosApp/iosApp/Components/ClothingCard.swift index 02d2a29..1d0cdca 100644 --- a/iosApp/iosApp/Components/ClothingCard.swift +++ b/iosApp/iosApp/Components/ClothingCard.swift @@ -21,9 +21,9 @@ struct ClothingCard: View { .frame(maxWidth: .infinity) .frame(height: photoHeight) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) .overlay( - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: WornShape.large) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .shadow(color: .black.opacity(0.25), radius: 8, x: 0, y: 4) @@ -48,7 +48,7 @@ struct ClothingCard: View { // AI-generated names can run long; left unbounded they push the category row down and // misalign the cards next to them in the grid row. Text(item.name) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) .lineLimit(2) .truncationMode(.tail) @@ -58,7 +58,7 @@ struct ClothingCard: View { .fill(dotColor(for: item.category)) .frame(width: 8, height: 8) Text(displayLabel(for: item.category)) - .font(.system(size: 12)) + .font(.caption) .foregroundColor(WornColors.textMuted) } } diff --git a/iosApp/iosApp/Components/CropEditorView.swift b/iosApp/iosApp/Components/CropEditorView.swift index 8cbb492..3f0848c 100644 --- a/iosApp/iosApp/Components/CropEditorView.swift +++ b/iosApp/iosApp/Components/CropEditorView.swift @@ -48,11 +48,11 @@ struct CropEditorView: View { .accessibilityIdentifier("crop_editor_cancel") Spacer() Text(String(localized: "crop_title")) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(.white) Spacer() Button(String(localized: "crop_apply"), action: applyCrop) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(canApply ? WornColors.accentGreen : WornColors.textMuted) .disabled(!canApply) .accessibilityIdentifier("crop_editor_apply") @@ -63,7 +63,7 @@ struct CropEditorView: View { private var bottomBar: some View { Button(String(localized: "crop_reset")) { selection = bounds } - .font(.system(size: 15)) + .font(.subheadline) .foregroundColor(.white) .disabled(bounds == nil || isProcessing) .padding(.vertical, 12) diff --git a/iosApp/iosApp/Components/CropPhotoButton.swift b/iosApp/iosApp/Components/CropPhotoButton.swift index 3498ccb..30a1b20 100644 --- a/iosApp/iosApp/Components/CropPhotoButton.swift +++ b/iosApp/iosApp/Components/CropPhotoButton.swift @@ -14,7 +14,7 @@ struct CropPhotoButton: View { .font(.system(size: 15)) .foregroundColor(WornColors.textSecondary) Text(String(localized: "crop_photo_button")) - .font(.system(size: 15, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) } } diff --git a/iosApp/iosApp/Components/EmptyStateView.swift b/iosApp/iosApp/Components/EmptyStateView.swift index 86cdd00..3ee4fe0 100644 --- a/iosApp/iosApp/Components/EmptyStateView.swift +++ b/iosApp/iosApp/Components/EmptyStateView.swift @@ -24,7 +24,7 @@ struct EmptyStateView: View { ZStack { Circle() - .fill(Color.white) + .fill(WornColors.bgCard) .frame(width: 130, height: 130) .shadow(color: WornColors.accentIndigo.opacity(0.08), radius: 15, x: 0, y: 0) .overlay( @@ -36,12 +36,12 @@ struct EmptyStateView: View { } Text(title) - .font(.system(size: 24, weight: .semibold)) + .font(.title2.weight(.semibold)) .tracking(-0.5) .foregroundColor(WornColors.textPrimary) Text(description) - .font(.system(size: 15)) + .font(.subheadline) .lineSpacing(4) .multilineTextAlignment(.center) .foregroundColor(WornColors.textSecondary) diff --git a/iosApp/iosApp/Components/ErrorContentView.swift b/iosApp/iosApp/Components/ErrorContentView.swift index c86d33b..ec5cd6a 100644 --- a/iosApp/iosApp/Components/ErrorContentView.swift +++ b/iosApp/iosApp/Components/ErrorContentView.swift @@ -18,19 +18,19 @@ struct ErrorContentView: View { .frame(maxWidth: .infinity) Text(message) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) .multilineTextAlignment(.center) .padding(.top, 24) Button(action: onRetry) { Text(String(localized: "common_retry")) - .font(.system(size: 15, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(retryButtonColor) .padding(.horizontal, 24) .padding(.vertical, 12) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } .buttonStyle(.plain) .padding(.top, 20) diff --git a/iosApp/iosApp/Components/PropertyRow.swift b/iosApp/iosApp/Components/PropertyRow.swift index e568030..8606a40 100644 --- a/iosApp/iosApp/Components/PropertyRow.swift +++ b/iosApp/iosApp/Components/PropertyRow.swift @@ -3,16 +3,16 @@ import SwiftUI struct PropertyRow: View { let label: String let value: String - var fontSize: CGFloat = 15 + var textFont: Font = .subheadline var body: some View { HStack { Text(label) - .font(.system(size: fontSize, weight: .medium)) + .font(textFont.weight(.medium)) .foregroundColor(WornColors.textSecondary) Spacer() Text(value) - .font(.system(size: fontSize, weight: .medium)) + .font(textFont.weight(.medium)) .foregroundColor(WornColors.textPrimary) } } diff --git a/iosApp/iosApp/Components/SelectionHeader.swift b/iosApp/iosApp/Components/SelectionHeader.swift index e521864..2d12657 100644 --- a/iosApp/iosApp/Components/SelectionHeader.swift +++ b/iosApp/iosApp/Components/SelectionHeader.swift @@ -9,7 +9,7 @@ struct SelectionHeader: View { VStack(alignment: .leading, spacing: 8) { HStack { Text(String(format: String(localized: "selected_count"), count)) - .font(.system(size: 28, weight: .medium)) + .font(.title.weight(.medium)) .tracking(-0.8) .foregroundColor(WornColors.textPrimary) Spacer() @@ -20,7 +20,7 @@ struct SelectionHeader: View { Image(systemName: "trash") .font(.system(size: 15)) Text(String(localized: "common_delete")) - .font(.system(size: 15, weight: .semibold)) + .font(.subheadline.weight(.semibold)) } .foregroundColor(.white) .padding(.horizontal, 20) @@ -30,7 +30,7 @@ struct SelectionHeader: View { } } Button(String(localized: "common_cancel")) { onCancel() } - .font(.system(size: 15, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textSecondary) } } diff --git a/iosApp/iosApp/Components/WornBottomBar.swift b/iosApp/iosApp/Components/WornBottomBar.swift index bc1d487..73ecb14 100644 --- a/iosApp/iosApp/Components/WornBottomBar.swift +++ b/iosApp/iosApp/Components/WornBottomBar.swift @@ -63,9 +63,9 @@ struct WornBottomBar: View { .frame(height: 62) .padding(4) .background(WornColors.bgElevated) - .clipShape(RoundedRectangle(cornerRadius: 36)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.extraExtraLarge)) .overlay( - RoundedRectangle(cornerRadius: 36) + RoundedRectangle(cornerRadius: WornShape.extraExtraLarge) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .accessibilityIdentifier("bottom_bar") @@ -105,7 +105,7 @@ private struct TabItem: View { Image(systemName: tab.icon) .font(.system(size: 18)) Text(tab.label) - .font(.system(size: 10, weight: .semibold)) + .font(.caption2.weight(.semibold)) .tracking(0.5) // Labels wrap on a 5-tab compact bar, which clips the descender. Shrink // instead, down far enough for the longest translation to fit its slot, and @@ -115,6 +115,10 @@ private struct TabItem: View { .truncationMode(.tail) .padding(.horizontal, 2) } + // The bar is a fixed-height row of five equal slots, so it cannot grow with the + // largest accessibility sizes the way flowing text can. Cap the scaling here and let + // minimumScaleFactor absorb the rest; the rest of the app stays uncapped. + .dynamicTypeSize(...DynamicTypeSize.accessibility1) .foregroundColor(isActive ? WornColors.textOnColor : WornColors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( @@ -122,8 +126,10 @@ private struct TabItem: View { ? WornColors.accentGreen : Color.clear ) - .clipShape(RoundedRectangle(cornerRadius: 26)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.extraLargeIncreased)) } .buttonStyle(.plain) + // Mirrors the SegmentTick on Android: a discrete position change in a row of segments. + .sensoryFeedback(.selection, trigger: isActive) } } diff --git a/iosApp/iosApp/Components/WornChip.swift b/iosApp/iosApp/Components/WornChip.swift index fc92e1a..8658051 100644 --- a/iosApp/iosApp/Components/WornChip.swift +++ b/iosApp/iosApp/Components/WornChip.swift @@ -8,7 +8,7 @@ struct WornChip: View { var body: some View { Button(action: onTap) { Text(label) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(isActive ? WornColors.textOnColor : WornColors.textSecondary) .padding(.horizontal, 16) .padding(.vertical, 8) @@ -20,6 +20,7 @@ struct WornChip: View { ) } .buttonStyle(.plain) + .sensoryFeedback(.selection, trigger: isActive) } } diff --git a/iosApp/iosApp/Components/WornGradientButton.swift b/iosApp/iosApp/Components/WornGradientButton.swift index 6dfed2a..182b7a4 100644 --- a/iosApp/iosApp/Components/WornGradientButton.swift +++ b/iosApp/iosApp/Components/WornGradientButton.swift @@ -1,10 +1,15 @@ import SwiftUI +/// Gradient stops for the filled CTAs. +/// +/// These read from the dedicated `*Gradient*` tokens rather than the accent roles: the accents +/// invert between appearances while these buttons always draw white label text, so pairing them +/// with `accentGreen` would leave the label at roughly 1.8:1 in dark. See `WornColors`. enum WornGradients { static let save = [WornColors.saveGradientStart, WornColors.saveGradientEnd] - static let green = [WornColors.accentGreen, WornColors.accentGreenDark] - static let greenCta = [WornColors.accentGreen, WornColors.accentGreenEnd] - static let indigo = [WornColors.accentIndigo, Color(hex: 0x556070)] + static let green = [WornColors.greenCtaStart, WornColors.accentGreenDark] + static let greenCta = [WornColors.greenCtaStart, WornColors.greenCtaEnd] + static let indigo = [WornColors.indigoGradientStart, WornColors.indigoGradientEnd] static let disabled = [WornColors.textMuted, WornColors.iconMuted] } @@ -54,21 +59,11 @@ struct WornGradientButton: View { private var buttonText: some View { Text(text) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(.white) } } -private extension Color { - init(hex: UInt) { - self.init( - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0 - ) - } -} - #Preview("iPhone") { VStack(spacing: 16) { WornGradientButton(text: "Save to Wardrobe", action: {}) @@ -77,7 +72,7 @@ private extension Color { text: "Analyze", action: {}, gradientColors: WornGradients.indigo, - cornerRadius: 28, + cornerRadius: WornShape.extraLargeIncreased, shadowRadius: 10, shadowColor: WornColors.accentIndigo.opacity(0.15), shadowY: 6 diff --git a/iosApp/iosApp/Screens/AddItemSheet.swift b/iosApp/iosApp/Screens/AddItemSheet.swift index 718d302..4c78d22 100644 --- a/iosApp/iosApp/Screens/AddItemSheet.swift +++ b/iosApp/iosApp/Screens/AddItemSheet.swift @@ -177,7 +177,7 @@ struct AddItemSheet: View { .font(.system(size: 32)) .foregroundColor(WornColors.iconMuted) Text(String(localized: "add_item_photo_hint")) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textSecondary) } } @@ -188,9 +188,9 @@ struct AddItemSheet: View { } .frame(maxWidth: .infinity) .frame(height: 140) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) .overlay( - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: WornShape.large) .stroke(WornColors.borderStrong, lineWidth: 1.5) ) } @@ -211,7 +211,7 @@ struct AddItemSheet: View { set: { onRemoveBackgroundChange($0) } )) { Text(String(localized: "add_item_remove_background")) - .font(.system(size: 15, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) } .tint(WornColors.accentGreen) @@ -248,10 +248,10 @@ struct AddItemSheet: View { } label: { HStack(spacing: 6) { Text("✦") - .font(.system(size: 12, weight: .semibold)) + .font(.caption.weight(.semibold)) .foregroundColor(.white) Text(String(localized: "add_item_ai_badge")) - .font(.system(size: 12, weight: .semibold)) + .font(.caption.weight(.semibold)) .foregroundColor(.white) } .padding(.horizontal, 12) @@ -269,12 +269,12 @@ struct AddItemSheet: View { private var nameField: some View { TextField(String(localized: "add_item_name_hint"), text: $name) - .font(.system(size: 15)) + .font(.subheadline) .padding(16) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .accessibilityIdentifier("add_item_name_field") @@ -293,7 +293,7 @@ struct AddItemSheet: View { .frame(width: 20, height: 20) } Text(selectedCategory.map { displayName(for: $0) } ?? String(localized: "label_category")) - .font(.system(size: 15)) + .font(.subheadline) .foregroundColor(selectedCategory != nil ? WornColors.textPrimary : WornColors.iconMuted) Spacer() Image(systemName: categoryExpanded ? "chevron.up" : "chevron.down") @@ -323,7 +323,7 @@ struct AddItemSheet: View { .foregroundColor(WornColors.textSecondary) .frame(width: 20, height: 20) Text(label) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) Spacer() } @@ -339,9 +339,9 @@ struct AddItemSheet: View { } } .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .accessibilityIdentifier("add_item_category_dropdown") @@ -350,7 +350,7 @@ struct AddItemSheet: View { private var colorSection: some View { VStack(alignment: .leading, spacing: 10) { Text(String(localized: "label_color")) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 7), spacing: 12) { @@ -387,7 +387,7 @@ struct AddItemSheet: View { private var seasonSection: some View { VStack(alignment: .leading, spacing: 10) { Text(String(localized: "label_season")) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) HStack(spacing: 8) { @@ -478,7 +478,7 @@ struct AddItemSheet: View { Text(selectedSubcategory.map { localizedSubcategoryName($0) } ?? String(localized: "label_subcategory")) - .font(.system(size: 15)) + .font(.subheadline) .foregroundColor(selectedSubcategory != nil ? WornColors.textPrimary : WornColors.iconMuted) Spacer() Image(systemName: subcategoryExpanded ? "chevron.up" : "chevron.down") @@ -498,7 +498,7 @@ struct AddItemSheet: View { withAnimation { subcategoryExpanded = false } } label: { Text(label) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) @@ -513,9 +513,9 @@ struct AddItemSheet: View { } } .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .stroke(WornColors.borderSubtle, lineWidth: 1) ) } @@ -532,7 +532,7 @@ struct AddItemSheet: View { private var fitSection: some View { VStack(alignment: .leading, spacing: 10) { Text(String(localized: "label_fit")) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) HStack(spacing: 8) { @@ -560,7 +560,7 @@ struct AddItemSheet: View { private var materialSection: some View { VStack(alignment: .leading, spacing: 10) { Text(String(localized: "label_material")) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) { @@ -579,6 +579,11 @@ struct AddItemSheet: View { AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) } +#Preview("iPhone · Dark") { + AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { AddItemSheet(isSaving: false, isAiAvailable: false, onSave: { _, _, _, _, _, _, _, _ in }, onDismiss: {}) } diff --git a/iosApp/iosApp/Screens/CreateOutfitSheet.swift b/iosApp/iosApp/Screens/CreateOutfitSheet.swift index 2d572ac..e6b8d56 100644 --- a/iosApp/iosApp/Screens/CreateOutfitSheet.swift +++ b/iosApp/iosApp/Screens/CreateOutfitSheet.swift @@ -60,12 +60,12 @@ struct CreateOutfitSheet: View { private var nameField: some View { TextField(String(localized: "create_outfit_name_hint"), text: $name) - .font(.system(size: 15)) + .font(.subheadline) .padding(16) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .accessibilityIdentifier("create_outfit_name_field") @@ -74,12 +74,12 @@ struct CreateOutfitSheet: View { private var selectItemsHeader: some View { HStack { Text(String(localized: "create_outfit_select_items")) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(WornColors.textPrimary) Spacer() if !selectedItemIds.isEmpty { Text(String(format: String(localized: "selected_count"), selectedItemIds.count)) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(WornColors.accentGreen) } } @@ -129,9 +129,9 @@ private struct SelectableItemCell: View { .frame(maxWidth: .infinity) .frame(height: 100) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) .overlay( - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: WornShape.large) .stroke( isSelected ? WornColors.accentGreen : WornColors.borderSubtle, lineWidth: isSelected ? 2 : 1 @@ -178,6 +178,20 @@ private let previewItems: [ClothingItem] = [ ) } +#Preview("iPhone · Dark") { + CreateOutfitSheet( + clothingItems: previewItems, + selectedItemIds: Set(["1", "2"]), + activeCategory: nil, + isSaving: false, + onCategorySelected: { _ in }, + onToggleItem: { _ in }, + onSave: { _ in }, + onDismiss: {} + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { CreateOutfitSheet( clothingItems: previewItems, diff --git a/iosApp/iosApp/Screens/GapsScreen.swift b/iosApp/iosApp/Screens/GapsScreen.swift index 5b9da21..97fe0f1 100644 --- a/iosApp/iosApp/Screens/GapsScreen.swift +++ b/iosApp/iosApp/Screens/GapsScreen.swift @@ -96,11 +96,11 @@ struct GapsContent: View { ScrollView { VStack(alignment: .leading, spacing: 0) { Text(String(localized: "gaps_title")) - .font(.system(size: 28, weight: .semibold)) + .font(.title.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .padding(.top, 24) Text(String(localized: "gaps_subtitle")) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) .padding(.top, 4) .padding(.bottom, 20) @@ -155,13 +155,13 @@ struct GapsContent: View { .frame(maxWidth: .infinity) Text(String(localized: "gaps_complete_title")) - .font(.system(size: 18, weight: .semibold)) + .font(.headline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .padding(.top, 24) .multilineTextAlignment(.center) Text(String(localized: "gaps_complete_description")) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) .multilineTextAlignment(.center) .padding(.top, 8) @@ -204,12 +204,12 @@ struct GapsContent: View { HStack { VStack(alignment: .leading, spacing: 2) { Text(state.isAiMode ? String(localized: "gaps_banner_ai_title") : String(localized: "gaps_banner_common_title")) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(.white) Text(state.isAiMode ? String(localized: "gaps_banner_ai_subtitle") : String(localized: "gaps_banner_common_subtitle")) - .font(.system(size: 13)) + .font(.footnote) .foregroundColor(.white.opacity(0.8)) } Spacer() @@ -219,7 +219,7 @@ struct GapsContent: View { } .padding(16) .background(state.isAiMode ? WornColors.accentGreen : WornColors.accentGreenDark) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } .buttonStyle(.plain) .accessibilityIdentifier("gaps_banner") @@ -227,7 +227,7 @@ struct GapsContent: View { private func sectionLabel(_ text: String) -> some View { Text(text.uppercased()) - .font(.system(size: 12, weight: .medium)) + .font(.caption.weight(.medium)) .foregroundColor(WornColors.textSecondary) .tracking(0.5) } @@ -240,12 +240,12 @@ struct GapsContent: View { categoryIcon(for: recommendation.mappedCategory) VStack(alignment: .leading, spacing: 2) { Text(recommendation.itemName) - .font(.system(size: 15, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) Text(state.isAiMode ? String(format: String(localized: "gaps_pairing_ai"), recommendation.pairingCount) : String(localized: "gaps_pairing_common")) - .font(.system(size: 12)) + .font(.caption) .foregroundColor(WornColors.textSecondary) } Spacer() @@ -255,13 +255,13 @@ struct GapsContent: View { } .padding(12) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) } .buttonStyle(.plain) } private func categoryIcon(for category: Shared.Category) -> some View { - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: WornShape.medium) .fill(dotColor(for: category)) .frame(width: 36, height: 36) .overlay( @@ -322,7 +322,7 @@ private struct GapDetailSheet: View { private var detailHeader: some View { VStack(alignment: .leading, spacing: 0) { ZStack { - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: WornShape.large) .fill(WornColors.bgCard) .frame(height: 140) Image(systemName: iconName(for: recommendation.mappedCategory)) @@ -332,7 +332,7 @@ private struct GapDetailSheet: View { .frame(maxWidth: .infinity) Text(recommendation.itemName) - .font(.system(size: 22, weight: .semibold)) + .font(.title2.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .padding(.top, 16) @@ -341,7 +341,7 @@ private struct GapDetailSheet: View { .fill(dotColor(for: recommendation.mappedCategory)) .frame(width: 8, height: 8) Text(displayLabel(for: recommendation.mappedCategory)) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) } .padding(.top, 4) @@ -356,13 +356,13 @@ private struct GapDetailSheet: View { Text(isAiMode ? String(format: String(localized: "gaps_pairing_ai"), recommendation.pairingCount) : String(localized: "gaps_pairing_common")) - .font(.system(size: 13)) + .font(.footnote) .foregroundColor(WornColors.textSecondary) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.small)) } private var detailRows: some View { @@ -391,11 +391,11 @@ private struct GapDetailSheet: View { private func detailRow(_ label: String, _ value: String) -> some View { HStack { Text(label) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) Spacer() Text(value) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) } .padding(.vertical, 8) @@ -405,7 +405,7 @@ private struct GapDetailSheet: View { VStack(spacing: 8) { Button(action: onAddToWardrobe) { Text(String(localized: "gaps_add_to_wardrobe")) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 52) @@ -415,18 +415,18 @@ private struct GapDetailSheet: View { startPoint: .top, endPoint: .bottom ) ) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } .accessibilityIdentifier("gap_add_to_wardrobe") Button(action: onDismiss) { Text(String(localized: "gaps_dismiss")) - .font(.system(size: 15, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textSecondary) .frame(maxWidth: .infinity) .frame(height: 48) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } .accessibilityIdentifier("gap_dismiss") } @@ -514,6 +514,17 @@ private let previewGaps: [GapRecommendation] = [ ) } +#Preview("iPhone · Dark") { + GapsContent( + state: GapsState( + recommendations: previewGaps, isLoading: false, isSaving: false, + isAiAvailable: true, isAiMode: true, error: nil + ), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPhone - Complete") { GapsContent( state: GapsState( @@ -524,6 +535,17 @@ private let previewGaps: [GapRecommendation] = [ ) } +#Preview("iPhone - Complete · Dark") { + GapsContent( + state: GapsState( + recommendations: [], isLoading: false, isSaving: false, + isAiAvailable: false, isAiMode: false, error: nil + ), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { GapsContent( state: GapsState( diff --git a/iosApp/iosApp/Screens/ItemDetailSheet.swift b/iosApp/iosApp/Screens/ItemDetailSheet.swift index c28256e..3af61c7 100644 --- a/iosApp/iosApp/Screens/ItemDetailSheet.swift +++ b/iosApp/iosApp/Screens/ItemDetailSheet.swift @@ -12,11 +12,11 @@ struct ItemDetailSheet: View { private var photoHeight: CGFloat { isCompact ? 280 : 360 } private var photoRadius: CGFloat { isCompact ? 20 : 24 } - private var nameSize: CGFloat { isCompact ? 22 : 26 } - private var propFontSize: CGFloat { isCompact ? 14 : 15 } + private var nameFont: Font { isCompact ? .title2 : .title } + private var propFont: Font { isCompact ? .subheadline : .callout } private var propGap: CGFloat { isCompact ? 14 : 16 } private var buttonHeight: CGFloat { isCompact ? 48 : 52 } - private var buttonFontSize: CGFloat { isCompact ? 15 : 16 } + private var buttonFont: Font { isCompact ? .subheadline : .callout } private var contentPadding: CGFloat { isCompact ? 24 : 32 } private var sectionGap: CGFloat { isCompact ? 20 : 24 } private var placeholderIconSize: CGFloat { isCompact ? 64 : 80 } @@ -68,7 +68,7 @@ struct ItemDetailSheet: View { private var nameGroup: some View { VStack(alignment: .leading, spacing: 6) { Text(item.name) - .font(.system(size: nameSize, weight: .semibold)) + .font(nameFont.weight(.semibold)) .foregroundColor(WornColors.textPrimary) HStack(spacing: 8) { @@ -76,7 +76,7 @@ struct ItemDetailSheet: View { .fill(dotColor(for: item.category)) .frame(width: 10, height: 10) Text(displayLabel(for: item.category)) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) } } @@ -94,7 +94,7 @@ struct ItemDetailSheet: View { if !item.colors.isEmpty { HStack { Text(String(localized: "label_color")) - .font(.system(size: propFontSize, weight: .medium)) + .font(propFont.weight(.medium)) .foregroundColor(WornColors.textSecondary) Spacer() HStack(spacing: 8) { @@ -105,7 +105,7 @@ struct ItemDetailSheet: View { Circle().stroke(WornColors.borderSubtle, lineWidth: 1) ) Text(item.colors.map { $0.capitalized }.joined(separator: ", ")) - .font(.system(size: propFontSize, weight: .medium)) + .font(propFont.weight(.medium)) .foregroundColor(WornColors.textPrimary) } } @@ -115,19 +115,19 @@ struct ItemDetailSheet: View { let seasonText = item.seasons.count == Season.entries.count ? String(localized: "common_all_seasons") : item.seasons.map { seasonDisplayName($0) }.joined(separator: ", ") - PropertyRow(label: String(localized: "label_season"), value: seasonText, fontSize: propFontSize) + PropertyRow(label: String(localized: "label_season"), value: seasonText, textFont: propFont) } if let fit = item.fit { - PropertyRow(label: String(localized: "label_fit"), value: fitDisplayName(fit), fontSize: propFontSize) + PropertyRow(label: String(localized: "label_fit"), value: fitDisplayName(fit), textFont: propFont) } if let subcategory = item.subcategory { - PropertyRow(label: String(localized: "label_subcategory"), value: subcategoryDisplayName(subcategory), fontSize: propFontSize) + PropertyRow(label: String(localized: "label_subcategory"), value: subcategoryDisplayName(subcategory), textFont: propFont) } if let material = item.material { - PropertyRow(label: String(localized: "label_material"), value: materialDisplayName(material), fontSize: propFontSize) + PropertyRow(label: String(localized: "label_material"), value: materialDisplayName(material), textFont: propFont) } } } @@ -135,29 +135,34 @@ struct ItemDetailSheet: View { private var buttons: some View { VStack(spacing: 12) { + // Edit is the primary action, so it takes the filled button. Delete was previously the + // filled one — a full-width solid red block ranked *below* a plain white Edit — which + // gave the destructive action more visual weight than the thing people came to do. Button { onEdit(item) } label: { Text(String(localized: "item_detail_edit")) - .font(.system(size: buttonFontSize, weight: .semibold)) - .foregroundColor(WornColors.textPrimary) + .font(buttonFont.weight(.semibold)) + .foregroundColor(WornColors.textOnColor) .frame(maxWidth: .infinity) .frame(height: buttonHeight) - .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 24)) - .overlay( - RoundedRectangle(cornerRadius: 24) - .stroke(WornColors.borderSubtle, lineWidth: 1) - ) + .background(WornColors.accentGreen) + .clipShape(RoundedRectangle(cornerRadius: WornShape.extraLarge)) } .accessibilityIdentifier("item_detail_edit") - Button { showDeleteAlert = true } label: { + // `role: .destructive` is what makes VoiceOver announce this as destructive and lets + // the system style it; the outline keeps it unmistakable without shouting. Tinting the + // label rather than filling also survives dark, where deleteRed is a light #F2B8AC and + // white on it is unreadable. + Button(role: .destructive) { showDeleteAlert = true } label: { Text(String(localized: "item_detail_delete")) - .font(.system(size: buttonFontSize, weight: .semibold)) - .foregroundColor(.white) + .font(buttonFont.weight(.semibold)) + .foregroundColor(WornColors.deleteRed) .frame(maxWidth: .infinity) .frame(height: buttonHeight) - .background(WornColors.deleteRed) - .clipShape(RoundedRectangle(cornerRadius: 24)) + .overlay( + RoundedRectangle(cornerRadius: WornShape.extraLarge) + .stroke(WornColors.deleteRed, lineWidth: 1) + ) } .accessibilityIdentifier("item_detail_delete") } @@ -245,6 +250,14 @@ private let previewItem = ClothingItem( ) } +#Preview("iPhone · Dark") { + ItemDetailSheet( + item: previewItem, isCompact: true, + onEdit: { _ in }, onDelete: { _ in } + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { ItemDetailSheet( item: previewItem, isCompact: false, diff --git a/iosApp/iosApp/Screens/OutfitDetailSheet.swift b/iosApp/iosApp/Screens/OutfitDetailSheet.swift index 5762f4e..0f0e3a1 100644 --- a/iosApp/iosApp/Screens/OutfitDetailSheet.swift +++ b/iosApp/iosApp/Screens/OutfitDetailSheet.swift @@ -12,14 +12,14 @@ struct OutfitDetailSheet: View { private var contentPadding: CGFloat { isCompact ? 24 : 32 } private var sectionGap: CGFloat { isCompact ? 20 : 24 } - private var nameSize: CGFloat { isCompact ? 22 : 26 } + private var nameFont: Font { isCompact ? .title2 : .title } private var cardSize: CGFloat { isCompact ? 200 : 300 } private var cardRadius: CGFloat { isCompact ? 18 : 20 } private var cardGap: CGFloat { isCompact ? 12 : 16 } - private var propFontSize: CGFloat { isCompact ? 14 : 15 } + private var propFont: Font { isCompact ? .subheadline : .callout } private var propGap: CGFloat { isCompact ? 14 : 16 } private var buttonHeight: CGFloat { isCompact ? 48 : 52 } - private var buttonFontSize: CGFloat { isCompact ? 15 : 16 } + private var buttonFont: Font { isCompact ? .subheadline : .callout } private var outfitItems: [ClothingItem] { outfit.itemIds.compactMap { id in @@ -32,7 +32,7 @@ struct OutfitDetailSheet: View { VStack(spacing: sectionGap) { // Title Text(outfit.name) - .font(.system(size: nameSize, weight: .semibold)) + .font(nameFont.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, contentPadding) @@ -57,8 +57,8 @@ struct OutfitDetailSheet: View { // Properties VStack(spacing: propGap) { - PropertyRow(label: String(localized: "label_items"), value: String(format: String(localized: "outfit_detail_items_count"), outfit.itemIds.count), fontSize: propFontSize) - PropertyRow(label: String(localized: "label_season"), value: deriveSeasonText(), fontSize: propFontSize) + PropertyRow(label: String(localized: "label_items"), value: String(format: String(localized: "outfit_detail_items_count"), outfit.itemIds.count), textFont: propFont) + PropertyRow(label: String(localized: "label_season"), value: deriveSeasonText(), textFont: propFont) } .padding(.horizontal, contentPadding) @@ -66,14 +66,14 @@ struct OutfitDetailSheet: View { VStack(spacing: 12) { Button { onEdit(outfit) } label: { Text(String(localized: "outfit_detail_edit")) - .font(.system(size: buttonFontSize, weight: .semibold)) + .font(buttonFont.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .frame(maxWidth: .infinity) .frame(height: buttonHeight) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 24)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.extraLarge)) .overlay( - RoundedRectangle(cornerRadius: 24) + RoundedRectangle(cornerRadius: WornShape.extraLarge) .stroke(WornColors.borderSubtle, lineWidth: 1) ) } @@ -81,12 +81,12 @@ struct OutfitDetailSheet: View { Button { showDeleteAlert = true } label: { Text(String(localized: "outfit_detail_delete")) - .font(.system(size: buttonFontSize, weight: .semibold)) + .font(buttonFont.weight(.semibold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: buttonHeight) .background(WornColors.deleteRed) - .clipShape(RoundedRectangle(cornerRadius: 24)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.extraLarge)) } .accessibilityIdentifier("outfit_detail_delete") } @@ -117,7 +117,7 @@ struct OutfitDetailSheet: View { .shadow(color: .black.opacity(0.25), radius: 8, x: 0, y: 4) Text(item.name) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(WornColors.textPrimary) } } @@ -164,6 +164,14 @@ private let previewOutfit = Outfit(id: "1", name: "Weekend Casual", itemIds: ["i ) } +#Preview("iPhone · Dark") { + OutfitDetailSheet( + outfit: previewOutfit, clothingItems: previewItems, + isCompact: true, onEdit: { _ in }, onDelete: { _ in } + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { OutfitDetailSheet( outfit: previewOutfit, clothingItems: previewItems, diff --git a/iosApp/iosApp/Screens/OutfitsScreen.swift b/iosApp/iosApp/Screens/OutfitsScreen.swift index abdafe5..af6f7eb 100644 --- a/iosApp/iosApp/Screens/OutfitsScreen.swift +++ b/iosApp/iosApp/Screens/OutfitsScreen.swift @@ -144,7 +144,7 @@ struct OutfitsContent: View { VStack(alignment: .leading, spacing: 8) { HStack { Text(String(localized: "outfits_title")) - .font(.system(size: state.outfits.isEmpty ? 22 : 28, weight: .semibold)) + .font((state.outfits.isEmpty ? Font.title2 : Font.title).weight(.semibold)) .tracking(-0.5) .foregroundColor(WornColors.textPrimary) Spacer() @@ -154,7 +154,7 @@ struct OutfitsContent: View { Image(systemName: "plus") .font(.system(size: 12, weight: .semibold)) Text(String(localized: "outfits_button_create")) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) } .foregroundColor(.white) .padding(.horizontal, 16) @@ -168,7 +168,7 @@ struct OutfitsContent: View { if !state.outfits.isEmpty { Text(String(format: String(localized: "saved_combinations"), state.outfits.count)) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textSecondary) } } @@ -193,6 +193,9 @@ struct OutfitsContent: View { } } .onLongPressGesture { + // Long-press is the only way into selection mode and has no visual affordance + // before it fires, so the impact is what confirms it. + UIImpactFeedbackGenerator(style: .medium).impactOccurred() onToggleSelection(outfit.id) } } @@ -214,7 +217,7 @@ struct OutfitsContent: View { text: String(localized: "outfits_empty_cta"), action: onCreateClick, gradientColors: WornGradients.greenCta, - cornerRadius: 28, + cornerRadius: WornShape.extraLargeIncreased, shadowRadius: 10, shadowColor: WornColors.accentIndigo.opacity(0.15), shadowY: 6, @@ -264,9 +267,9 @@ private struct OutfitCardView: View { .frame(height: 170) .frame(maxWidth: .infinity, alignment: .leading) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.largeIncreased)) .overlay( - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: WornShape.largeIncreased) .stroke( isSelected ? WornColors.accentGreen : WornColors.borderSubtle, lineWidth: 1 @@ -289,7 +292,7 @@ private struct OutfitCardView: View { private func itemThumbnail(for category: Shared.Category?) -> some View { ZStack { - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: WornShape.medium) .fill(WornColors.bgElevated) .frame(width: 40, height: 40) Image(systemName: iconName(for: category)) @@ -311,12 +314,12 @@ private struct OutfitCardView: View { private var itemCountBadge: some View { Text(String(format: String(localized: "outfit_detail_items_count"), outfit.itemIds.count)) - .font(.system(size: 11, weight: .semibold)) + .font(.caption2.weight(.semibold)) .foregroundColor(.white) .padding(.horizontal, 10) .padding(.vertical, 4) .background(badgeColor) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.small)) } private var bottomRow: some View { @@ -324,12 +327,12 @@ private struct OutfitCardView: View { VStack(alignment: .leading, spacing: 2) { // Auto-generated names concatenate every item, so they can outgrow the card. Text(outfit.name) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .lineLimit(1) .truncationMode(.tail) Text(formatDate(outfit.createdAt)) - .font(.system(size: 12)) + .font(.caption) .foregroundColor(WornColors.textSecondary) } Spacer() @@ -361,6 +364,14 @@ private let previewOutfits: [Outfit] = [ ) } +#Preview("iPhone · Dark") { + OutfitsContent( + state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPhone - Selection") { OutfitsContent( state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(["1", "3"]), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), @@ -368,6 +379,14 @@ private let previewOutfits: [Outfit] = [ ) } +#Preview("iPhone - Selection · Dark") { + OutfitsContent( + state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(["1", "3"]), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPhone - Empty") { OutfitsContent( state: OutfitState(outfits: [], isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), @@ -375,6 +394,14 @@ private let previewOutfits: [Outfit] = [ ) } +#Preview("iPhone - Empty · Dark") { + OutfitsContent( + state: OutfitState(outfits: [], isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { OutfitsContent( state: OutfitState(outfits: previewOutfits, isLoading: false, isDeleting: false, selectedIds: Set(), error: nil, itemCategories: [:], allClothingItems: [], clothingItems: [], selectedItemIds: Set(), activeItemCategory: nil, isSaving: false, isLoadingItems: false), diff --git a/iosApp/iosApp/Screens/SettingsScreen.swift b/iosApp/iosApp/Screens/SettingsScreen.swift index 012cc59..befb32f 100644 --- a/iosApp/iosApp/Screens/SettingsScreen.swift +++ b/iosApp/iosApp/Screens/SettingsScreen.swift @@ -67,7 +67,7 @@ struct SettingsContent: View { ScrollView { VStack(alignment: .leading, spacing: 0) { Text(String(localized: "settings_title")) - .font(.system(size: 28, weight: .semibold)) + .font(.title.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .padding(.top, 24) .padding(.bottom, 28) @@ -170,7 +170,7 @@ struct SettingsContent: View { private func sectionLabel(_ text: String) -> some View { Text(text) - .font(.system(size: 12, weight: .medium)) + .font(.caption.weight(.medium)) .foregroundColor(WornColors.textSecondary) .tracking(0.5) } @@ -178,7 +178,7 @@ struct SettingsContent: View { private func settingsCard(iconColor: Color, iconName: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 14) { - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .fill(iconColor) .frame(width: 40, height: 40) .overlay( @@ -188,10 +188,10 @@ struct SettingsContent: View { ) VStack(alignment: .leading, spacing: 2) { Text(title) - .font(.system(size: 16, weight: .medium)) + .font(.callout.weight(.medium)) .foregroundColor(WornColors.textPrimary) Text(subtitle) - .font(.system(size: 13)) + .font(.footnote) .foregroundColor(WornColors.textSecondary) } Spacer() @@ -201,7 +201,7 @@ struct SettingsContent: View { } .padding(16) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } .buttonStyle(.plain) } @@ -217,7 +217,7 @@ struct SettingsContent: View { enabled: Bool ) -> some View { HStack(spacing: 14) { - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .fill(iconColor) .frame(width: 40, height: 40) .overlay( @@ -227,10 +227,10 @@ struct SettingsContent: View { ) VStack(alignment: .leading, spacing: 2) { Text(title) - .font(.system(size: 16, weight: .medium)) + .font(.callout.weight(.medium)) .foregroundColor(WornColors.textPrimary) Text(subtitle) - .font(.system(size: 13)) + .font(.footnote) .foregroundColor(WornColors.textSecondary) } Spacer() @@ -241,7 +241,7 @@ struct SettingsContent: View { } .padding(16) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } private var appVersion: String { @@ -252,11 +252,11 @@ struct SettingsContent: View { VStack(spacing: 0) { HStack { Text(String(localized: "settings_version")) - .font(.system(size: 15)) + .font(.subheadline) .foregroundColor(WornColors.textPrimary) Spacer() Text(appVersion) - .font(.system(size: 15)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) } .padding(16) @@ -270,7 +270,7 @@ struct SettingsContent: View { } label: { HStack { Text(String(localized: "settings_suggestions_bugs")) - .font(.system(size: 15)) + .font(.subheadline) .foregroundColor(WornColors.textPrimary) Spacer() Image(systemName: "chevron.right") @@ -290,7 +290,7 @@ struct SettingsContent: View { } label: { HStack { Text(String(localized: "settings_licenses")) - .font(.system(size: 15)) + .font(.subheadline) .foregroundColor(WornColors.textPrimary) Spacer() Image(systemName: "chevron.right") @@ -302,7 +302,7 @@ struct SettingsContent: View { .buttonStyle(.plain) } .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } @State private var showCopied = false @@ -310,11 +310,11 @@ struct SettingsContent: View { private var donationCard: some View { VStack(alignment: .leading, spacing: 4) { Text(String(localized: "settings_donate_title")) - .font(.system(size: 15, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textPrimary) Text(String(localized: "settings_donate_subtitle")) - .font(.system(size: 13)) + .font(.footnote) .foregroundColor(WornColors.textSecondary) Button { @@ -323,18 +323,18 @@ struct SettingsContent: View { } label: { HStack { Text(donationLNAddress) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(WornColors.accentGreen) Spacer() Text(showCopied ? String(localized: "settings_donate_copied") : String(localized: "settings_donate_copy")) - .font(.system(size: 12, weight: .medium)) + .font(.caption.weight(.medium)) .foregroundColor(WornColors.textSecondary) } .padding(12) .background(WornColors.bgElevated) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .stroke(WornColors.borderSubtle, lineWidth: 1) ) } @@ -343,7 +343,7 @@ struct SettingsContent: View { } .padding(16) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) } } @@ -362,10 +362,10 @@ private struct ProfileSheet: View { ScrollView { VStack(alignment: .leading, spacing: 20) { Text(String(localized: "settings_your_profile")) - .font(.system(size: 24, weight: .semibold)) + .font(.title2.weight(.semibold)) .foregroundColor(WornColors.textPrimary) Text(String(localized: "settings_profile_help")) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) chipGroup(title: String(localized: "label_body_type"), options: bodyTypeOptions, @@ -435,13 +435,13 @@ private struct ApiKeySheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { Text(String(localized: "settings_connect_claude")) - .font(.system(size: 24, weight: .semibold)) + .font(.title2.weight(.semibold)) .foregroundColor(WornColors.textPrimary) Text(String(localized: "settings_api_description")) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) Text(String(localized: "settings_api_get_key")) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(WornColors.accentGreen) HStack { @@ -453,7 +453,7 @@ private struct ApiKeySheet: View { } } .disabled(hasApiKey) - .font(.system(size: 15)) + .font(.subheadline) Button { passwordVisible.toggle() } label: { Image(systemName: passwordVisible ? "eye" : "eye.slash") @@ -462,9 +462,9 @@ private struct ApiKeySheet: View { } .padding(14) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .accessibilityIdentifier("api_key_field") @@ -484,7 +484,7 @@ private struct ApiKeySheet: View { dismiss() } label: { Text(String(localized: "settings_remove_key")) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textSecondary) } .accessibilityIdentifier("api_key_remove_button") @@ -517,13 +517,13 @@ private struct YouCamCredentialsSheet: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text(String(localized: "settings_youcam_title")) - .font(.system(size: 24, weight: .semibold)) + .font(.title2.weight(.semibold)) .foregroundColor(WornColors.textPrimary) Text(String(localized: "settings_youcam_description")) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) Text(String(localized: "settings_youcam_get_key")) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(WornColors.accentGreen) fieldLabel(String(localized: "settings_youcam_client_id_hint")) @@ -543,7 +543,7 @@ private struct YouCamCredentialsSheet: View { if let errorMessage, !verifying { Text(errorMessage) - .font(.system(size: 13)) + .font(.footnote) .foregroundColor(WornColors.deleteRed) .accessibilityIdentifier("youcam_error") } @@ -556,7 +556,7 @@ private struct YouCamCredentialsSheet: View { dismiss() } label: { Text(String(localized: "settings_youcam_remove")) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textSecondary) } .accessibilityIdentifier("youcam_remove_button") @@ -573,7 +573,7 @@ private struct YouCamCredentialsSheet: View { private func fieldLabel(_ text: String) -> some View { Text(text) - .font(.system(size: 13, weight: .semibold)) + .font(.footnote.weight(.semibold)) .foregroundColor(WornColors.textPrimary) } @@ -587,7 +587,7 @@ private struct YouCamCredentialsSheet: View { } } .disabled(!enabled) - .font(.system(size: 15)) + .font(.subheadline) Button { visible.wrappedValue.toggle() } label: { Image(systemName: visible.wrappedValue ? "eye" : "eye.slash") @@ -596,9 +596,9 @@ private struct YouCamCredentialsSheet: View { } .padding(14) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.medium)) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: WornShape.medium) .stroke(WornColors.borderSubtle, lineWidth: 1) ) } @@ -611,7 +611,7 @@ private func chipGroup( ) -> some View { VStack(alignment: .leading, spacing: 10) { Text(title) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) FlowLayout(spacing: 8) { ForEach(Array(options.enumerated()), id: \.offset) { _, item in @@ -631,10 +631,10 @@ private func multiChipGroup( VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Text(title) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) Text(String(localized: "settings_multi_select")) - .font(.system(size: 12)) + .font(.caption) .foregroundColor(WornColors.textMuted) } FlowLayout(spacing: 8) { @@ -759,6 +759,14 @@ private func previewSettingsState( ) } +#Preview("iPhone · Dark") { + SettingsContent( + state: previewSettingsState(availability: OnDeviceAiAvailabilityAvailable()), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPhone - AI unavailable") { SettingsContent( state: previewSettingsState( @@ -768,6 +776,16 @@ private func previewSettingsState( ) } +#Preview("iPhone - AI unavailable · Dark") { + SettingsContent( + state: previewSettingsState( + availability: OnDeviceAiAvailabilityUnavailable(reason: .unsupportedDevice) + ), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { SettingsContent( state: previewSettingsState(availability: OnDeviceAiAvailabilityAvailable()), diff --git a/iosApp/iosApp/Screens/TryItScreen.swift b/iosApp/iosApp/Screens/TryItScreen.swift index f3ec315..b6a92c2 100644 --- a/iosApp/iosApp/Screens/TryItScreen.swift +++ b/iosApp/iosApp/Screens/TryItScreen.swift @@ -154,12 +154,12 @@ struct TryItScreen: View { } Text(String(localized: "tryit_locked_title")) - .font(.system(size: isCompact ? 24 : 26, weight: .medium)) + .font((isCompact ? Font.title2 : Font.title).weight(.medium)) .foregroundColor(WornColors.textPrimary) .multilineTextAlignment(.center) Text(String(localized: "tryit_locked_description")) - .font(.system(size: isCompact ? 15 : 16)) + .font(isCompact ? .subheadline : .callout) .foregroundColor(WornColors.textSecondary) .multilineTextAlignment(.center) .lineSpacing(4) @@ -205,7 +205,7 @@ struct TryItScreen: View { private var phoneContent: some View { VStack(alignment: .leading, spacing: 20) { - tryItTitle(fontSize: 28) + tryItTitle(font: .title) .id(Self.analysisAnchor) uploadZone(height: 200) if photoData != nil { garmentCropButton } @@ -241,7 +241,7 @@ struct TryItScreen: View { private var tabletContent: some View { VStack(alignment: .leading, spacing: 28) { - tryItTitle(fontSize: 32) + tryItTitle(font: .largeTitle) .id(Self.analysisAnchor) HStack(alignment: .top, spacing: 32) { @@ -293,9 +293,9 @@ struct TryItScreen: View { // MARK: - Components - private func tryItTitle(fontSize: CGFloat) -> some View { + private func tryItTitle(font: Font) -> some View { Text(String(localized: "tryit_title")) - .font(.system(size: fontSize, weight: .semibold)) + .font(font.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .tracking(-0.8) } @@ -320,7 +320,7 @@ struct TryItScreen: View { .font(.system(size: 44)) .foregroundColor(WornColors.iconMuted) Text(String(localized: "tryit_upload_hint")) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(WornColors.textSecondary) .multilineTextAlignment(.center) } @@ -329,9 +329,9 @@ struct TryItScreen: View { .frame(maxWidth: .infinity) .frame(height: height) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.largeIncreased)) .overlay( - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: WornShape.largeIncreased) .stroke(WornColors.borderStrong, lineWidth: 1.5) ) .shadow(color: .black.opacity(0.03), radius: 2, y: 1) @@ -348,7 +348,7 @@ struct TryItScreen: View { viewModel.analyzePhoto(imageData: data) }, gradientColors: WornGradients.indigo, - cornerRadius: 28, + cornerRadius: WornShape.extraLargeIncreased, shadowRadius: 10, shadowColor: WornColors.accentIndigo.opacity(0.15), shadowY: 6, @@ -400,7 +400,7 @@ struct TryItScreen: View { if !matchingItems.isEmpty { VStack(alignment: .leading, spacing: 12) { Text(String(localized: "tryit_pairs_with")) - .font(.system(size: 18, weight: .semibold)) + .font(.headline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .tracking(-0.2) @@ -428,9 +428,9 @@ struct TryItScreen: View { } .frame(width: size, height: size) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.large)) .overlay( - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: WornShape.large) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .shadow(color: .black.opacity(0.04), radius: 4, y: 2) @@ -438,15 +438,15 @@ struct TryItScreen: View { private func combinationsCard(count: Int, isCompact: Bool) -> some View { let cardHeight: CGFloat = isCompact ? 90 : 110 - let valueSize: CGFloat = isCompact ? 40 : 44 + let valueFont: Font = .largeTitle return VStack(alignment: .leading, spacing: 4) { Text(String(localized: "tryit_combinations_unlocked")) - .font(.system(size: 12, weight: .semibold)) + .font(.caption.weight(.semibold)) .foregroundColor(WornColors.textSecondary) .tracking(0.5) Text("\(count)") - .font(.system(size: valueSize, weight: .bold)) + .font(valueFont.weight(.bold)) .foregroundColor(WornColors.accentGreen) .tracking(-1.2) } @@ -455,9 +455,9 @@ struct TryItScreen: View { .frame(maxWidth: .infinity, alignment: .leading) .frame(height: cardHeight) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.largeIncreased)) .overlay( - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: WornShape.largeIncreased) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .shadow(color: .black.opacity(0.04), radius: 4, y: 2) @@ -468,7 +468,7 @@ struct TryItScreen: View { if !gaps.isEmpty { VStack(alignment: .leading, spacing: 12) { Text(String(localized: "tryit_gaps_filled")) - .font(.system(size: 18, weight: .semibold)) + .font(.headline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .tracking(-0.2) @@ -478,7 +478,7 @@ struct TryItScreen: View { .fill(WornColors.accentGreen) .frame(width: 8, height: 8) Text(gap) - .font(.system(size: isCompact ? 14 : 15)) + .font(isCompact ? .subheadline : .callout) .foregroundColor(WornColors.textPrimary) } } @@ -500,7 +500,7 @@ struct TryItScreen: View { .font(.system(size: 22)) .foregroundColor(.white) Text(text) - .font(.system(size: 16, weight: .semibold)) + .font(.callout.weight(.semibold)) .foregroundColor(.white) } .frame(maxWidth: .infinity) @@ -508,7 +508,7 @@ struct TryItScreen: View { .background( LinearGradient(colors: gradientColors, startPoint: .top, endPoint: .bottom) ) - .clipShape(RoundedRectangle(cornerRadius: 28)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.extraLargeIncreased)) .shadow(color: WornColors.accentGreen.opacity(0.15), radius: 10, y: 6) } @@ -517,7 +517,7 @@ struct TryItScreen: View { text: text, action: action, gradientColors: WornGradients.indigo, - cornerRadius: 28, + cornerRadius: WornShape.extraLargeIncreased, shadowRadius: 10, shadowColor: WornColors.accentIndigo.opacity(0.15), shadowY: 6, @@ -542,7 +542,7 @@ struct TryItScreen: View { private func tryOnSection(isCompact: Bool) -> some View { VStack(alignment: .leading, spacing: 16) { Text(String(localized: "tryit_your_photo_title")) - .font(.system(size: 18, weight: .semibold)) + .font(.headline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .tracking(-0.2) @@ -554,7 +554,7 @@ struct TryItScreen: View { } Text(String(localized: "tryit_tryon_category_title")) - .font(.system(size: 18, weight: .semibold)) + .font(.headline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .tracking(-0.2) @@ -584,7 +584,7 @@ struct TryItScreen: View { } Text(String(localized: "tryit_tryon_cost_note")) - .font(.system(size: 12)) + .font(.caption) .foregroundColor(WornColors.textSecondary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -645,7 +645,7 @@ struct TryItScreen: View { .font(.system(size: 44)) .foregroundColor(WornColors.iconMuted) Text(String(localized: "tryit_your_photo_hint")) - .font(.system(size: 13, weight: .medium)) + .font(.footnote.weight(.medium)) .foregroundColor(WornColors.textSecondary) .multilineTextAlignment(.center) } @@ -654,9 +654,9 @@ struct TryItScreen: View { .frame(maxWidth: .infinity) .frame(height: height) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.largeIncreased)) .overlay( - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: WornShape.largeIncreased) .stroke(WornColors.borderStrong, lineWidth: 1.5) ) } @@ -672,7 +672,7 @@ struct TryItScreen: View { let selected = viewModel.state.selectedCategory == category Button { viewModel.selectCategory(category) } label: { Text(label) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .padding(.horizontal, 14) .padding(.vertical, 8) .background(selected ? WornColors.accentIndigo : WornColors.bgCard) @@ -692,7 +692,7 @@ struct TryItScreen: View { text: String(localized: "tryit_see_on_me"), action: { generateTryOn() }, gradientColors: WornGradients.indigo, - cornerRadius: 28, + cornerRadius: WornShape.extraLargeIncreased, shadowRadius: 10, shadowColor: WornColors.accentIndigo.opacity(0.15), shadowY: 6, @@ -700,6 +700,9 @@ struct TryItScreen: View { Image(systemName: "sparkles") .font(.system(size: 18)) .foregroundColor(.white) + // The sparkle is the app's "AI is here" mark; variableColor makes it read as + // alive rather than as a static decoration. + .symbolEffect(.variableColor.iterative.reversing) ), fixedHeight: nil, contentPadding: EdgeInsets(top: 14, leading: 0, bottom: 14, trailing: 0) @@ -711,7 +714,7 @@ struct TryItScreen: View { VStack(spacing: 12) { ProgressView().tint(WornColors.accentIndigo) Text(String(localized: "tryit_tryon_generating")) - .font(.system(size: 14)) + .font(.subheadline) .foregroundColor(WornColors.textSecondary) } .frame(maxWidth: .infinity) @@ -721,7 +724,7 @@ struct TryItScreen: View { private func tryOnResultView(imageData: Data, height: CGFloat) -> some View { VStack(alignment: .leading, spacing: 12) { Text(String(localized: "tryit_tryon_result_title")) - .font(.system(size: 18, weight: .semibold)) + .font(.headline.weight(.semibold)) .foregroundColor(WornColors.textPrimary) .tracking(-0.2) @@ -736,9 +739,9 @@ struct TryItScreen: View { .frame(maxWidth: .infinity) .frame(height: height) .background(WornColors.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipShape(RoundedRectangle(cornerRadius: WornShape.largeIncreased)) .overlay( - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: WornShape.largeIncreased) .stroke(WornColors.borderSubtle, lineWidth: 1) ) .accessibilityIdentifier("try_on_result") @@ -757,6 +760,11 @@ struct TryItScreen: View { TryItScreen(onTabSelected: { _ in }) } +#Preview("iPhone · Dark") { + TryItScreen(onTabSelected: { _ in }) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { TryItScreen(onTabSelected: { _ in }) } diff --git a/iosApp/iosApp/Screens/WardrobeScreen.swift b/iosApp/iosApp/Screens/WardrobeScreen.swift index 6277487..ffbed78 100644 --- a/iosApp/iosApp/Screens/WardrobeScreen.swift +++ b/iosApp/iosApp/Screens/WardrobeScreen.swift @@ -157,17 +157,17 @@ struct WardrobeContent: View { VStack(alignment: .leading, spacing: 8) { if state.totalItemCount == 0 { Text(String(localized: "wardrobe_title_empty")) - .font(.system(size: 22, weight: .semibold)) + .font(.title2.weight(.semibold)) .tracking(-0.5) .foregroundColor(WornColors.textPrimary) } else { Text(String(localized: "wardrobe_title")) - .font(.system(size: 28, weight: .semibold)) + .font(.title.weight(.semibold)) .tracking(-0.8) .foregroundColor(WornColors.textPrimary) Text(String(format: String(localized: "wardrobe_subtitle"), state.totalItemCount)) - .font(.system(size: 14, weight: .medium)) + .font(.subheadline.weight(.medium)) .foregroundColor(WornColors.textSecondary) } } @@ -204,6 +204,9 @@ struct WardrobeContent: View { } } .onLongPressGesture { + // Long-press is the only way into selection mode and has no visual + // affordance before it fires, so the impact is what confirms it. + UIImpactFeedbackGenerator(style: .medium).impactOccurred() onToggleSelection(item.id) } } @@ -222,7 +225,7 @@ struct WardrobeContent: View { .foregroundColor(WornColors.textSecondary.opacity(0.5)) Text(String(localized: "wardrobe_category_empty")) - .font(.system(size: 16, weight: .medium)) + .font(.callout.weight(.medium)) .foregroundColor(WornColors.textSecondary) Spacer() @@ -243,7 +246,7 @@ struct WardrobeContent: View { text: String(localized: "wardrobe_empty_cta"), action: onAddItemClick, gradientColors: WornGradients.greenCta, - cornerRadius: 28, + cornerRadius: WornShape.extraLargeIncreased, shadowRadius: 10, shadowColor: WornColors.accentIndigo.opacity(0.15), shadowY: 6, @@ -267,7 +270,7 @@ struct WardrobeContent: View { Image(systemName: "plus") .font(.system(size: 15, weight: .semibold)) Text(String(localized: "wardrobe_fab_add")) - .font(.system(size: 15, weight: .semibold)) + .font(.subheadline.weight(.semibold)) } .foregroundColor(WornColors.textOnColor) .padding(.horizontal, 20) @@ -296,6 +299,14 @@ private let previewItems: [ClothingItem] = [ ) } +#Preview("iPhone · Dark") { + WardrobeContent( + state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPhone - Selection") { WardrobeContent( state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(["1", "3"]), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), @@ -303,6 +314,14 @@ private let previewItems: [ClothingItem] = [ ) } +#Preview("iPhone - Selection · Dark") { + WardrobeContent( + state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(["1", "3"]), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPhone - Empty") { WardrobeContent( state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: 0), @@ -310,6 +329,14 @@ private let previewItems: [ClothingItem] = [ ) } +#Preview("iPhone - Empty · Dark") { + WardrobeContent( + state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: 0), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait", traits: .portrait) { WardrobeContent( state: WardrobeState(items: previewItems, isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: nil, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), @@ -331,6 +358,14 @@ private let previewItems: [ClothingItem] = [ ) } +#Preview("iPhone - Empty Category · Dark") { + WardrobeContent( + state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: .top, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), + isCompact: true + ) + .preferredColorScheme(.dark) +} + #Preview("iPad Portrait - Empty Category", traits: .portrait) { WardrobeContent( state: WardrobeState(items: [], isLoading: false, isSaving: false, isDeleting: false, selectedIds: Set(), activeCategory: .top, isAiAvailable: false, error: nil, totalItemCount: Int32(previewItems.count)), diff --git a/iosApp/iosApp/Theme/WornColors.swift b/iosApp/iosApp/Theme/WornColors.swift index 007ac66..2ea70af 100644 --- a/iosApp/iosApp/Theme/WornColors.swift +++ b/iosApp/iosApp/Theme/WornColors.swift @@ -1,40 +1,64 @@ import SwiftUI import Shared +/// Worn's palette, mirroring `WornColorScheme.kt` on Android value for value. +/// +/// Every token is built from a light/dark pair through `UIColor(dynamicProvider:)`, so SwiftUI +/// resolves it against the current trait collection and the whole app follows the system +/// appearance with no `@Environment(\.colorScheme)` checks at any call site. +/// +/// Light keeps the established warm beige + sage brand unchanged. Dark sits on a warm #211D18 +/// base rather than a neutral near-black — the latter reads cold against the sage and drops the +/// brand's warmth — with the ramp spaced widely enough that page, card and bar actually separate. enum WornColors { // Backgrounds - static let bgPage = Color(hex: "F5F0EB") - static let bgCard = Color.white - static let bgElevated = Color(hex: "EDE8E1") + static let bgPage = Color(light: "F5F0EB", dark: "211D18") + static let bgCard = Color(light: "FFFFFF", dark: "2B261F") + static let bgElevated = Color(light: "EDE8E1", dark: "363029") // Borders - static let borderSubtle = Color(hex: "E0D9D0") - static let borderStrong = Color(hex: "C8C0B5") + static let borderSubtle = Color(light: "E0D9D0", dark: "554E45") + static let borderStrong = Color(light: "C8C0B5", dark: "A0988B") // Accents - static let accentGreen = Color(hex: "7A9468") - static let accentGreenEnd = Color(hex: "6B8A58") - static let accentGreenDark = Color(hex: "5C6E50") - static let accentIndigo = Color(hex: "6B7B8E") - static let accentCoral = Color(hex: "A87560") - static let deleteRed = Color(hex: "C45B4A") + static let accentGreen = Color(light: "7A9468", dark: "A8C295") + static let accentIndigo = Color(light: "6B7B8E", dark: "A9BACE") + static let accentCoral = Color(light: "A87560", dark: "D8A88F") + static let deleteRed = Color(light: "C45B4A", dark: "F2B8AC") - // Gradients - static let saveGradientStart = Color(hex: "8FA47D") - static let saveGradientEnd = Color(hex: "6B7F5E") + /// Banner and gradient-end fills that always carry white text, so dark in *both* appearances. + static let accentGreenDark = Color(light: "5C6E50", dark: "4E6641") + + // Gradient stops. + // + // Deliberately not derived from `accentGreen` / `accentIndigo`: those invert between + // appearances (sage goes from #7A9468 to a much lighter #A8C295) and the gradient buttons + // always draw white label text, so deriving them would silently drop the label to about + // 1.8:1 in dark. These stay saturated enough for white either way. + static let saveGradientStart = Color(light: "8FA47D", dark: "6E8A5C") + static let saveGradientEnd = Color(light: "6B7F5E", dark: "546B45") + static let greenCtaStart = Color(light: "7A9468", dark: "6E8A5C") + static let greenCtaEnd = Color(light: "6B8A58", dark: "5C7A4B") + static let indigoGradientStart = Color(light: "6B7B8E", dark: "5E6E80") + static let indigoGradientEnd = Color(light: "556070", dark: "4A5462") // Text - static let textPrimary = Color(hex: "2C2924") - static let textSecondary = Color(hex: "7D776F") - static let textMuted = Color(hex: "B5AFA8") + static let textPrimary = Color(light: "2C2924", dark: "EFE8DD") + static let textSecondary = Color(light: "7D776F", dark: "D2C9BC") + static let textMuted = Color(light: "B5AFA8", dark: "8A8378") + /// Label colour on top of a filled accent — white in both appearances by design. static let textOnColor = Color.white // Icons - static let iconMuted = Color(hex: "A09A92") + static let iconMuted = Color(light: "A09A92", dark: "9B9488") - // Category dots - static let categoryDotTop = Color(hex: "444444") - static let categoryDotBottom = Color(hex: "2B4570") + // Category dots. + // + // These do double duty: small dots on the page surface, and 36pt tile fills behind *white* + // icons in Gaps. So they can only be lifted far enough to stay visible on #211D18, not so far + // that white stops reading on top. Only the two darkest needed moving for dark. + static let categoryDotTop = Color(light: "444444", dark: "6E6862") + static let categoryDotBottom = Color(light: "2B4570", dark: "42618F") static let categoryDotDress = Color(hex: "A87560") static let categoryDotOuterwear = Color(hex: "7A9468") static let categoryDotShoes = Color(hex: "8B6914") @@ -48,14 +72,14 @@ extension Outfit: @retroactive Identifiable {} extension Color { init(hex: String) { - let scanner = Scanner(string: hex) - var rgb: UInt64 = 0 - scanner.scanHexInt64(&rgb) - self.init( - red: Double((rgb >> 16) & 0xFF) / 255, - green: Double((rgb >> 8) & 0xFF) / 255, - blue: Double(rgb & 0xFF) / 255 - ) + self.init(uiColor: UIColor(hex: hex)) + } + + /// Builds a colour that resolves per appearance, so it tracks the system theme automatically. + init(light: String, dark: String) { + self.init(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light) + }) } var isBright: Bool { @@ -65,3 +89,17 @@ extension Color { return brightness > 0.5 } } + +extension UIColor { + convenience init(hex: String) { + let scanner = Scanner(string: hex) + var rgb: UInt64 = 0 + scanner.scanHexInt64(&rgb) + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } +} diff --git a/iosApp/iosApp/Theme/WornShapes.swift b/iosApp/iosApp/Theme/WornShapes.swift new file mode 100644 index 0000000..d0b31ef --- /dev/null +++ b/iosApp/iosApp/Theme/WornShapes.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Worn's corner-radius scale, mirroring `WornShapes.kt` step for step. +/// +/// The radii are the ones already scattered across the UI as `cornerRadius:` literals, collapsed +/// onto one named scale. The handful of near-duplicate one-offs (10/22/26/30) fold into the +/// nearest step, so the app stops shipping four radii that differ by 2pt and read as the same +/// curve. +enum WornShape { + static let extraSmall: CGFloat = 4 + static let small: CGFloat = 8 + /// Chips, small tiles, input fields. + static let medium: CGFloat = 12 + /// Cards and photo frames. + static let large: CGFloat = 16 + static let largeIncreased: CGFloat = 20 + /// Sheets and dialogs. + static let extraLarge: CGFloat = 24 + /// Pill buttons and the FAB. + static let extraLargeIncreased: CGFloat = 28 + /// The bottom bar. + static let extraExtraLarge: CGFloat = 36 +}