Bring both platforms onto a shared design system, then adopt native chrome and motion on Android - #63
Merged
Merged
Conversation
The theme filled in only 10 of ~30 M3 colour roles, so anything resolving error, tertiary, scrim, inverseSurface or surfaceContainer* fell back to baseline Material purple. There was also no dark scheme at all. Replace the flat `object WornColors` façade with complete light and dark ColorSchemes and drive all ~290 call sites off MaterialTheme directly, so the M3 role in play is visible at the point of use instead of hidden behind an alias. The handful of brand tokens M3 has no role for (gradient stops, muted text/icon greys, category dots) hang off MaterialTheme as `wornExtras`. Light keeps every existing hue unchanged. Dark uses a warm #211D18 base rather than a neutral near-black, which would read cold against the sage, with the ramp spaced widely enough that page, card and bar actually separate. Also corrects outline/outlineVariant, which were assigned the wrong way round — M3 defines outline as the stronger of the pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors WornColorScheme.kt token for token. Each colour is now built from a light/dark pair via UIColor(dynamicProvider:), so SwiftUI resolves it against the trait collection and every existing WornColors call site follows the system appearance with no colorScheme checks anywhere. Gradient stops move onto dedicated tokens rather than the accent roles, which invert between appearances while these buttons always draw white labels. Info.plist deliberately still does not pin UIUserInterfaceStyle: the app follows the system, which also stops the photo picker and share sheet from rendering dark against a permanently light app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cards were mapped to surfaceContainerLowest, which is the darkest step of the M3 ramp, so in dark they sank behind the page and read as black holes. The app's design is white cards standing forward on a beige page; dark has to preserve that relationship rather than invert it. surfaceContainer is the role that means "elevated container" and sits above surface in dark, so cards move there — #2B261F against a #211D18 page. Light is unchanged: surfaceContainer is #FFFFFF, exactly what the cards already were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Android had 145 hardcoded `fontSize =` sites and zero uses of MaterialTheme.typography; iOS had 167 `.font(.system(size:))` and zero semantic text styles. The iOS side is the serious one: fixed point sizes do not respond to the system text-size setting, so Dynamic Type was entirely non-functional. Android gains a Typography built from the sizes already in use, so the scale names what was there rather than redesigning it. iOS maps onto the built-in text styles, which scale automatically — the existing point sizes already sat within a point or two of them, so the visual change is small and the accessibility gain is not. Where a size was threaded through a composable as a raw TextUnit/CGFloat (PropertyRow, ItemDetailSheet, OutfitDetailSheet, TryItTitle) the parameter now carries a TextStyle/Font, so responsive variants pick from the scale instead of from loose literals. Two follow-on details: - The tab bar is capped at accessibility1. It is a fixed-height row of five equal slots and cannot grow the way flowing text can; the rest of the app is uncapped. - SF Symbol sizes are deliberately left as fixed glyph sizes. Previews move to PhonePreview/TabletPreview multipreview annotations, which carry a dark variant each, so dark mode is now visible in the IDE — that is where a missed colour role shows up. detekt.yml lists the aliases so the TooManyFunctions exemption keeps applying. Note: explicit line heights slightly tighten vertical rhythm versus the previous font-metric defaults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Radii were 12 different literals spread across ~60 call sites, four of which (10/22/26/30dp) differ from a neighbour by 2dp and read as the same curve. Collapse them onto the eight M3 shape slots, wired into the theme so Material components pick the right corner on their own, and mirror the same scale on iOS as WornShape. Bottom sheets get a named `MaterialTheme.sheetShape` since M3 has no slot for a partly-rounded shape and ten copies of the literal had accumulated. The largeIncreased/extraLargeIncreased/extraExtraLarge slots are Expressive APIs, so the files referencing them carry the opt-in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
themes.xml pinned android:statusBarColor and navigationBarColor to the splash green while MainActivity called enableEdgeToEdge(), so content ran under an opaque green status bar instead of behind transparent system bars. It also parented off the legacy android:Theme.Material.Light.NoActionBar, which has no dark variant at all. Reparent to Theme.Material3.DayNight.NoActionBar, drop the pinned bar colours so enableEdgeToEdge owns them, and pass explicit SystemBarStyle.auto so the system picks bar icon colours from uiMode — the default overload assumes a light scrim and left dark glyphs on the dark page. windowBackground moves to a day/night colour matching each scheme's surface, so the pre-Compose window no longer flashes light before the first frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…BarItem The bar was a Box overlay, so every screen compensated by hand with WornDimens.BottomBarClearance = 95.dp threaded into its FAB padding and list contentPadding. Settings still clipped its last card behind the bar, because one of those hand-tuned offsets was simply missing. Move it into Scaffold(bottomBar =) so the pager gets a bottom inset that already accounts for the bar, and delete the constant along with all six call sites. The clipping is fixed by correct insets rather than a seventh tuned number. Tabs move from Surface + clickable(indication = null) to NavigationBarItem, which restores the ripple, animates the indicator between tabs instead of snapping, and reports proper selectable semantics to TalkBack. The floating pill container is unchanged, so the design is the same. The old comment blamed the ripple for a ~1s repaint; that was the pager recomposing the destination page, and suppressing touch feedback only hid it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes, all found once the build actually produced a fresh APK. themes.xml parented off Theme.Material3.DayNight.NoActionBar, which does not exist here: the project uses Compose Multiplatform Material3 and has no dependency on com.google.android.material, so resource linking failed and every build since has silently reused a stale APK. Day/night now comes from a values-night qualifier over the platform Material themes instead. The bottom bar's move to NavigationBarItem was wrong for this design. Its indicator only wraps the icon, so the selected label sat outside the pill in onPrimary — dark green on a dark bar, effectively unreadable. Keep the hand-built pill, which wraps icon and label together, and fix the two things that actually needed fixing: selectable() for a bounded ripple and proper selected/Tab semantics, and animateColorAsState so the fill eases between tabs instead of snapping. Clipping ClothingCard's Column to a rounded shape cut its own bottom-left corner, which is where the category dot sits — it rendered as a teardrop. combinedClickable already bounds its ripple to the item, so the clip is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Titles were plain Text in a Column, so they scrolled away like body content and there was no collapse behaviour, no scroll signal and no consistent placement between screens. Wardrobe, Outfits, Gaps and Settings now use LargeFlexibleTopAppBar with exitUntilCollapsedScrollBehavior. The title/subtitle pair the screens already had maps directly onto the bar's own title and subtitle slots, and Outfits' Create button becomes a real app-bar action. All heading strings are unchanged — bottom-navigation, create-first-outfit and gaps-common-suggestions all assert on the visible text. Two shared details live in WornTopAppBar.kt: the bar takes `surface` for both its container and scrolled container, so it reads as part of the page rather than a separate slab and never picks up an elevation tint the palette does not contain; and the title carries an 8dp nudge, because M3 indents it 16dp while the screens' content gutter is 24dp. Selection mode keeps its own header, now hosted in the topBar slot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app had zero animate*AsState, zero animateContentSize and zero haptics on Android, and zero sensoryFeedback and zero symbolEffect on iOS. Nothing moved and nothing was felt. Android switches to MaterialExpressiveTheme with MotionScheme.expressive(), which gives every Material component spring-based motion rather than flat easing. The theme is otherwise unchanged. - Category chips and bottom-bar tabs ease their fill with animateColorAsState. Filtering swaps the whole grid underneath, so easing the control the user actually touched gives the eye something continuous to hold. - Screen-level spinners become the expressive LoadingIndicator. - Haptics where an action has no visual affordance until it fires: LongPress on long-press-to-select, SegmentTick on tab and chip changes. iOS mirrors the same set: .sensoryFeedback(.selection) on tab and chip changes, an impact generator on long-press-to-select, and .symbolEffect .variableColor on the AI sparkle so it reads as alive rather than decorative. Note: the iOS changes are unverified — this machine has no Swift toolchain. See #62. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The App-level Scaffold exists to host the bottom bar, but the pager consumed its innerPadding wholesale — including the status-bar top inset. Each screen's own TopAppBar then applied that inset again, so every title sat under roughly 90dp of empty space. No app-bar height parameter could explain it, because the duplication happened outside the bar entirely. The pager now takes only the bottom padding and lets each screen's app bar own its top inset. With the double inset gone the bars are re-sized to suit these screens: the four copies of LargeFlexibleTopAppBar collapse into one WornTopAppBar built on the medium bar, with heights below the M3 defaults. The large bar reserves 152dp expanded, of which the top 64dp is the row that would hold a navigation icon — and these are tab destinations with nowhere to navigate back to. Outfits' Create moves from a top-bar action to an ExtendedFloatingActionButton. As an action it sat alone in that otherwise empty leading row, reading as a button floating above the title; as a FAB it matches Wardrobe's "Add item", so both list screens now put their primary action in the same place. outfits_create_button is preserved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d Settings Moving these titles into the app bar took their trailing spacing with them: the Gaps header item carried a 20dp Spacer before the Common Suggestions banner, and Settings had 28dp between its title and the first section label. Both ended up flush against the bar. Gaps gets the gap back as LazyColumn contentPadding rather than a Spacer item, so it scrolls away with the content instead of occupying a list slot. 20dp matches the .padding(.bottom, 20) the iOS Gaps header already uses, so the two platforms line up again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Category.SHOES used Lucide's "footprints", which reads as walking or tracking rather than as an item of clothing — the odd one out next to a shirt, a wind glyph and a pair of glasses. Its six thin disconnected strokes also dissolved at the 18dp the Gaps category tiles render at. ic_sneaker is a single closed side-profile silhouette plus a sole seam, which survives that size, in the same 2dp round-cap stroke style as the other category icons. This also closes a parity gap: iOS already used the SF Symbol "shoe", so the two platforms showed different things for the same category. Verified in both places the icon renders — the Gaps category tiles and the Add Item category picker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings both platforms onto a shared design system, then continues on Android with native chrome and a motion layer.
Three systemic gaps prompted this. There was no design system:
WornThemeset only 10 of ~30 M3 colour roles, so anything resolvingerror,tertiary,scrimorsurfaceContainer*fell back to baseline Material purple, and there was no typography or shape scale — 145 hardcodedfontSizeon Android, 167.font(.system(size:))on iOS. Neither platform supported dark mode at all, and because iOS never pinnedUIUserInterfaceStyle, the system photo picker and share sheet already rendered dark against a permanently light app. On iOS the fixed point sizes also meant Dynamic Type was entirely non-functional.Second, platform chrome was reimplemented rather than used: zero
TopAppBar, zeroNavigationBar, a bottom bar overlaid as aBoxwhose overlap was patched by aBottomBarClearance = 95.dpconstant threaded into every screen — and Settings still clipped its last card, because one of those hand-tuned offsets was simply missing.Third, nothing moved: zero
animate*AsState, zero haptics on Android; zerosensoryFeedback, zerosymbolEffecton iOS.The toolchain already shipped everything needed and none of it was used — the resolved
material3 1.11.0-alpha07containsMaterialExpressiveTheme,MotionScheme,MaterialShapesandLoadingIndicator.The warm sage/beige brand is kept exactly; light mode is visually near-identical.
Changes
Design system — both platforms
ColorSchemes with every M3 role filled in. Dark uses a warm#211D18base rather than a neutral near-black, which reads cold against the sage. iOS mirrors it value-for-value viaUIColor(dynamicProvider:), so all ~290 Swift call sites follow the system appearance with nocolorSchemechecks.outline/outlineVariant, which were assigned the wrong way round.accessibility1since it is a fixed-height row of five slots.WornShapeon iOS.PhonePreview/TabletPreviewmultipreview annotations, each carrying a dark variant — dark is where a missed colour role shows up.Android chrome
Scaffold(bottomBar =);BottomBarClearanceand all six call sites deleted. The Settings clipping is fixed by correct insets rather than a seventh tuned number.themes.xmlstops fightingenableEdgeToEdge()— the pinnedstatusBarColor/navigationBarColorare gone andSystemBarStyle.autoflips bar icons with the theme.WornTopAppBar(medium, collapsing) across four screens, sized below the M3 defaults: the large bar reserves 152dp expanded, 64dp of which is the row that would hold a back arrow, and these are tab destinations.error. Delete was a solid red block ranked below a plain white Edit, and its hardcoded white-on-red is unreadable in dark.Motion
MaterialExpressiveThemewithMotionScheme.expressive(); animated chip and tab fills; expressiveLoadingIndicator; haptics where an action has no visual affordance until it fires..sensoryFeedback(.selection), impact on long-press-to-select,.symbolEffect(.variableColor)on the AI sparkle.Icon
Category.SHOESmoves from Lucide's "footprints" — walking trails rather than an item of clothing, and six thin strokes that dissolved at 18dp — toic_sneaker, a single closed silhouette in the same stroke style as the other category icons. This also closes a parity gap: iOS already used the SF Symbol"shoe", so the platforms had been showing different things for the same category.Test plan
./gradlew detekt— clean./gradlew :shared:allTests— greenjourneys/bottom-navigation.xmlwalked manually end to end — all five headings passandroid layoutconfirms the journey identifiers survive:tab_*,clothing_card,wardrobe_add_fab,outfits_create_button,gaps_bannerNot verified: iOS. This work was done on a Linux host with no
swift,swiftcorxcodebuild, so no line of Swift here was compiled. The iOS changes are deliberately limited to mechanical mirrors of verified Android changes. Please build in Xcode before merging.Known gaps
List/Section,TabView+ Liquid Glass,ContentUnavailableView,.contextMenu/.swipeActions) is deferred to iOS: adopt native chrome and motion to match the Android design-system work #62.ItemDetailSheetout ofModalBottomSheet, since shared-element geometry does not resolve across Android's separate sheet window.lineHeightslightly tightens vertical rhythm versus the previous font-metric defaults.Checklist
./gradlew detektpasses🤖 Generated with Claude Code