Skip to content

Replace the Home header with a floating search toolbar - #121

Merged
AndrewG828 merged 47 commits into
mainfrom
user/andrewgao/feature/UI-UX-Lucid-Refactor-11-home-search
Oct 8, 2026
Merged

AndrewG828 merged 47 commits into
mainfrom
user/andrewgao/feature/UI-UX-Lucid-Refactor-11-home-search

Conversation

@AndrewG828

@AndrewG828 AndrewG828 commented Sep 3, 2026 •

Copy link
Copy Markdown
Member

Stacked PR 11 of 15. Based on #120 — merge in order.
#111 → #112 → #113 → #114 → #115 → #116 → #117 → #118 → #119 → #120 → #121 → #122 → #123 → #124 → #125

Home's static header (wordmark, search icon, bell) becomes a glass toolbar that
floats over the feed: a search pill that expands into the full search card, a
notification button, and the section's filter button once that scrolls out of
view.

Search now happens in place instead of pushing SearchView. Results render into
the same scroll view as the feed, so there is no navigation transition between
typing and reading results.

The Shop By Category row moves to Explore, which is where browsing by category
now lives, and the expandable add button goes with the Sell tab. What is left
on Home is the recent-listings feed and its filter.

Two things to look at:

  • Scrolling is disabled only while the search card covers the feed with no
    results yet. Tying it to "is the card open" — the obvious version — also
    freezes the results the card produces, so you can't scroll past the first row.
  • Toolbar filter visibility is driven by KVO on the enclosing UIScrollView's
    content offset. SwiftUI's PreferenceKey and GeometryReader paths stall
    mid-scroll and leave the button stuck in whichever state it was in.
    onScrollGeometryChange would do the same job natively, but only from iOS 18
    and the deployment target is 17.5, so this is one mechanism instead of two.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W

AndrewG828 and others added 11 commits September 3, 2026 01:27
Replaces the raster category illustrations with single-scale SVG line icons
and reworks the category taxonomy in `Constants.filters`, which is the one
place categories are declared:

- "Sports & Outdoors" becomes "Active", with a matching `Active` imageset.
  Category artwork is looked up as `Image(filter.title)`, so the imageset had
  to be renamed in lockstep.
- "Household" and "Other" gain colors. A nil color is what hides a category
  from the Home row and the browse grid, so both are now browsable; Household
  needed a new imageset.
- `FilterView` derives its chips from `Constants.filters` instead of keeping a
  parallel hardcoded list that had already drifted (it was still offering
  "Sports & Outdoors" and omitting "Household").

The tinted circle behind each category icon is gone — the new line icons carry
their own shape — so `CircularFilterButton` renders the artwork directly at
72pt. Filter and condition chips now cross-fade on selection instead of
swapping in an "x" glyph that shifted the chip's width mid-tap.

Also drops `sfsfs.swift`, an empty file that was never in the build phase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
Groundwork shared by the Explore, Home and Product Details screens that follow.
Nothing calls these yet.

- `GlassToolbarModifier` (new): the Liquid Glass pill behind floating toolbar
  controls. It lives in Components rather than next to its first caller because
  Home, Explore and the category browser all need the same treatment. The
  near-transparent fill plus explicit `contentShape` are load-bearing — neither
  `glassEffect` nor `Material` claims the full pill for hit testing on its own,
  so without them taps near a control's edge fall through to the feed.

- `View.enableSwipeBack()`: screens that hide the navigation bar to draw their
  own chrome also lose the interactive pop gesture, because the system gesture
  is owned by the back button they just hid. This re-enables it and gates it on
  having something to pop back to.

- `UIImage.prefersLightToolbarIcons(displayedIn:)`: samples a 16x4 luminance
  grid from the strip of a photo that actually sits under the toolbar (after
  aspect-fill cropping and EXIF orientation flattening) so overlaid icons can
  pick black or white. It requires 78% of samples to be dark before going
  white, so mid-tone heroes stay on black instead of flickering between the two.

- `BackButton` takes an optional `tint` for the same reason: over a photo hero
  the chevron can't be unconditionally black.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
Feed cells were locked to a 4:3 box, so tall and square photos were cropped to
whatever fit. `CachedImageView` now reports the decoded image's aspect ratio
back to its caller, and `ProductGalleryCell` sizes the image from it — falling
back to 4:3 only while loading. Cells are wider (46pt of chrome instead of 68)
and the column gap tightened to match.

The cell also gains the information the old one dropped: category and condition
under the title, and a bookmark toggle that saves without opening the listing.

`loadSavedState()` only asks the server about a single post while
`HomeViewModel.savedItems` hasn't loaded yet. `savedItems` holds every saved
post, so once it has arrived, absence is a real answer — the naive version fired
one `/post/isSaved` request per visible cell on every scroll. That needed
`hasLoadedSavedItems` on `HomeViewModel` to distinguish "not saved" from
"not known yet".

`CategoriesView` is extracted as its own component: the Shop By Category row is
about to be used by Explore, and it is the same row Home already shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
…y Viewed

Three new endpoints and the two view models that drive the Explore tab. No UI
consumes them yet apart from view recording.

`NetworkManager` gains `getDailyPicks`, `getTrendingPosts(category:page:limit:)`
and `recordPostView`. The last one needed a `post(url:)` overload because it is
the first request in the app that sends no body and decodes no response — the
server upserts one view per viewer/post/UTC-day and ignores your own listings,
so the client can fire it unconditionally and ignore failures.

`ExploreViewModel` is a shared singleton with a 3-minute freshness window, since
Explore is a tab root that re-appears constantly and should not refetch on every
visit. Trending is keyed by category so switching categories bypasses the cache.

`RecentlyViewedViewModel` keeps its own ordered id list plus a listing cache in
`AppStorage`, so the section renders from disk on launch and only fetches the
posts it is missing, eight at a time. `ProductDetailsViewModel.setPost` is what
feeds it — opening a listing caches it locally and reports the view to the
server.

`Post` picks up the save-count fields. The count arrives under three different
key spellings depending on endpoint, and `getPostInfo` returns `savers` as a
user array rather than a number, so `displaySaveCount` takes the largest of
whatever decoded. Trending payloads often omit savers entirely, hence
`ensuringMinimumSaves` — if the viewer has the post saved locally, the true
count is at least one. That is a lower bound, not a global total; worth
confirming against the backend before we lean on it further.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
The three "see all" destinations behind the Explore rails, plus their routes.
They are routable and complete from this commit; nothing links to them until
Explore itself lands.

Each is a plain gallery over one slice of the discovery data layer. Trending is
keyed on `category.id` via `.task(id:)` so pushing a different category
refetches rather than showing the previous one's results, and Recently Viewed
loads its first page for an immediate render before filling in the tail.

Empty states are gated on the matching loading flag so none of them flash "no
results" during the first fetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
The horizontal rails that make up the Explore feed, and the collage card that
fronts Saved / Recently Viewed.

`ExploreCollageCard` shows a single image when a collection is thin and a
2x2 grid once it has at least four listings, so a one-item Saved list doesn't
render three empty tiles.

`ExploreTrendingSection` puts the category picker in the section header itself
— the header is the control — and shows a skeleton on first load only, so
switching categories doesn't tear the rail down and rebuild it.

Two things worth calling out for reviewers:

- A trending card pushed the category list instead of the listing it displays.
  Fixed; it now pushes `.productDetails` like the Daily Picks cards do.
- The upcoming-events section was a standalone shimmer with no data source and
  no caller. Left it out rather than shipping a placeholder that never resolves;
  it can come back with the events endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
Assembles the discovery sections into a tab root: category shortcuts, a Just
For You row fronting Saved and Recently Viewed, then Daily Picks and Trending.
It is not in the tab bar yet — that comes with the navigation rework.

Explore owns a local `SearchViewModel` rather than the shared singleton.
Both tab roots stay mounted under the new navigation, so sharing one would mean
typing in Explore silently replaced Home's results.

`SearchPanel` (new) is the expanded search card, extracted rather than written
inline: Home and the category browser present the identical card, and this is
the first of the three to land. Keeping it in one place also keeps the "opaque,
not glass" decision in one place — materializing a glass layer that large in
the same frame the keyboard animates made opening search feel like a hang.

Scrolling is disabled only while the card covers the feed with no results yet.
Gating it on "is the panel open" instead — which is what the first cut did —
also freezes the search results the panel hands back, leaving you unable to
scroll past the first four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
`NewListingViewModel` tracked a single `PhotosPickerItem`, so adding nine photos
meant nine trips through the picker. It now holds a selection array and loads
the batch in one pass, trimmed to whatever slots are left.

Two bugs fall out of the rewrite:

- The picker enforced a limit of 7 images while `PaginatedImageView` advertised
  9, so the last two picks were silently dropped. Both now read
  `NewListingViewModel.maxImages`, and the system picker is given
  `maxSelectionCount: remainingImageSlots` so it stops you at the limit instead
  of discarding the overflow afterwards.
- The old loader appended from a `DispatchQueue.main.async` block inside an
  already-`@MainActor` view model, which reordered appends against the
  `selectedItem = nil` reset.

`removeImage(at:)` is added here for the Sell form, which lets you delete a
photo from the carousel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
Replaces the three-step push flow (add button → images → details) with a single
form: photos, title and price, category, condition, description, publish.
Nothing routes to it yet — the tab bar rework wires it up.

The photo well is a carousel rather than a strip, so each image is reviewed at
the size it will be posted at, with delete and add controls on the image itself
and arrows only where there is somewhere to go.

Categories come from `Constants.filters` instead of a hand-written list. The
first cut hardcoded seven, which already disagreed with the taxonomy — it was
missing Household. Only the "Handmade" → "Homemade" display name is
special-cased here.

New listings default to "Gently Used" rather than "Never Used": the previous
default made the strongest claim on the seller's behalf, which is the wrong way
round for a form that people will submit without reading.

Left out of the port: a clothing-only "Additional Details" block whose call site
was commented out and whose fields did nothing.

Worth flagging for a follow-up — `NewListingViewModel.createNewListing()` calls
`clear()` on failure as well as success, so a network error silently empties the
form. That was survivable when the form was a pushed screen you were about to
leave; on a tab root that keeps you where you are, it reads as the app throwing
your listing away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
Home / Explore / Sell / Messages / Profile, five tabs where there were three,
each with an independent navigation history.

The single app-wide `NavigationStack` meant pushing a listing from Home and
then switching to Messages left the listing on screen — there was one path and
every tab shared it. `Router` now holds one path per tab and exposes the active
one through the same `path` property, so `push`/`pop`/`popToRoot` and all their
call sites are unchanged. `Router.Tab` names the slots, because the indices
appear in the tab bar, the stack container and two unrelated screens.

The container is hand-rolled rather than a `TabView`. The system bar kept
drawing underneath the custom one and showed through the glass as a second
outline. Every stack stays mounted and is toggled by opacity so tab state
survives switching; the inactive ones are hidden from accessibility and hit
testing.

Fixes that come with the tab-count change:

- `ProductDetailsView` sent you to `selection = 2` when you tapped your own
  name on a listing. Slot 2 is now Sell, not Profile; both call sites use
  `Router.Tab` instead of a literal.
- `showsTabBar` reacts to the route rather than to `MessagesView.onDisappear`,
  which fires after the pop animation and left the bar missing for a beat.
- Bottom content inset is always in the tree, just zero-height when the bar is
  hidden. Adding and removing the `safeAreaInset` broke scroll insets after a
  push and often failed to restore them on pop.
- The `.reviewTesting` destination pointed at a view deleted from main; the
  branch it came from still referenced it and did not compile.

The conversation toolbar picks up a Lucide calendar icon, which is what adds
`lucide-icons-swift` to the project (Profile and Availability use two more of
its glyphs in later commits). Four glyphs is a thin reason for a dependency —
worth revisiting if we don't reach for more of it. Its status dot is drawn only
when there actually is a confirmed meeting; unconditionally is how it started,
which made a badge that told you nothing and lost the hint explaining why the
button refuses to open the picker.

`TabViewIcon` is removed: it drew the old three-icon bar and has no other
caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
Home's static header (wordmark, search icon, bell) becomes a glass toolbar that
floats over the feed: a search pill that expands into the full search card, a
notification button, and the section's filter button once that scrolls out of
view.

Search now happens in place instead of pushing `SearchView`. Results render into
the same scroll view as the feed, so there is no navigation transition between
typing and reading results.

The Shop By Category row moves to Explore, which is where browsing by category
now lives, and the expandable add button goes with the Sell tab. What is left
on Home is the recent-listings feed and its filter.

Two things to look at:

- Scrolling is disabled only while the search card covers the feed with no
  results yet. Tying it to "is the card open" — the obvious version — also
  freezes the results the card produces, so you can't scroll past the first row.
- Toolbar filter visibility is driven by KVO on the enclosing `UIScrollView`'s
  content offset. SwiftUI's PreferenceKey and GeometryReader paths stall
  mid-scroll and leave the button stuck in whichever state it was in.
  `onScrollGeometryChange` would do the same job natively, but only from iOS 18
  and the deployment target is 17.5, so this is one mechanism instead of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W
AdelynnWu and others added 5 commits September 23, 2026 19:40
- Add Constants.productCategories as the one place that drops the
  "Recent" pseudo-category, and read FilterView's chips from it.
- Use the Constants.Fonts.title3 / Constants.Colors.black tokens for the
  chip labels instead of an inline Rubik-Medium font and Color.black.
- Drop a stray blank line in RangeSlider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…andrewgao/feature/UI-UX-Lucid-Refactor-02-chrome-primitives
CLAUDE.md asks for reusable modifiers to be applied through a View
extension (like .loadingView(isLoading:)) rather than .modifier(...) at
each call site, so add .glassToolbarBackground(cornerRadius:isOpaque:).
Also take the near-clear hit-testing fill from Constants.Colors.white.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AndrewG828 and others added 12 commits September 30, 2026 18:16
…destinations' into user/andrewgao/feature/UI-UX-Lucid-Refactor-06-explore-sections
…sections' into user/andrewgao/feature/UI-UX-Lucid-Refactor-07-explore-tab
The Daily Picks and Trending sections each had their own copy of the
listing card, the loading skeleton, the horizontal rail and the card
metrics. Pull those into private ExplorePostCard, ExploreRailSkeleton,
ExploreRail, ExploreSectionHeader and ExploreRailLayout so each section's
body is an outline of named pieces, per CLAUDE.md.

Also make the router private, add doc comments to the section views,
split Trending's menu label and empty state into named sub-views, give
ExploreCollageCard its Properties / UI sections, and build its 2x2 grid
with a loop instead of four copies of the cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add SearchResultsView (spinner / "No results" / grid) and SearchPill
  (the collapsed glass search control) to Views/Components. Home builds
  the same two pieces in the next PR, and CLAUDE.md wants shared UI in
  Components rather than inline in each feature view.
- ExploreView: load its content with .task { } instead of
  .onAppear { Task { } }, pull the search-expansion handler and the
  loader out of body into named functions, split the Just For You
  collages into named sub-views, keep all stored properties under
  "Properties", and make the router private.
- SearchPanel: add MARK sections and take the shadow/stroke colors
  from Constants.Colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tab' into user/andrewgao/feature/UI-UX-Lucid-Refactor-08-multi-image-picker
…age-picker' into user/andrewgao/feature/UI-UX-Lucid-Refactor-09-sell-tab
- Move maxImages and remainingImageSlots under Properties (they had
  landed in the middle of the @published list and under Functions) and
  document them.
- Document updateListingImages(newItems:), and log photos that fail to
  load instead of dropping them silently with try?.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' into user/andrewgao/feature/UI-UX-Lucid-Refactor-10-tab-navigation
- Add ImageCountBadge to Views/Components for the "N / M" capsule over a
  photo carousel. The Sell form, the listing hero and the full-screen
  photo viewer each built their own copy; the next PRs switch the other
  two over.
- Split the carousel's trash / add controls into named sub-views and
  rename actionButtons to publishButton, which is what it is.
- Read categories from Constants.productCategories.
- Drop dead code in listingField: the prefix and width parameters were
  never passed, and Field.price was never focused (price opens a sheet).
- Take the chevron shadow color from Constants.Colors, and add a doc
  comment to the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gation' into user/andrewgao/feature/UI-UX-Lucid-Refactor-11-home-search
- Router.activeTab / pathBinding(for:) now take Router.Tab instead of a
  raw Int, and per-tab paths live in a [Tab: [Route]] dictionary, which
  removes the index bounds checks. MainTabView converts its Int
  selection binding once through a selectedTab property.
- MainTabView: move the tab button out of the ForEach into
  tabBarButton(for:) / unreadBadge(count:isTabSelected:), reuse
  isMessagesRouteActive in the path onChange instead of re-deriving it,
  and document the two private modifiers.
- Add Constants.Fonts.tabBarLabel and .badge for the tab label (Rubik
  13) and unread badge (Roboto 10) instead of inline Font.custom, and
  take the tab bar's colors from Constants.Colors.
- Fix hasProfilePicture: `user.photoUrl != nil` is always true (the URL
  isn't optional, and the compiler warns about it), so the tab showed
  the placeholder image whenever the photo failed to load. Set it once
  the photo actually decodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Use SearchPill and SearchResultsView (added with Explore) instead of
  a second inline copy of each, and .glassToolbarBackground() instead of
  .modifier(GlassToolbarModifier()).
- Load saved posts with .task { } rather than a Task inside onAppear,
  and have pull-to-refresh await the filtered refresh directly instead
  of wrapping it in an unawaited Task with try?; failures are logged.
- Move the search-expansion handler out of body, extract the inline
  filter button, and add the Properties / UI / Private Methods sections.
- HomeScrollOffsetReader: replace DispatchQueue.main.async with
  main-actor Tasks (CLAUDE.md: no raw DispatchQueue in feature code) and
  mark the coordinator @mainactor.
- Use Constants.Spacing.horizontalPadding for the header inset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7ba0b6f8-b4aa-4a9a-8b12-32a097bd8a07
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AndrewG828
AndrewG828 added this pull request to stack #127 October 8, 2026 22:04
AndrewG828 and others added 11 commits October 8, 2026 18:08
…rimitives' into user/andrewgao/feature/UI-UX-Lucid-Refactor-03-listing-cards

# Conflicts:
#	Resell.xcodeproj/project.pbxproj
Addresses review on #113: the cell still decided its own saved state and
handled save failures, and CachedImageView carried an unneeded comment.

- Add HomeViewModel.knownSavedState(for:), the "savedItems answers once
  it has loaded" rule, and have isPostSaved(_:) and the cell both use it.
  The cell now re-syncs only when that answer changes, not on every
  savedItems update.
- setSaved(_:for:) now logs failures itself and returns whether the
  change went through. The cell keeps only its optimistic flip and the
  rollback, since the bookmark state is per card.
- hasLoadedSavedItems is private now that only the view model reads it.
- Drop the doc comment on CachedImageView.aspectRatio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cards' into user/andrewgao/feature/UI-UX-Lucid-Refactor-04-discovery-data-layer
…y-data-layer' into user/andrewgao/feature/UI-UX-Lucid-Refactor-05-explore-destinations
…destinations' into user/andrewgao/feature/UI-UX-Lucid-Refactor-06-explore-sections
…sections' into user/andrewgao/feature/UI-UX-Lucid-Refactor-07-explore-tab
…tab' into user/andrewgao/feature/UI-UX-Lucid-Refactor-08-multi-image-picker
…age-picker' into user/andrewgao/feature/UI-UX-Lucid-Refactor-09-sell-tab
…' into user/andrewgao/feature/UI-UX-Lucid-Refactor-10-tab-navigation
…gation' into user/andrewgao/feature/UI-UX-Lucid-Refactor-11-home-search
Base automatically changed from user/andrewgao/feature/UI-UX-Lucid-Refactor-10-tab-navigation to main October 8, 2026 22:37
@AndrewG828
AndrewG828 merged commit bdeb3a4 into main Oct 8, 2026
@AndrewG828
AndrewG828 deleted the user/andrewgao/feature/UI-UX-Lucid-Refactor-11-home-search branch October 8, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants