Compare commits

..

53 Commits

Author SHA1 Message Date
Yusuf Suleman
715175786b feat: polished trip timeline — Apple-level detail view
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
Continuous timeline rail:
- Vertical line runs full length connecting all days
- Colored icon nodes on the rail (tinted circle, not solid)
- Line connects across day boundaries (not just within days)

Day headers:
- Numbered badge on the timeline (Day 1, Day 2...)
- Date as "Tuesday, Jan 20" with expanded font
- Staggered entrance animation per day

Type-specific cards:
- ✈️ Flights: glass material card, route bar (FROM → TO),
  flight number capsule badge, transport type icon
- 🏨 Hotels: warm tinted card, night count badge capsule,
  location label, check-out info
- 📍 Places: compact card, category capsule badge (red),
  time in secondary text

Visual hierarchy:
- Flights/hotels: .regularMaterial background (glass, prominent)
- Places: surfaceCard background (compact, secondary)
- Staggered entrance: cards fade + slide up with delay

Polish:
- Consistent 14pt padding on major cards
- 10pt padding on compact place cards
- Icon colors: blue (transport), warm (lodging), red (places)
- Capsule badges for metadata (nights, category, flight #)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:23:22 -05:00
Yusuf Suleman
58dd589d5a fix: Reader loadMore failing — duplicate IDs + offset drift
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
ROOT CAUSE (from instrumented logs):
1. Duplicate entry IDs in LazyVStack — loadMore appended entries
   that already existed. IDs 37603/37613 appeared twice, causing
   "undefined results" from SwiftUI.
2. Offset drift — marking entries as read shifted the unread filter
   results. offset=30 no longer pointed to page 2; entries moved.
3. loadMore returned +0 entries because offset was past the shifted
   end of the filtered dataset.

FIXES:
1. Deduplication: loadMore filters out entries whose IDs already
   exist in the array before appending.
2. Use entries.count as offset instead of a separate counter.
   This naturally accounts for deduplication and status changes.
3. hasMore based on whether new (deduped) entries were actually
   added, not just API page size.

Removed all debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:20:49 -05:00
Yusuf Suleman
4461689251 debug: add article open + webView load timing to Reader logging
All checks were successful
Security Checks / dependency-audit (push) Successful in 12s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
2026-04-04 23:07:55 -05:00
Yusuf Suleman
dbddf126f9 debug: Reader comprehensive logging — jitter, markRead, loadMore
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
2026-04-04 23:06:21 -05:00
Yusuf Suleman
8a04c18627 fix: Madiha's Trips stuck loading — tripsVM.loadTrips() was behind showReader guard
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
The .task block had guard showReader (Madiha=false) before
tripsVM.loadTrips(). Trips pre-load never ran for Madiha.
Moved tripsVM.loadTrips() before the guard so it runs for all users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:50:40 -05:00
Yusuf Suleman
e28c4d0003 fix: camera crash — add NSCameraUsageDescription + availability check
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Crash: iOS terminates the app when accessing camera without the
privacy description in Info.plist.

Fix:
- Added NSCameraUsageDescription to Info.plist
- "Take Photo" option only shows if camera is available
  (UIImagePickerController.isSourceTypeAvailable(.camera))

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:40:43 -05:00
Yusuf Suleman
6a694fcbcf fix: #31 — changing quantity auto-recalculates macros in draft card
All checks were successful
Security Checks / dependency-audit (push) Successful in 12s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Captures base per-unit values (calories/protein/carbs/fat/sugar/fiber
divided by original quantity) when draft first appears. When quantity
changes, recalculateMacros() multiplies base values by new quantity.

Example: AI drafts "1 medium banana" at 105 cal. User changes
quantity to 2.0 → calories becomes 210, protein doubles, etc.

Values round to integers for calories, 1 decimal for macros.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:33:21 -05:00
Yusuf Suleman
b5d734efe1 fix: #29 — edit food name, macros, unit in food library
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Long-press a food → "Edit" opens EditFoodSheet with:
- Name and brand fields
- All macros: calories, protein, carbs, fat, sugar, fiber
- Base unit field
- Save calls PATCH /api/fitness/foods/{id}
- List refreshes after save

Also added updateFood() to FitnessAPI and UpdateFoodRequest model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:31:46 -05:00
Yusuf Suleman
55ef010370 fix: Gitea issues #27, #28, #30
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
#28 — Return key now inserts new line in AI chat:
TextField replaced with TextField(axis: .vertical) + lineLimit(1...5).
Return = new line, send button submits. Removed .onSubmit.

#27 — Delete foods in food library:
Long-press context menu with "Delete" option on each food item.
Calls DELETE /api/fitness/foods/{id} and reloads.

#30 — Camera + photo library options:
Replaced PhotosPicker with a Menu offering "Take Photo" (camera)
and "Photo Library" (picker). CameraView wraps UIImagePickerController.
handleCameraImage() in ViewModel resizes and sends same as photo picker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:21:13 -05:00
Yusuf Suleman
fa3932c597 feat: Trip Detail as chronological timeline (matches web dashboard)
All checks were successful
Security Checks / dependency-audit (push) Successful in 34s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s
Replaced type-grouped sections with a unified timeline:
- All items (flights, lodging, places) sorted by date + time
- Grouped by day with "Tuesday, Jan 20" headers
- Vertical timeline line connecting items within each day
- Color-coded icons: blue (flights), warm (lodging), red (places)
- Time shown in 12h format (8:50 AM)
- Category badges for places
- Subtitle with route/location details

Same layout order as the web dashboard — not grouped by type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:31:07 -05:00
Yusuf Suleman
a739e0de80 feat: Trip Detail screen — real data, Wishlist-quality polish
All checks were successful
Security Checks / dependency-audit (push) Successful in 25s
Security Checks / secret-scanning (push) Successful in 7s
Security Checks / dockerfile-lint (push) Successful in 6s
Adapted from Apple's Wishlist TripDetailView:
- 510pt hero image with .scrollEdgeEffectStyle(.soft)
- Trip stats overlay (glass material + MeshGradient, same pattern)
- Title in .largeTitle toolbar (expanded font)
- .toolbarRole(.editor) for clean back navigation

Content sections with real API data:
- Lodging: hotel name, location, check-in/out dates
- Flights & Transport: route, type icon, flight number
- Places: name, category, visit date
- AI Recommendations: first 500 chars of suggestions
- Empty state when no details added

Models: TripDetail, TripTransportation, TripLodging, TripLocation,
TripNote — matching the /api/trip/{id} response shape

GradientOverlay: MeshGradient mask from Wishlist's GradientView

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:35:25 -05:00
Yusuf Suleman
d680af0547 cleanup: remove trips debug logging — zoom transition confirmed stable
All checks were successful
Security Checks / dependency-audit (push) Successful in 27s
Security Checks / secret-scanning (push) Successful in 8s
Security Checks / dockerfile-lint (push) Successful in 7s
2026-04-04 18:21:01 -05:00
Yusuf Suleman
e4a079bf11 debug: trips navigation logging round 2
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
2026-04-04 15:28:32 -05:00
Yusuf Suleman
583138fbe2 fix: stabilize view tree for zoom transition recovery
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
ROOT CAUSE: @State TripsViewModel inside TripsHomeView was recreated
during navigation transitions. Each recreation set isLoading=true,
flipping the Group conditional, destroying the ScrollView + namespace.
The zoom transition's matchedTransitionSource evaporated.

FIXES (matching Apple's Wishlist pattern):
1. ViewModel owned by MainTabView (like ReaderVM) — survives
   view recreation. Pre-loaded on app launch.
2. Loading state rendered INSIDE ScrollView — no conditional
   Group wrapper that swaps the entire view tree.
3. Single @Namespace in TripsHomeView, passed to both
   UpcomingTripsPageView and PastTripsSection.
4. Zoom transition restored on all NavigationLinks.

Why Apple's sample works: they use @Environment(DataSource.self)
which is app-level stable. Our equivalent: @State at MainTabView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:43:46 -05:00
Yusuf Suleman
864ca679ce fix: remove .zoom transition causing trip card disappearance on back
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
ROOT CAUSE: .navigationTransition(.zoom) + .matchedTransitionSource
in a paged TabView. When navigating back, the zoom tries to animate
to the source card, but the TabView may have recycled/repositioned
the page. The animation targets a stale frame, causing the card to
flash black or disappear.

FIX: Removed .zoom transitions and .matchedTransitionSource from
both UpcomingTripsPageView and PastTripsSection. Standard push/pop
navigation is stable with paged TabViews.

Also removed all debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:37:37 -05:00
Yusuf Suleman
48b6522cf5 debug: trips navigation lifecycle logging
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
2026-04-04 14:33:10 -05:00
4621d4f606 fix: resolve duplicate B10060 ID — entitlements vs TripModels collision
All checks were successful
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / dockerfile-lint (push) Successful in 3s
2026-04-04 14:26:16 -05:00
Yusuf Suleman
97f4ac5150 force: ensure pbxproj with Trips entries is latest
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
2026-04-04 14:21:50 -05:00
Yusuf Suleman
4d6960c508 feat: Trips home screen — inspired by Apple's Wishlist sample
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 3s
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Mapped Wishlist → Trips:
- WishlistView → TripsHomeView (NavigationStack + ScrollView)
- RecentTripsPageView → UpcomingTripsPageView (paged TabView hero)
- TripCollectionView → PastTripsSection (horizontal scroll compact)
- TripCard → TripCard (.compact/.expanded sizes)
- TripImageView → TripImageView (Rectangle overlay + AsyncImage)
- ExpandedNavigationTitle → same pattern for "Trips" title
- AddTripView → Plan Trip button (.glassProminent, Phase 2)

Structure (9 files):
- TripModels.swift: Trip model with date helpers, image URL builder
- TripsAPI.swift: getTrips() via gateway /api/trips/trips
- TripsViewModel.swift: upcoming/past sorting, @Observable
- TripsHomeView.swift: main screen, Wishlist layout pattern
- UpcomingTripsPageView.swift: full-width paged hero cards
- PastTripsSection.swift: horizontal compact card scroll
- TripCard.swift: reusable card with compact/expanded sizes
- TripImageView.swift: AsyncImage with gradient placeholder
- TripPlaceholderView.swift: simple detail for Phase 1

New tab: Trips (airplane icon) in tab bar for all users.
Images served via gateway: /api/trips/images/{filename}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:16:15 -05:00
Yusuf Suleman
1cfb729cae fix: auto-scroll loads more when near bottom
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
onAppear doesn't fire during programmatic scrolling (UIScrollView
contentOffset changes don't trigger SwiftUI lifecycle). Added
onNearBottom callback to ScrollViewDriver — fires when within 500pt
of bottom during auto-scroll tick. 3s cooldown prevents rapid-fire.

Auto-scroll no longer stops at bottom — idles at maxOffset while
loadMore fetches. When new entries arrive, contentSize grows and
scrolling resumes automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:39:55 -05:00
Yusuf Suleman
ae3b3f11bf fix: card thumbnail overflow — use GeometryReader to constrain image
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Images with .fill were expanding beyond the 180pt frame because
SwiftUI's .clipped() doesn't constrain the layout, only the
rendering. Now uses GeometryReader to set explicit width + height
on the image, then clips the container. Guarantees 180pt max
regardless of image aspect ratio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:15:19 -05:00
Yusuf Suleman
92a44faac3 fix: three Reader bugs — image overflow, load more, refresh read state
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
#22 Image overflow: added .frame(maxWidth: .infinity) before
.frame(height: 180) on AsyncImage to constrain width within card.
Card's .clipShape already clips corners.

#23 Load more not triggering: added loadMoreIfNeeded(for:) that
fires onAppear for entries 5 from the bottom. No longer relies
solely on the bottom sentinel Color.clear which could be missed.
Also increased sentinel height from 1pt to 40pt.

#24 Refresh not updating read state: flushDeferredReads() now
called before vm.refresh() in .refreshable. Deferred marks are
synced to API before re-fetching, so the server returns correct
read states. Also clears markedByScroll set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:08:27 -05:00
Yusuf Suleman
d8f0e5d845 feat: animated macro rings, bars, and staggered meal card entrance
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
MacroRing:
- Animates from 0 to target on appear (spring, 1.0s response)
- Center value fades in + scales up with 0.4s delay
- Updates animate smoothly on data change
- .contentTransition(.numericText()) on calorie count

MacroBar:
- Bar width animates from 0 to target on appear (spring, 0.3s delay)
- Updates animate smoothly on data change
- .contentTransition(.numericText()) on values

TodayView:
- Meal sections stagger in: fade up with 0.08s delay per card
- Re-animates on tab switch (onAppear resets animated flag)
- Re-animates on date change
- Spring physics for natural feel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:20:21 -05:00
Yusuf Suleman
bf2ff59ade feat: auto-focus search field when Quick Add opens in sheet
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Keyboard appears automatically when opening Quick Add from the
food assistant sheet. 300ms delay lets the sheet animation finish
first so the keyboard doesn't interfere with the presentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:02:39 -05:00
Yusuf Suleman
0a10d297cd fix: keep pill selector + swipeable pages (Quick Add first)
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Pill tabs (Quick Add / AI Chat) stay visible and tappable.
Pages are also swipeable. Pills sync with swipe position.
Quick Add shown first, swipe left for AI Chat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:53:32 -05:00
Yusuf Suleman
66ab375ee0 feat: swipeable food sheet — Quick Add first, swipe for AI Chat
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Replaced pill tab selector with swipeable TabView (.page style):
- Page 0: Quick Add (food search list) — shown first
- Page 1: AI Chat — swipe right to access
- Custom dot indicators replace the old pill tabs
- Swipe gesture between pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:52:01 -05:00
Yusuf Suleman
a5c95c2e5f feat: widget has two tap targets — fitness + add food
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s
Widget background tap → platform://fitness → opens Fitness tab
Widget + button tap → platform://add-food → opens Fitness + food assistant

Small widget: + button in bottom-right corner (emerald green)
Medium widget: + button in bottom-right corner
Lock screen widgets: single tap → fitness (no room for + button)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:45:08 -05:00
Yusuf Suleman
7cfe3eeed5 feat: tap widget → opens app to food search
All checks were successful
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
Widget: .widgetURL(platform://add-food) on all widget sizes
App: .onOpenURL handles platform://add-food → switches to Fitness
tab and opens the food assistant sheet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:41:21 -05:00
3002c1f59d add widget Info.plist with NSExtension
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
2026-04-04 11:36:21 -05:00
Yusuf Suleman
5d51ac6833 harden: widget edge cases — expired session, account switch, cache
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s
1. Session expires: widget gets 401 → clears stale cookie from
   App Group → stops retrying with bad auth → shows cached data
   until user opens app and re-authenticates

2. Account switch: login() now calls clearWidgetAuth() BEFORE
   syncCookieToWidget() — clears previous user's cached calories
   before writing new user's cookie. No brief display of wrong data.

3. Logout: already correct — clearWidgetAuth removes cookie +
   cached data, widget shows 0/2000

4. Minimum data: only session cookie + 2 cached numbers + timestamp
   in App Group. No passwords, no user IDs, no PII.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:31:44 -05:00
Yusuf Suleman
e21a26db18 feat: widget fetches calories from API independently + shared auth
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s
Widget:
- Fetches /api/fitness/entries/totals and /api/fitness/goals/for-date
  directly from gateway using shared session cookie
- Falls back to cached data in App Group UserDefaults if network fails
- Refreshes every 15 minutes via WidgetKit timeline
- Each phone shows the logged-in user's own data

Auth sharing:
- AuthManager.syncCookieToWidget() copies the session cookie to
  App Group UserDefaults on login and auth check
- Widget reads cookie and makes authenticated API calls
- Logout clears widget auth + cached data

Data in App Group (group.com.quadjourney.platform):
- widget_sessionCookie: auth token for API calls
- widget_totalCalories: cached fallback
- widget_calorieGoal: cached fallback
- widget_lastUpdate: cache timestamp

HomeViewModel also writes cache on each loadTodayData() as fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:29:52 -05:00
2d4cafa16e add App Group entitlements for both targets
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
2026-04-04 11:29:26 -05:00
Yusuf Suleman
9965b1d634 feat: calorie ring widget — home screen + lock screen
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Widget displays:
- systemSmall: calorie ring + "X left" text
- systemMedium: ring + "Calories" / "X of Y" / "X remaining"
- accessoryCircular: gauge ring for lock screen
- accessoryInline: "🔥 845 / 2000 cal" text for lock screen
- accessoryRectangular: linear gauge + calorie count

Data flow: main app writes totalCalories + calorieGoal to
UserDefaults on each loadTodayData(), then calls
WidgetCenter.shared.reloadAllTimelines(). Widget reads on
15-minute refresh cycle.

Note: currently uses standard UserDefaults (same app container).
For production, migrate to App Group UserDefaults so widget
process can read the data. Requires Xcode App Group setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:21:40 -05:00
a4ebe77973 feat: add PlatformWidget extension target
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
2026-04-04 11:19:18 -05:00
Yusuf Suleman
c13259c2b5 polish: depth, contrast, and layering refinements
All checks were successful
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 13s
1. Background — reduced warmth, more neutral:
   Dark:  #0e0d0b → #0d0d0b (less warm, more true black)
   Light: #EDE6DA → #EBE6DE (cooler sand, less yellow)

2. Cards — increased elevation:
   Shadow: 0.04/6/2 → 0.08/8/3 (more visible lift)
   Spacing: 12pt → 14pt between cards, 4pt → 8pt top padding

3. Text — more neutral for glass legibility:
   Primary light: 0.12 → 0.10 (darker)
   Secondary: warmer gray → neutral gray (0.40 uniform)
   Tertiary: warmer → neutral (0.58 uniform)

4. Accent — slightly deeper in light mode:
   #8B6914 → #805E0F (more contrast against glass)

5. Dark mode accent — slightly brighter:
   0.78/0.62/0.25 → 0.82/0.65/0.28

All changes are color/shadow/spacing only. No layout or
architectural changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:54:32 -05:00
Yusuf Suleman
f4b527e70b fix: increase surface contrast — cards now clearly separate from background
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Dark mode:
  Canvas: #1a1714 → #0e0d0b (deeper black, more separation)
  Card:   #26231f → #1e1b17 (warmer, 7% brighter than bg)

Light mode:
  Canvas: #F5EFE6 → #EDE6DA (cooler sand, slightly darker)
  Card:   #FFFCF8 → #FFFFFF (clean white, max contrast)

Both modes now have ~7% brightness gap + temperature contrast
(warm bg, cleaner card). Cards visually float without needing
borders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:40:45 -05:00
Yusuf Suleman
c74f36a94d fix: widen speed gaps — Slow 1.0x, Med 2.0x, Fast 3.5x (was 1.5/1.75/2.0)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
2026-04-04 10:31:55 -05:00
Yusuf Suleman
01c63d69d0 feat: auto-scroll speed cycles via tab bar button (Slow/Med/Fast)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Tab bar action button cycles through speeds on each tap:
- ▶ (play) → tap → Slow (1.5x, hare icon)
- Slow → tap → Med (1.75x, walk icon)
- Med → tap → Fast (2.0x, bolt icon)
- Fast → tap → back to Slow

Touch feed to stop → icon returns to ▶

Removed speed controls from Reader toolbar — all speed control
is now in the single tab bar button. Clean, no overlays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:28:03 -05:00
Yusuf Suleman
e0ae9cb95f fix: inline title (no large title drop) + restore canvas background
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s
1. .navigationBarTitleDisplayMode(.inline) — title stays at top
2. .background(Color.canvas) — restores warm cream background

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:11:09 -05:00
Yusuf Suleman
0f1a35ab84 simplify: remove sub-tabs, starred, feed filter chips from Reader
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Reader now shows only unread entries. Removed:
- Unread/Starred/All sub-tab selector
- Feed filter chip bar (categories)
- All related state (selectedSubTab) and helpers

Glass nav bar shows: "Reader" title + "74 unread" subtitle
Trailing toolbar: grid/list toggle + ellipsis menu
Auto-scroll: speed controls in leading toolbar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:07:40 -05:00
Yusuf Suleman
d75fb870d7 feat: Liquid Glass navigation bar for Reader (iOS 26 standard APIs)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Removed .navigationBarHidden(true) and all custom header layout.
Now uses standard iOS 26 navigation APIs that get Liquid Glass free:

- .navigationTitle("Reader") + .navigationSubtitle("74 unread")
- ToolbarItemGroup for sub-tabs (Unread/Starred/All) on leading
- ToolbarSpacer between groups
- ToolbarItemGroup for grid/list + menu on trailing
- Feed filter chips in .bottomBar toolbar
- When auto-scrolling: toolbar swaps to speed controls

The glass nav bar is translucent — content scrolls underneath.
Collapses to inline glass pill on scroll (system behavior).
No custom backgrounds, no custom layout — all system-managed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:03:58 -05:00
Yusuf Suleman
61cd78e080 feat: speed controls in collapsed tab bar via safeAreaBar
All checks were successful
Security Checks / dependency-audit (push) Successful in 27s
Security Checks / secret-scanning (push) Successful in 7s
Security Checks / dockerfile-lint (push) Successful in 6s
Uses .safeAreaBar(edge: .bottom) on the Reader content — the iOS 26
API that places content in the collapsed tab bar area, same position
as the Now Playing mini bar. Speed controls [ - ] 1.50x [ + ] appear
in the glass bar when auto-scrolling.

Removed the floating speed pill overlay from ContentView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:00:42 -05:00
Yusuf Suleman
e37444c62e fix: speed pill and feedback in same VStack — no more guessing position
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Speed pill and feedback button were in separate VStacks with
independent absolute padding, causing misalignment. Now they share
one VStack with .padding(.bottom, 70) at the container level.
The speed pill sits directly above the tab bar area, positioned
relative to the same anchor as all other bottom controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:55:30 -05:00
Yusuf Suleman
a452c0d4f2 fix: move speed pill up to not overlap cards (padding.bottom 50)
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
2026-04-04 08:52:14 -05:00
Yusuf Suleman
416a6ed3f8 fix: adjust speed pill position — was clipped at bottom, now 16pt above safe area
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
2026-04-04 08:47:07 -05:00
Yusuf Suleman
a39e0377b5 fix: move speed controls into tab bar area (tab bar hidden during scroll)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:43:52 -05:00
Yusuf Suleman
63b6027902 fix: move speed controls to bottom of screen, use regularMaterial
Some checks failed
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Has been cancelled
Speed pill was floating mid-screen (padding.bottom 90). Moved to
bottom (padding.bottom 8) to sit just above the tab bar area.
Switched from ultraThinMaterial to regularMaterial for better
Liquid Glass look with more opacity and stronger blur.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:43:33 -05:00
Yusuf Suleman
17d10ec4c1 fix: eliminate auto-scroll jitter by deferring mark-as-read visual updates
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
ROOT CAUSE (confirmed by instrumentation):
Every markRead during auto-scroll mutated entries[idx].status,
causing SwiftUI to re-render the row (bold→regular, dot removed).
This changed card height, causing contentSize jumps of 10-128pt
per entry — visible as jitter.

FIX: During auto-scroll, collect entry IDs in deferredReadIDs
instead of mutating entries array. When auto-scroll stops,
flushDeferredReads() applies all status changes at once and
sends a single batched API call.

Manual scroll still marks immediately (no deferral needed since
the user controls the scroll position).

Removed all debug instrumentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:27:37 -05:00
Yusuf Suleman
39b9303918 debug: auto-scroll jitter instrumentation — tick timing, contentSize, loadMore, markRead
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s
2026-04-04 08:19:30 -05:00
Yusuf Suleman
976469f5fe revert: remove fake delegate calls for tab bar minimize
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Calling scrollViewWillBeginDragging/DidEndDragging on the delegate
didn't trigger tabBarMinimizeBehavior — iOS 26 likely tracks actual
touch events, not delegate calls. Reverted to avoid side effects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:08:55 -05:00
Yusuf Suleman
a82ae267b6 fix: tab bar collapses during auto-scroll
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 5s
tabBarMinimizeBehavior needs scrollViewWillBeginDragging to recognize
a scroll session. Now ScrollViewDriver calls it on the original
delegate when auto-scroll starts, and scrollViewDidEndDragging +
scrollViewDidEndDecelerating when it stops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:03:23 -05:00
Yusuf Suleman
395cca08dd fix: action button on Reader now toggles auto-scroll (not food assistant)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Bug: when action tab (value=3) was tapped, selectedTab was already 3
by the time handleActionTap ran. The check 'if selectedTab == 2'
always failed, falling through to food assistant.

Fix: use onChange(of: selectedTab) oldValue to capture which tab the
user was on BEFORE tapping the action button. Pass that to
handleActionTap(from:). If from Reader (2), toggle auto-scroll.
If from Home/Fitness, open food assistant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:00:43 -05:00
Yusuf Suleman
e2fc87b6aa feat: Tab(role: .search) with context-dependent action per tab
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s
Single separated circle in tab bar (like Photos search icon):
- Home/Fitness: shows + icon, taps opens food assistant
- Reader idle: shows play icon, taps starts auto-scroll
- Reader playing: shows pause icon, taps stops auto-scroll

Icon updates dynamically via computed actionIcon property.
handleActionTap() routes the tap based on selectedTab.
After action, selectedTab returns to the previous tab (doesn't
stay on the invisible "action" tab).

Speed controls still appear as glass capsule overlay when playing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:55:50 -05:00
44 changed files with 2473 additions and 319 deletions

3
ios/Platform/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# AGENTS.md
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.

View File

@@ -14,6 +14,17 @@
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; }; A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; }; A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; };
A10051 /* EditableDraftCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10051 /* EditableDraftCard.swift */; }; A10051 /* EditableDraftCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10051 /* EditableDraftCard.swift */; };
A10060 /* TripModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10060 /* TripModels.swift */; };
A10061 /* TripsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10061 /* TripsAPI.swift */; };
A10062 /* TripsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10062 /* TripsViewModel.swift */; };
A10063 /* TripsHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10063 /* TripsHomeView.swift */; };
A10064 /* UpcomingTripsPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10064 /* UpcomingTripsPageView.swift */; };
A10065 /* PastTripsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10065 /* PastTripsSection.swift */; };
A10066 /* TripCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10066 /* TripCard.swift */; };
A10067 /* TripImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10067 /* TripImageView.swift */; };
A10068 /* TripPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10068 /* TripPlaceholderView.swift */; };
A10069 /* TripDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10069 /* TripDetailView.swift */; };
A10070 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10070 /* CameraView.swift */; };
A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; }; A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; }; A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; }; A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; };
@@ -52,8 +63,36 @@
A10047 /* FeedManagementSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10047 /* FeedManagementSheet.swift */; }; A10047 /* FeedManagementSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10047 /* FeedManagementSheet.swift */; };
A10048 /* ScrollViewDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10048 /* ScrollViewDriver.swift */; }; A10048 /* ScrollViewDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10048 /* ScrollViewDriver.swift */; };
F20549752F805F5800AE8DF5 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F20549742F805F5800AE8DF5 /* ConfettiSwiftUI */; }; F20549752F805F5800AE8DF5 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F20549742F805F5800AE8DF5 /* ConfettiSwiftUI */; };
W10001 /* PlatformWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = W10010 /* PlatformWidgetBundle.swift */; };
W10002 /* PlatformWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = W10011 /* PlatformWidget.swift */; };
W10003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = W10012 /* Assets.xcassets */; };
W10004 /* PlatformWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = W10020 /* PlatformWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
W10030 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = G10010 /* Project object */;
proxyType = 1;
remoteGlobalIDString = W10040;
remoteInfo = PlatformWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
W10050 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
W10004 /* PlatformWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
B10001 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformApp.swift; sourceTree = "<group>"; }; B10001 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformApp.swift; sourceTree = "<group>"; };
B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; B10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -62,6 +101,17 @@
B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; }; B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = "<group>"; }; B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = "<group>"; };
B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = "<group>"; }; B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = "<group>"; };
B10060 /* TripModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripModels.swift; sourceTree = "<group>"; };
B10061 /* TripsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsAPI.swift; sourceTree = "<group>"; };
B10062 /* TripsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsViewModel.swift; sourceTree = "<group>"; };
B10063 /* TripsHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsHomeView.swift; sourceTree = "<group>"; };
B10064 /* UpcomingTripsPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpcomingTripsPageView.swift; sourceTree = "<group>"; };
B10065 /* PastTripsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PastTripsSection.swift; sourceTree = "<group>"; };
B10066 /* TripCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripCard.swift; sourceTree = "<group>"; };
B10067 /* TripImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripImageView.swift; sourceTree = "<group>"; };
B10068 /* TripPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripPlaceholderView.swift; sourceTree = "<group>"; };
B10069 /* TripDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripDetailView.swift; sourceTree = "<group>"; };
B10070 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = "<group>"; };
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; }; B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
@@ -100,7 +150,14 @@
B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = "<group>"; }; B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = "<group>"; };
B10048 /* ScrollViewDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDriver.swift; sourceTree = "<group>"; }; B10048 /* ScrollViewDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDriver.swift; sourceTree = "<group>"; };
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B10090 /* Platform.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Platform.entitlements; sourceTree = "<group>"; };
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; }; D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
W10013 /* PlatformWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PlatformWidgetExtension.entitlements; sourceTree = "<group>"; };
W10014 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
W10010 /* PlatformWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformWidgetBundle.swift; sourceTree = "<group>"; };
W10011 /* PlatformWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformWidget.swift; sourceTree = "<group>"; };
W10012 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
W10020 /* PlatformWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PlatformWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -112,6 +169,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
W10060 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -119,6 +183,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F10002 /* Platform */, F10002 /* Platform */,
W10070 /* PlatformWidget */,
F10020 /* Products */, F10020 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -126,6 +191,7 @@
F10002 /* Platform */ = { F10002 /* Platform */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B10090 /* Platform.entitlements */,
B10001 /* PlatformApp.swift */, B10001 /* PlatformApp.swift */,
B10002 /* ContentView.swift */, B10002 /* ContentView.swift */,
B10003 /* Config.swift */, B10003 /* Config.swift */,
@@ -157,6 +223,7 @@
F10014 /* Assistant */, F10014 /* Assistant */,
F10021 /* Feedback */, F10021 /* Feedback */,
F10030 /* Reader */, F10030 /* Reader */,
F10050 /* Trips */,
); );
path = Features; path = Features;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -266,6 +333,7 @@
B10027 /* MacroRing.swift */, B10027 /* MacroRing.swift */,
B10028 /* MacroBar.swift */, B10028 /* MacroBar.swift */,
B10029 /* LoadingView.swift */, B10029 /* LoadingView.swift */,
B10070 /* CameraView.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -283,10 +351,23 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D10001 /* Platform.app */, D10001 /* Platform.app */,
W10020 /* PlatformWidgetExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
W10070 /* PlatformWidget */ = {
isa = PBXGroup;
children = (
W10013 /* PlatformWidgetExtension.entitlements */,
W10014 /* Info.plist */,
W10010 /* PlatformWidgetBundle.swift */,
W10011 /* PlatformWidget.swift */,
W10012 /* Assets.xcassets */,
);
path = PlatformWidget;
sourceTree = "<group>";
};
F10021 /* Feedback */ = { F10021 /* Feedback */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -343,6 +424,55 @@
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
F10050 /* Trips */ = {
isa = PBXGroup;
children = (
F10051 /* Models */,
F10052 /* API */,
F10053 /* ViewModels */,
F10054 /* Views */,
);
path = Trips;
sourceTree = "<group>";
};
F10051 /* Models */ = {
isa = PBXGroup;
children = (
B10060 /* TripModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
F10052 /* API */ = {
isa = PBXGroup;
children = (
B10061 /* TripsAPI.swift */,
);
path = API;
sourceTree = "<group>";
};
F10053 /* ViewModels */ = {
isa = PBXGroup;
children = (
B10062 /* TripsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
F10054 /* Views */ = {
isa = PBXGroup;
children = (
B10063 /* TripsHomeView.swift */,
B10064 /* UpcomingTripsPageView.swift */,
B10065 /* PastTripsSection.swift */,
B10066 /* TripCard.swift */,
B10067 /* TripImageView.swift */,
B10068 /* TripPlaceholderView.swift */,
B10069 /* TripDetailView.swift */,
);
path = Views;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -353,18 +483,45 @@
G10002 /* Sources */, G10002 /* Sources */,
E10001 /* Frameworks */, E10001 /* Frameworks */,
G10003 /* Resources */, G10003 /* Resources */,
W10050 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
W10031 /* PBXTargetDependency */,
); );
name = Platform; name = Platform;
productName = Platform; productName = Platform;
productReference = D10001 /* Platform.app */; productReference = D10001 /* Platform.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
W10040 /* PlatformWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = W10080 /* Build configuration list for PBXNativeTarget "PlatformWidgetExtension" */;
buildPhases = (
W10041 /* Sources */,
W10060 /* Frameworks */,
W10042 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = PlatformWidgetExtension;
productName = PlatformWidgetExtension;
productReference = W10020 /* PlatformWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXTargetDependency section */
W10031 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = W10040 /* PlatformWidgetExtension */;
targetProxy = W10030 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXProject section */ /* Begin PBXProject section */
G10010 /* Project object */ = { G10010 /* Project object */ = {
isa = PBXProject; isa = PBXProject;
@@ -376,6 +533,9 @@
G10001 = { G10001 = {
CreatedOnToolsVersion = 15.4; CreatedOnToolsVersion = 15.4;
}; };
W10040 = {
CreatedOnToolsVersion = 15.4;
};
}; };
}; };
buildConfigurationList = H10001 /* Build configuration list for PBXProject "Platform" */; buildConfigurationList = H10001 /* Build configuration list for PBXProject "Platform" */;
@@ -395,6 +555,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
G10001 /* Platform */, G10001 /* Platform */,
W10040 /* PlatformWidgetExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -408,6 +569,14 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
W10042 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
W10003 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -422,6 +591,17 @@
A10005 /* AuthManager.swift in Sources */, A10005 /* AuthManager.swift in Sources */,
A10050 /* AppearanceManager.swift in Sources */, A10050 /* AppearanceManager.swift in Sources */,
A10051 /* EditableDraftCard.swift in Sources */, A10051 /* EditableDraftCard.swift in Sources */,
A10060 /* TripModels.swift in Sources */,
A10061 /* TripsAPI.swift in Sources */,
A10062 /* TripsViewModel.swift in Sources */,
A10063 /* TripsHomeView.swift in Sources */,
A10064 /* UpcomingTripsPageView.swift in Sources */,
A10065 /* PastTripsSection.swift in Sources */,
A10066 /* TripCard.swift in Sources */,
A10067 /* TripImageView.swift in Sources */,
A10068 /* TripPlaceholderView.swift in Sources */,
A10069 /* TripDetailView.swift in Sources */,
A10070 /* CameraView.swift in Sources */,
A10006 /* LoginView.swift in Sources */, A10006 /* LoginView.swift in Sources */,
A10007 /* HomeView.swift in Sources */, A10007 /* HomeView.swift in Sources */,
A10008 /* HomeViewModel.swift in Sources */, A10008 /* HomeViewModel.swift in Sources */,
@@ -461,6 +641,15 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
W10041 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
W10001 /* PlatformWidgetBundle.swift in Sources */,
W10002 /* PlatformWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -588,6 +777,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Platform/Platform.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CRN5A2VZ79; DEVELOPMENT_TEAM = CRN5A2VZ79;
@@ -617,6 +807,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Platform/Platform.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CRN5A2VZ79; DEVELOPMENT_TEAM = CRN5A2VZ79;
@@ -641,6 +832,62 @@
}; };
name = Release; name = Release;
}; };
W10081 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PlatformWidget/PlatformWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CRN5A2VZ79;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PlatformWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = PlatformWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform.PlatformWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
W10082 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PlatformWidget/PlatformWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CRN5A2VZ79;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PlatformWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = PlatformWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.Platform.PlatformWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -662,6 +909,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
W10080 /* Build configuration list for PBXNativeTarget "PlatformWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
W10081 /* Debug */,
W10082 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,15 @@
{
"originHash" : "0813d8074328ac339b3ad4d9e895dca3cbd4327633fce793259860c9993a0805",
"pins" : [
{
"identity" : "confettiswiftui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simibac/ConfettiSwiftUI",
"state" : {
"revision" : "9ae5bc2ce2149980884d4612321c3a266d6bf82c",
"version" : "3.0.0"
}
}
],
"version" : 3
}

View File

@@ -28,13 +28,43 @@ struct MainTabView: View {
@State private var showAssistant = false @State private var showAssistant = false
@State private var confettiTrigger = 0 @State private var confettiTrigger = 0
@State private var readerVM = ReaderViewModel() @State private var readerVM = ReaderViewModel()
@State private var tripsVM = TripsViewModel()
@State private var isAutoScrolling = false @State private var isAutoScrolling = false
@State private var scrollSpeed: Double = 1.0 @State private var scrollSpeed: Double = 1.5
@State private var speedLevel = 0 // 0=slow, 1=med, 2=fast
@State private var previousTab = 0
private var showReader: Bool { private var showReader: Bool {
auth.currentUser?.id != 4 auth.currentUser?.id != 4
} }
private let speedLevels: [(String, String, Double)] = [
("Slow", "hare", 1.0),
("Med", "figure.walk", 2.0),
("Fast", "bolt.fill", 3.5),
]
// Dynamic icon + label for the search-role tab
private var actionIcon: String {
if selectedTab == 2 && isAutoScrolling {
return speedLevels[speedLevel].1
}
if selectedTab == 2 {
return "play.fill"
}
return "plus"
}
private var actionLabel: String {
if selectedTab == 2 && isAutoScrolling {
return speedLevels[speedLevel].0
}
if selectedTab == 2 {
return "Play"
}
return "Add"
}
var body: some View { var body: some View {
ZStack { ZStack {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@@ -51,89 +81,34 @@ struct MainTabView: View {
ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed) ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed)
} }
} }
Tab("Trips", systemImage: "airplane", value: 4) {
TripsHomeView(vm: tripsVM)
}
// Action button separated circle on trailing side of tab bar
// Home/Fitness: quick add food (+)
// Reader: play/pause auto-scroll
Tab(value: 3, role: .search) {
Color.clear
} label: {
Label(actionLabel, systemImage: actionIcon)
}
} }
.tint(Color.accentWarm) .tint(Color.accentWarm)
.tabBarMinimizeBehavior(.onScrollDown) .tabBarMinimizeBehavior(.onScrollDown)
// Floating action button context-dependent // Feedback button (not on Reader)
VStack { if selectedTab != 2 {
Spacer()
HStack(alignment: .bottom) {
if selectedTab != 2 {
FeedbackButton()
.padding(.leading, 20)
}
Spacer()
if selectedTab == 2 && showReader {
// Reader: play/pause auto-scroll
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling.toggle()
}
} label: {
Image(systemName: isAutoScrolling ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(isAutoScrolling ? Color.red.opacity(0.85) : Color.accentWarm)
.clipShape(Circle())
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
}
.padding(.trailing, 20)
} else {
// Home/Fitness: FAB (+) for food
Button { showAssistant = true } label: {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentWarm)
.clipShape(Circle())
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
}
.padding(.trailing, 20)
}
}
.padding(.bottom, 70)
}
// Auto-scroll speed controls (overlay when playing)
if isAutoScrolling && selectedTab == 2 {
VStack { VStack {
Spacer() Spacer()
HStack(spacing: 12) { HStack {
Button { FeedbackButton()
scrollSpeed = max(0.25, scrollSpeed - 0.25) .padding(.leading, 20)
} label: { Spacer()
Image(systemName: "minus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
Text(String(format: "%.2fx", scrollSpeed))
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(Color.textPrimary)
Button {
scrollSpeed = min(3.0, scrollSpeed + 0.25)
} label: {
Image(systemName: "plus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
} }
.padding(.horizontal, 14) .padding(.bottom, 70)
.padding(.vertical, 8)
.background(.ultraThinMaterial, in: Capsule())
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
.padding(.bottom, 140)
.transition(.move(edge: .bottom).combined(with: .opacity))
} }
.animation(.spring(duration: 0.3), value: isAutoScrolling)
} }
} }
.confettiCannon( .confettiCannon(
@@ -150,15 +125,56 @@ struct MainTabView: View {
.sheet(isPresented: $showAssistant) { .sheet(isPresented: $showAssistant) {
AssistantSheetView(onFoodAdded: foodAdded) AssistantSheetView(onFoodAdded: foodAdded)
} }
.onOpenURL { url in
guard url.scheme == "platform" else { return }
switch url.host {
case "fitness":
selectedTab = 1
case "add-food":
selectedTab = 1
showAssistant = true
default:
break
}
}
.task { .task {
// Pre-load trips for all users
await tripsVM.loadTrips()
// Reader only for non-Madiha
guard showReader else { return } guard showReader else { return }
let renderer = ArticleRenderer.shared let renderer = ArticleRenderer.shared
renderer.attachToWindow() renderer.attachToWindow()
await readerVM.loadInitial() await readerVM.loadInitial()
} }
.onChange(of: selectedTab) { _, newTab in .onChange(of: selectedTab) { oldTab, newTab in
// Stop auto-scroll when leaving Reader if newTab == 3 {
if newTab != 2 { isAutoScrolling = false } // Action tab tapped handle based on previous tab
handleActionTap(from: oldTab)
} else {
previousTab = newTab
if newTab != 2 { isAutoScrolling = false }
}
}
}
private func handleActionTap(from sourceTab: Int) {
if sourceTab == 2 {
if !isAutoScrolling {
// First tap: start at Slow
speedLevel = 0
scrollSpeed = speedLevels[0].2
isAutoScrolling = true
} else {
// Cycle: Slow Med Fast Slow
speedLevel = (speedLevel + 1) % speedLevels.count
scrollSpeed = speedLevels[speedLevel].2
}
selectedTab = 2
} else {
// Home/Fitness: open food assistant, return to previous tab
showAssistant = true
selectedTab = sourceTab
} }
} }
@@ -188,9 +204,10 @@ struct AssistantSheetView: View {
.font(.headline) .font(.headline)
.foregroundStyle(Color.textPrimary) .foregroundStyle(Color.textPrimary)
// Pill selector tappable, syncs with swipe
HStack(spacing: 4) { HStack(spacing: 4) {
tabButton("AI Chat", icon: "sparkles", index: 0) tabPill("Quick Add", icon: "magnifyingglass", index: 0)
tabButton("Quick Add", icon: "magnifyingglass", index: 1) tabPill("AI Chat", icon: "sparkles", index: 1)
} }
.padding(4) .padding(4)
.background(Color.textTertiary.opacity(0.08)) .background(Color.textTertiary.opacity(0.08))
@@ -200,17 +217,22 @@ struct AssistantSheetView: View {
.padding(.bottom, 12) .padding(.bottom, 12)
.background(Color.canvas) .background(Color.canvas)
if selectedMode == 0 { // Swipeable pages Quick Add first
AssistantChatView(onFoodAdded: onFoodAdded) TabView(selection: $selectedMode) {
} else {
FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded) FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded)
.tag(0)
AssistantChatView(onFoodAdded: onFoodAdded)
.tag(1)
} }
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.2), value: selectedMode)
} }
.background(Color.canvas) .background(Color.canvas)
.presentationDetents([.large]) .presentationDetents([.large])
} }
private func tabButton(_ title: String, icon: String, index: Int) -> some View { private func tabPill(_ title: String, icon: String, index: Int) -> some View {
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index } withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index }
} label: { } label: {

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import WidgetKit
@Observable @Observable
final class AuthManager { final class AuthManager {
@@ -10,6 +11,12 @@ final class AuthManager {
private let api = APIClient.shared private let api = APIClient.shared
private let loggedInKey = "isLoggedIn" private let loggedInKey = "isLoggedIn"
// App Group for sharing auth with widget
private static let appGroup = "group.com.quadjourney.platform"
static var sharedDefaults: UserDefaults {
UserDefaults(suiteName: appGroup) ?? .standard
}
init() { init() {
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey) isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
} }
@@ -46,9 +53,11 @@ final class AuthManager {
if response.authenticated, let user = response.user { if response.authenticated, let user = response.user {
currentUser = user currentUser = user
isLoggedIn = true isLoggedIn = true
syncCookieToWidget()
} else { } else {
isLoggedIn = false isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey) UserDefaults.standard.set(false, forKey: loggedInKey)
clearWidgetAuth()
} }
} catch { } catch {
isLoggedIn = false isLoggedIn = false
@@ -68,6 +77,9 @@ final class AuthManager {
currentUser = response.user currentUser = response.user
isLoggedIn = true isLoggedIn = true
UserDefaults.standard.set(true, forKey: loggedInKey) UserDefaults.standard.set(true, forKey: loggedInKey)
clearWidgetAuth() // Clear previous user's cached data
syncCookieToWidget()
WidgetCenter.shared.reloadAllTimelines()
} }
} catch let apiError as APIError { } catch let apiError as APIError {
error = apiError.localizedDescription error = apiError.localizedDescription
@@ -83,5 +95,26 @@ final class AuthManager {
currentUser = nil currentUser = nil
isLoggedIn = false isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey) UserDefaults.standard.set(false, forKey: loggedInKey)
clearWidgetAuth()
WidgetCenter.shared.reloadAllTimelines()
}
// MARK: - Widget Auth Sync
/// Copy the session cookie to App Group UserDefaults so the widget can authenticate.
private func syncCookieToWidget() {
guard let url = URL(string: "https://dash.quadjourney.com"),
let cookies = HTTPCookieStorage.shared.cookies(for: url) else { return }
for cookie in cookies where cookie.name == "session" {
Self.sharedDefaults.set(cookie.value, forKey: "widget_sessionCookie")
return
}
}
private func clearWidgetAuth() {
Self.sharedDefaults.removeObject(forKey: "widget_sessionCookie")
Self.sharedDefaults.removeObject(forKey: "widget_totalCalories")
Self.sharedDefaults.removeObject(forKey: "widget_calorieGoal")
} }
} }

View File

@@ -3,6 +3,8 @@ import PhotosUI
struct AssistantChatView: View { struct AssistantChatView: View {
@State private var vm = AssistantViewModel() @State private var vm = AssistantViewModel()
@State private var showCamera = false
@State private var showPhotoPicker = false
var onFoodAdded: (() -> Void)? var onFoodAdded: (() -> Void)?
var body: some View { var body: some View {
@@ -83,19 +85,32 @@ struct AssistantChatView: View {
Divider() Divider()
// Input bar // Input bar
HStack(spacing: 10) { HStack(alignment: .bottom, spacing: 10) {
PhotosPicker(selection: $vm.selectedPhoto, matching: .images) { Menu {
if UIImagePickerController.isSourceTypeAvailable(.camera) {
Button {
showCamera = true
} label: {
Label("Take Photo", systemImage: "camera")
}
}
Button {
showPhotoPicker = true
} label: {
Label("Photo Library", systemImage: "photo.on.rectangle")
}
} label: {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.font(.title3) .font(.title3)
.foregroundStyle(Color.accentWarm) .foregroundStyle(Color.accentWarm)
} }
.padding(.bottom, 4)
TextField("Describe your food...", text: $vm.inputText) // Multiline text input Return = new line, send button submits
TextField("Describe your food...", text: $vm.inputText, axis: .vertical)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.onSubmit { .lineLimit(1...5)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
Task { await vm.send() }
}
Button { Button {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
@@ -110,6 +125,7 @@ struct AssistantChatView: View {
) )
} }
.disabled(vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty || vm.isLoading) .disabled(vm.inputText.trimmingCharacters(in: .whitespaces).isEmpty || vm.isLoading)
.padding(.bottom, 4)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -126,6 +142,13 @@ struct AssistantChatView: View {
} }
} }
} }
.photosPicker(isPresented: $showPhotoPicker, selection: $vm.selectedPhoto, matching: .images)
.fullScreenCover(isPresented: $showCamera) {
CameraView { image in
showCamera = false
Task { await vm.handleCameraImage(image) }
}
}
} }
// MARK: - Chat Bubble // MARK: - Chat Bubble

View File

@@ -173,6 +173,23 @@ final class AssistantViewModel {
selectedPhoto = nil selectedPhoto = nil
} }
func handleCameraImage(_ image: UIImage) async {
let maxDim: CGFloat = 800
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
if let jpegData = resized.jpegData(compressionQuality: 0.7) {
let base64 = jpegData.base64EncodedString()
imageDataUrl = "data:image/jpeg;base64,\(base64)"
messages.append(ChatMessage(role: "user", content: "[Photo attached]"))
await doSend(action: "chat")
}
}
// MARK: - Helpers // MARK: - Helpers
private func draftToDict(_ draft: FitnessDraft) -> [String: Any] { private func draftToDict(_ draft: FitnessDraft) -> [String: Any] {

View File

@@ -3,6 +3,13 @@ import SwiftUI
struct EditableDraftCard: View { struct EditableDraftCard: View {
@Bindable var vm: AssistantViewModel @Bindable var vm: AssistantViewModel
@State private var isEditing = false @State private var isEditing = false
// Base values per 1.0 quantity set once when draft appears
@State private var baseCalories: Double?
@State private var baseProtein: Double?
@State private var baseCarbs: Double?
@State private var baseFat: Double?
@State private var baseSugar: Double?
@State private var baseFiber: Double?
private var draft: FitnessDraft? { vm.currentDraft } private var draft: FitnessDraft? { vm.currentDraft }
@@ -135,9 +142,35 @@ struct EditableDraftCard: View {
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2) .shadow(color: .black.opacity(0.06), radius: 6, y: 2)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onAppear {
// Capture base per-unit values on first appearance
if baseCalories == nil, let d = vm.currentDraft {
let q = max(d.quantity, 0.01)
baseCalories = d.calories / q
baseProtein = d.protein / q
baseCarbs = d.carbs / q
baseFat = d.fat / q
baseSugar = d.sugar / q
baseFiber = d.fiber / q
}
}
} }
} }
/// Recalculate macros from base values when quantity changes
private func recalculateMacros() {
guard let bc = baseCalories, let bp = baseProtein,
let bca = baseCarbs, let bf = baseFat,
let bs = baseSugar, let bfi = baseFiber else { return }
let q = vm.currentDraft?.quantity ?? 1.0
vm.currentDraft?.calories = (bc * q).rounded()
vm.currentDraft?.protein = (bp * q * 10).rounded() / 10
vm.currentDraft?.carbs = (bca * q * 10).rounded() / 10
vm.currentDraft?.fat = (bf * q * 10).rounded() / 10
vm.currentDraft?.sugar = (bs * q * 10).rounded() / 10
vm.currentDraft?.fiber = (bfi * q * 10).rounded() / 10
}
// MARK: - Editable macro cell // MARK: - Editable macro cell
private func editableMacro(_ label: String, value: Binding<Double>, color: Color) -> some View { private func editableMacro(_ label: String, value: Binding<Double>, color: Color) -> some View {
@@ -196,7 +229,10 @@ struct EditableDraftCard: View {
private var quantityBinding: Binding<String> { private var quantityBinding: Binding<String> {
Binding( Binding(
get: { String(format: "%.1f", vm.currentDraft?.quantity ?? 1) }, get: { String(format: "%.1f", vm.currentDraft?.quantity ?? 1) },
set: { vm.currentDraft?.quantity = Double($0) ?? 1 } set: {
vm.currentDraft?.quantity = Double($0) ?? 1
recalculateMacros()
}
) )
} }
} }

View File

@@ -68,6 +68,14 @@ struct FitnessAPI {
try await api.get("\(basePath)/foods/\(id)") try await api.get("\(basePath)/foods/\(id)")
} }
func deleteFood(id: String) async throws -> SuccessResponse {
try await api.delete("\(basePath)/foods/\(id)")
}
func updateFood(id: String, body: UpdateFoodRequest) async throws -> Food {
try await api.patch("\(basePath)/foods/\(id)", body: body)
}
// MARK: - Templates // MARK: - Templates
func getTemplates() async throws -> [MealTemplate] { func getTemplates() async throws -> [MealTemplate] {

View File

@@ -235,6 +235,20 @@ struct UpdateEntryRequest: Encodable {
} }
} }
// MARK: - Update Food Request
struct UpdateFoodRequest: Encodable {
var name: String?
var brand: String?
var caloriesPerBase: Double?
var proteinPerBase: Double?
var carbsPerBase: Double?
var fatPerBase: Double?
var sugarPerBase: Double?
var fiberPerBase: Double?
var baseUnit: String?
}
// MARK: - Delete Response // MARK: - Delete Response
struct SuccessResponse: Decodable { struct SuccessResponse: Decodable {

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct FoodLibraryView: View { struct FoodLibraryView: View {
@State private var vm = FoodSearchViewModel() @State private var vm = FoodSearchViewModel()
@State private var selectedFood: Food? @State private var selectedFood: Food?
@State private var editingFood: Food?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -76,6 +77,21 @@ struct FoodLibraryView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
} }
.contextMenu {
Button {
editingFood = food
} label: {
Label("Edit", systemImage: "pencil")
}
Button(role: .destructive) {
Task {
_ = try? await FitnessAPI().deleteFood(id: food.id)
await vm.loadInitial()
}
} label: {
Label("Delete", systemImage: "trash")
}
}
Divider().padding(.leading, 20) Divider().padding(.leading, 20)
} }
} }
@@ -92,5 +108,133 @@ struct FoodLibraryView: View {
.sheet(item: $selectedFood) { food in .sheet(item: $selectedFood) { food in
AddFoodSheet(food: food) AddFoodSheet(food: food)
} }
.sheet(item: $editingFood) { food in
EditFoodSheet(food: food) {
Task { await vm.loadInitial() }
}
}
}
}
// MARK: - Edit Food Sheet
struct EditFoodSheet: View {
let food: Food
var onSave: () -> Void = {}
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var brand: String
@State private var calories: String
@State private var protein: String
@State private var carbs: String
@State private var fat: String
@State private var sugar: String
@State private var fiber: String
@State private var baseUnit: String
@State private var isSaving = false
@State private var error: String?
init(food: Food, onSave: @escaping () -> Void = {}) {
self.food = food
self.onSave = onSave
_name = State(initialValue: food.name)
_brand = State(initialValue: food.brand ?? "")
_calories = State(initialValue: String(Int(food.caloriesPerBase)))
_protein = State(initialValue: String(Int(food.proteinPerBase)))
_carbs = State(initialValue: String(Int(food.carbsPerBase)))
_fat = State(initialValue: String(Int(food.fatPerBase)))
_sugar = State(initialValue: String(Int(food.sugarPerBase)))
_fiber = State(initialValue: String(Int(food.fiberPerBase)))
_baseUnit = State(initialValue: food.baseUnit)
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Food name", text: $name)
TextField("Brand (optional)", text: $brand)
}
Section("Nutrition per \(baseUnit)") {
macroField("Calories", text: $calories, color: .emerald)
macroField("Protein (g)", text: $protein, color: .macroProtein)
macroField("Carbs (g)", text: $carbs, color: .macroCarbs)
macroField("Fat (g)", text: $fat, color: .macroFat)
macroField("Sugar (g)", text: $sugar, color: .orange)
macroField("Fiber (g)", text: $fiber, color: .green)
}
Section("Unit") {
TextField("Base unit", text: $baseUnit)
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
}
}
.navigationTitle("Edit Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button {
save()
} label: {
if isSaving {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.disabled(name.isEmpty || isSaving)
}
}
}
}
private func macroField(_ label: String, text: Binding<String>, color: Color) -> some View {
HStack {
Text(label)
.foregroundStyle(color)
Spacer()
TextField("0", text: text)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
}
}
private func save() {
isSaving = true
error = nil
Task {
do {
let request = UpdateFoodRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
caloriesPerBase: Double(calories),
proteinPerBase: Double(protein),
carbsPerBase: Double(carbs),
fatPerBase: Double(fat),
sugarPerBase: Double(sugar),
fiberPerBase: Double(fiber),
baseUnit: baseUnit
)
_ = try await FitnessAPI().updateFood(id: food.id, body: request)
onSave()
dismiss()
} catch {
self.error = error.localizedDescription
}
isSaving = false
}
} }
} }

View File

@@ -5,6 +5,7 @@ struct FoodSearchView: View {
var onFoodAdded: (() -> Void)? var onFoodAdded: (() -> Void)?
@State private var vm = FoodSearchViewModel() @State private var vm = FoodSearchViewModel()
@State private var selectedFood: Food? @State private var selectedFood: Food?
@FocusState private var searchFocused: Bool
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -15,6 +16,7 @@ struct FoodSearchView: View {
TextField("Search foods...", text: $vm.searchText) TextField("Search foods...", text: $vm.searchText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($searchFocused)
if !vm.searchText.isEmpty { if !vm.searchText.isEmpty {
Button { Button {
vm.searchText = "" vm.searchText = ""
@@ -44,6 +46,11 @@ struct FoodSearchView: View {
.background(Color.canvas) .background(Color.canvas)
.task { .task {
await vm.loadInitial() await vm.loadInitial()
if isSheet {
// Small delay so sheet animation completes first
try? await Task.sleep(for: .milliseconds(300))
searchFocused = true
}
} }
.onChange(of: vm.searchText) { .onChange(of: vm.searchText) {
vm.search() vm.search()

View File

@@ -2,17 +2,14 @@ import SwiftUI
struct TodayView: View { struct TodayView: View {
@State private var vm = TodayViewModel() @State private var vm = TodayViewModel()
@State private var animated = false
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
// Date selector
dateSelector dateSelector
// Macro summary
macroSummary macroSummary
// Meal sections
if vm.entries.isEmpty && !vm.isLoading { if vm.entries.isEmpty && !vm.isLoading {
EmptyStateView( EmptyStateView(
icon: "fork.knife", icon: "fork.knife",
@@ -20,7 +17,8 @@ struct TodayView: View {
subtitle: "Tap + to log your first meal" subtitle: "Tap + to log your first meal"
) )
} else { } else {
ForEach(vm.mealGroups, id: \.0) { mealType, entries in ForEach(Array(vm.mealGroups.enumerated()), id: \.1.0) { index, group in
let (mealType, entries) = group
MealSectionView( MealSectionView(
mealType: mealType, mealType: mealType,
entries: entries, entries: entries,
@@ -28,6 +26,9 @@ struct TodayView: View {
Task { await vm.deleteEntry(entry) } Task { await vm.deleteEntry(entry) }
} }
) )
.opacity(animated ? 1 : 0)
.offset(y: animated ? 0 : 20)
.animation(.spring(response: 0.5, dampingFraction: 0.8).delay(Double(index) * 0.08), value: animated)
} }
} }
@@ -41,9 +42,27 @@ struct TodayView: View {
.background(Color.canvas) .background(Color.canvas)
.task { .task {
await vm.load() await vm.load()
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
animated = true
}
} }
.onChange(of: vm.selectedDate) { .onChange(of: vm.selectedDate) {
Task { await vm.load() } animated = false
Task {
await vm.load()
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
animated = true
}
}
}
.onAppear {
// Re-animate when switching back to this tab
animated = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
animated = true
}
}
} }
} }

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import PhotosUI import PhotosUI
import WidgetKit
@Observable @Observable
final class HomeViewModel { final class HomeViewModel {
@@ -32,6 +33,13 @@ final class HomeViewModel {
totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories } totalCalories = repo.entries.reduce(0) { $0 + $1.snapshotCalories }
calorieGoal = repo.goal?.calories ?? 2000 calorieGoal = repo.goal?.calories ?? 2000
isLoading = false isLoading = false
// Write to App Group UserDefaults for widget (fallback cache)
let shared = AuthManager.sharedDefaults
shared.set(totalCalories, forKey: "widget_totalCalories")
shared.set(calorieGoal, forKey: "widget_calorieGoal")
shared.set(Date(), forKey: "widget_lastUpdate")
WidgetCenter.shared.reloadAllTimelines()
} }
// MARK: - Background Image // MARK: - Background Image

View File

@@ -72,7 +72,6 @@ final class ReaderViewModel {
if reset { if reset {
offset = 0 offset = 0
hasMore = true hasMore = true
// DO NOT set entries = [] causes full list teardown + empty flash
} }
guard !isLoading else { return } guard !isLoading else { return }
isLoading = true isLoading = true
@@ -80,7 +79,7 @@ final class ReaderViewModel {
do { do {
let list = try await fetchEntries(offset: 0) let list = try await fetchEntries(offset: 0)
// Atomic swap SwiftUI diffs by Identifiable.id // Atomic swap fresh list replaces old (no duplicates possible)
entries = list.entries entries = list.entries
total = list.total total = list.total
offset = list.entries.count offset = list.entries.count
@@ -96,11 +95,21 @@ final class ReaderViewModel {
isLoadingMore = true isLoadingMore = true
do { do {
let list = try await fetchEntries(offset: offset) // Use entries.count as offset accounts for deduplication and
entries.append(contentsOf: list.entries) // avoids offset drift when entries change status during scroll
total = list.total let list = try await fetchEntries(offset: entries.count)
offset += list.entries.count
hasMore = offset < list.total if list.entries.isEmpty {
hasMore = false
} else {
// Deduplicate only append entries with IDs not already in the array
let existingIDs = Set(entries.map(\.id))
let newEntries = list.entries.filter { !existingIDs.contains($0.id) }
entries.append(contentsOf: newEntries)
total = list.total
offset = entries.count
hasMore = newEntries.count > 0
}
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }

View File

@@ -100,7 +100,6 @@ struct ArticleWebView: UIViewRepresentable {
// Only reload if content meaningfully changed // Only reload if content meaningfully changed
guard context.coordinator.lastHTML != newHTML else { return } guard context.coordinator.lastHTML != newHTML else { return }
let isUpgrade = context.coordinator.lastHTML != nil let isUpgrade = context.coordinator.lastHTML != nil
context.coordinator.lastHTML = newHTML context.coordinator.lastHTML = newHTML

View File

@@ -13,6 +13,7 @@ struct EntryListView: View {
@State private var cumulativeDown: CGFloat = 0 @State private var cumulativeDown: CGFloat = 0
@State private var cumulativeUp: CGFloat = 0 @State private var cumulativeUp: CGFloat = 0
@State private var markedByScroll: Set<Int> = [] @State private var markedByScroll: Set<Int> = []
@State private var deferredReadIDs: Set<Int> = [] // IDs to mark read when auto-scroll stops
// Viewport height use the first connected scene's screen. // Viewport height use the first connected scene's screen.
private var viewportHeight: CGFloat { private var viewportHeight: CGFloat {
@@ -39,8 +40,10 @@ struct EntryListView: View {
} else { } else {
ScrollView { ScrollView {
// Auto-scroll engine zero-size, drives parent UIScrollView // Auto-scroll engine zero-size, drives parent UIScrollView
ScrollViewDriver(isScrolling: $isAutoScrolling, speed: scrollSpeed) ScrollViewDriver(isScrolling: $isAutoScrolling, speed: scrollSpeed) {
.frame(width: 0, height: 0) Task { await vm.loadMore() }
}
.frame(width: 0, height: 0)
if isCardView { if isCardView {
cardLayout cardLayout
@@ -59,7 +62,15 @@ struct EntryListView: View {
// Stop auto-scroll on navigation return // Stop auto-scroll on navigation return
isAutoScrolling = false isAutoScrolling = false
} }
.onChange(of: isAutoScrolling) { _, scrolling in
if !scrolling {
flushDeferredReads()
}
}
.refreshable { .refreshable {
// Flush any deferred read marks before refreshing
flushDeferredReads()
markedByScroll.removeAll()
await vm.refresh() await vm.refresh()
} }
.navigationDestination(for: ReaderEntry.self) { entry in .navigationDestination(for: ReaderEntry.self) { entry in
@@ -113,24 +124,52 @@ struct EntryListView: View {
markedByScroll.insert(entryId) markedByScroll.insert(entryId)
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) { if isAutoScrolling {
vm.entries[idx].status = "read" // Defer visual update contentSize changes cause jitter
} // during auto-scroll. Collect IDs, apply when scroll stops.
deferredReadIDs.insert(entryId)
Task { } else {
let api = ReaderAPI() // Manual scroll apply immediately
try? await api.markEntries(ids: [entryId], status: "read") if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) {
vm.counters = try? await api.getCounters() vm.entries[idx].status = "read"
}
Task {
let api = ReaderAPI()
try? await api.markEntries(ids: [entryId], status: "read")
vm.counters = try? await api.getCounters()
}
} }
} }
} }
) )
} }
// MARK: - Flush Deferred Reads
private func flushDeferredReads() {
guard !deferredReadIDs.isEmpty else { return }
let ids = Array(deferredReadIDs)
deferredReadIDs.removeAll()
// Apply all visual updates at once
for id in ids {
if let idx = vm.entries.firstIndex(where: { $0.id == id }) {
vm.entries[idx].status = "read"
}
}
// Single batched API call
Task {
let api = ReaderAPI()
try? await api.markEntries(ids: ids, status: "read")
vm.counters = try? await api.getCounters()
}
}
// MARK: - Card Layout // MARK: - Card Layout
private var cardLayout: some View { private var cardLayout: some View {
LazyVStack(spacing: 12) { LazyVStack(spacing: 14) {
ForEach(vm.entries) { entry in ForEach(vm.entries) { entry in
scrollTracked(entry, scrollTracked(entry,
content: NavigationLink(value: entry) { content: NavigationLink(value: entry) {
@@ -142,6 +181,7 @@ struct EntryListView: View {
entryContextMenu(entry: entry, vm: vm) entryContextMenu(entry: entry, vm: vm)
} }
) )
.onAppear { loadMoreIfNeeded(for: entry) }
} }
loadMoreTrigger loadMoreTrigger
@@ -149,7 +189,7 @@ struct EntryListView: View {
Spacer(minLength: 80) Spacer(minLength: 80)
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 4) .padding(.top, 8)
} }
// MARK: - List Layout // MARK: - List Layout
@@ -167,6 +207,7 @@ struct EntryListView: View {
entryContextMenu(entry: entry, vm: vm) entryContextMenu(entry: entry, vm: vm)
} }
) )
.onAppear { loadMoreIfNeeded(for: entry) }
Divider() Divider()
.padding(.leading, 36) .padding(.leading, 36)
@@ -178,14 +219,25 @@ struct EntryListView: View {
} }
} }
// Trigger loadMore when an entry near the bottom appears
private func loadMoreIfNeeded(for entry: ReaderEntry) {
let entries = vm.entries
guard entries.count >= 5 else { return }
let threshold = entries[entries.count - 5].id
if entry.id == threshold {
Task { await vm.loadMore() }
}
}
private var loadMoreTrigger: some View { private var loadMoreTrigger: some View {
Group { Group {
if vm.isLoadingMore { if vm.isLoadingMore {
ProgressView() ProgressView()
.padding() .padding()
} else { } else if vm.entries.count > 0 {
// Fallback trigger at the very bottom
Color.clear Color.clear
.frame(height: 1) .frame(height: 40)
.onAppear { .onAppear {
Task { await vm.loadMore() } Task { await vm.loadMore() }
} }
@@ -205,11 +257,14 @@ struct EntryCardView: View {
AsyncImage(url: thumbURL) { phase in AsyncImage(url: thumbURL) { phase in
switch phase { switch phase {
case .success(let image): case .success(let image):
image GeometryReader { geo in
.resizable() image
.aspectRatio(contentMode: .fill) .resizable()
.frame(height: 180) .aspectRatio(contentMode: .fill)
.clipped() .frame(width: geo.size.width, height: 180)
}
.frame(height: 180)
.clipped()
default: default:
Rectangle() Rectangle()
.fill(Color.surfaceCard) .fill(Color.surfaceCard)
@@ -270,7 +325,7 @@ struct EntryCardView: View {
} }
.background(Color.surfaceCard) .background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.04), radius: 6, y: 2) .shadow(color: .black.opacity(0.08), radius: 8, y: 3)
} }
} }

View File

@@ -4,192 +4,78 @@ struct ReaderTabView: View {
@Bindable var vm: ReaderViewModel @Bindable var vm: ReaderViewModel
@Binding var isAutoScrolling: Bool @Binding var isAutoScrolling: Bool
@Binding var scrollSpeed: Double @Binding var scrollSpeed: Double
@State private var selectedSubTab = 0
@State private var showFeedSheet = false @State private var showFeedSheet = false
@State private var showFeedManagement = false @State private var showFeedManagement = false
@State private var isCardView = true @State private var isCardView = true
private var subtitleText: String {
if let counters = vm.counters, counters.totalUnread > 0 {
return "\(counters.totalUnread) unread"
}
return "All caught up"
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { // Entry list as the main content scrolls under the glass nav bar
// Sub-tab selector EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
HStack(spacing: 0) { .background(Color.canvas)
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in .navigationTitle("Reader")
Button { .navigationSubtitle(subtitleText)
withAnimation(.easeInOut(duration: 0.2)) { .navigationBarTitleDisplayMode(.inline)
selectedSubTab = index .toolbar {
switch index { ToolbarItemGroup(placement: .topBarTrailing) {
case 0: vm.applyFilter(.unread) Button {
case 1: vm.applyFilter(.starred) withAnimation(.easeInOut(duration: 0.2)) {
case 2: vm.applyFilter(.all) isCardView.toggle()
default: break
} }
} label: {
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
} }
} label: {
HStack(spacing: 4) {
Text(tab)
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
if index == 0, let counters = vm.counters, counters.totalUnread > 0 { Menu {
Text("\(counters.totalUnread)") Button {
.font(.caption2.weight(.bold)) Task { await vm.markAllRead() }
.foregroundStyle(.white) } label: {
.padding(.horizontal, 6) Label("Mark All Read", systemImage: "checkmark.circle")
.padding(.vertical, 2)
.background(Color.accentWarm)
.clipShape(Capsule())
} }
}
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary) Button {
.padding(.vertical, 10) Task { await vm.refresh() }
.padding(.horizontal, 16) } label: {
.background { Label("Refresh Feeds", systemImage: "arrow.clockwise")
if selectedSubTab == index {
Capsule()
.fill(Color.accentWarm.opacity(0.12))
} }
Divider()
Button {
showFeedManagement = true
} label: {
Label("Manage Feeds", systemImage: "list.bullet")
}
Button {
showFeedSheet = true
} label: {
Label("Add Feed", systemImage: "plus")
}
} label: {
Image(systemName: "ellipsis")
} }
} }
}
Spacer()
// Inline controls: grid/list + more menu
HStack(spacing: 4) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCardView.toggle()
}
} label: {
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
.font(.subheadline)
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
Menu {
Button {
Task { await vm.markAllRead() }
} label: {
Label("Mark All Read", systemImage: "checkmark.circle")
}
Button {
Task { await vm.refresh() }
} label: {
Label("Refresh Feeds", systemImage: "arrow.clockwise")
}
Divider()
Button {
showFeedManagement = true
} label: {
Label("Manage Feeds", systemImage: "list.bullet")
}
Button {
showFeedSheet = true
} label: {
Label("Add Feed", systemImage: "plus")
}
} label: {
Image(systemName: "ellipsis")
.font(.subheadline)
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
}
.padding(.trailing, 8)
} }
.padding(.leading) .sheet(isPresented: $showFeedSheet) {
.padding(.top, 8) AddFeedSheet(vm: vm)
}
// Feed filter bar .sheet(isPresented: $showFeedManagement) {
ScrollView(.horizontal, showsIndicators: false) { FeedManagementSheet(vm: vm)
HStack(spacing: 8) {
feedFilterChip("All", isSelected: isAllSelected) {
let tab = selectedSubTab
switch tab {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: vm.applyFilter(.unread)
}
}
ForEach(vm.feeds) { feed in
let count = vm.counters?.count(forFeed: feed.id) ?? 0
feedFilterChip(
feed.title,
count: selectedSubTab == 0 ? count : nil,
isSelected: vm.currentFilter == .feed(feed.id)
) {
vm.applyFilter(.feed(feed.id))
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
} }
.frame(height: 44)
// Entry list
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
.navigationBarHidden(true)
.sheet(isPresented: $showFeedSheet) {
AddFeedSheet(vm: vm)
}
.sheet(isPresented: $showFeedManagement) {
FeedManagementSheet(vm: vm)
}
} }
.onAppear { .onAppear {
ArticleRenderer.shared.reWarmIfNeeded() ArticleRenderer.shared.reWarmIfNeeded()
} }
.onChange(of: selectedSubTab) { _, _ in
isAutoScrolling = false
}
.onChange(of: vm.currentFilter) { _, _ in .onChange(of: vm.currentFilter) { _, _ in
isAutoScrolling = false isAutoScrolling = false
} }
} }
// MARK: - Helpers
private var subTabs: [String] { ["Unread", "Starred", "All"] }
private var isAllSelected: Bool {
switch vm.currentFilter {
case .unread, .starred, .all: return true
default: return false
}
}
private func feedFilterChip(
_ title: String,
count: Int? = nil,
isSelected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 4) {
Text(title)
.font(.caption.weight(isSelected ? .semibold : .regular))
.lineLimit(1)
if let count, count > 0 {
Text("\(count)")
.font(.system(size: 10).weight(.bold))
}
}
.foregroundStyle(isSelected ? Color.accentWarm : Color.textSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.accentWarm.opacity(0.12) : Color.surfaceCard)
.clipShape(Capsule())
}
}
} }

View File

@@ -7,6 +7,7 @@ import UIKit
struct ScrollViewDriver: UIViewRepresentable { struct ScrollViewDriver: UIViewRepresentable {
@Binding var isScrolling: Bool @Binding var isScrolling: Bool
let speed: Double // 1.0 = 60pt/sec let speed: Double // 1.0 = 60pt/sec
var onNearBottom: (() -> Void)? = nil
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> UIView {
let view = DriverView() let view = DriverView()
@@ -21,6 +22,7 @@ struct ScrollViewDriver: UIViewRepresentable {
let coordinator = context.coordinator let coordinator = context.coordinator
coordinator.speed = speed coordinator.speed = speed
coordinator.isScrollingBinding = $isScrolling coordinator.isScrollingBinding = $isScrolling
coordinator.onNearBottom = onNearBottom
if isScrolling && coordinator.displayLink == nil { if isScrolling && coordinator.displayLink == nil {
coordinator.startScrolling(in: driver) coordinator.startScrolling(in: driver)
@@ -54,8 +56,10 @@ struct ScrollViewDriver: UIViewRepresentable {
var displayLink: CADisplayLink? var displayLink: CADisplayLink?
var speed: Double = 1.0 var speed: Double = 1.0
var isScrollingBinding: Binding<Bool>? var isScrollingBinding: Binding<Bool>?
var onNearBottom: (() -> Void)?
private var originalDelegate: UIScrollViewDelegate? private var originalDelegate: UIScrollViewDelegate?
private var delegateInstalled = false private var delegateInstalled = false
private var loadMoreTriggered = false
func findScrollView(from view: UIView) { func findScrollView(from view: UIView) {
var current: UIView? = view.superview var current: UIView? = view.superview
@@ -78,7 +82,6 @@ struct ScrollViewDriver: UIViewRepresentable {
} }
func startScrolling(in view: UIView) { func startScrolling(in view: UIView) {
// Re-find scroll view if needed
if scrollView == nil { if scrollView == nil {
findScrollView(from: view) findScrollView(from: view)
} }
@@ -104,19 +107,28 @@ struct ScrollViewDriver: UIViewRepresentable {
let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom
guard maxOffset > 0 else { return } guard maxOffset > 0 else { return }
// 60pt/sec at 1.0x speed, scaled by actual frame duration
let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp) let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp)
let newY = min(sv.contentOffset.y + delta, maxOffset) let newY = min(sv.contentOffset.y + delta, maxOffset)
sv.contentOffset.y = newY sv.contentOffset.y = newY
// Notify delegate so tab bar minimize behavior triggers
originalDelegate?.scrollViewDidScroll?(sv) originalDelegate?.scrollViewDidScroll?(sv)
// Stop at bottom // Trigger load more when within 500pt of bottom
if newY >= maxOffset - 1 { let distanceToBottom = maxOffset - newY
stopAndNotify() if distanceToBottom < 500 && !loadMoreTriggered {
loadMoreTriggered = true
DispatchQueue.main.async { [weak self] in
self?.onNearBottom?()
// Reset after a delay so it can trigger again for the next page
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self?.loadMoreTriggered = false
}
}
} }
// Don't stop at bottom contentSize may grow after loadMore.
// The tick keeps running; if no more content, it just idles at maxOffset.
} }
private func stopAndNotify() { private func stopAndNotify() {

View File

@@ -0,0 +1,19 @@
import Foundation
struct TripsAPI {
private let api = APIClient.shared
private let basePath = "/api/trips"
func getTrips() async throws -> [Trip] {
let response: TripsResponse = try await api.get("\(basePath)/trips")
return response.trips
}
func getTrip(id: String) async throws -> Trip {
try await api.get("\(basePath)/trip/\(id)")
}
func getTripDetail(id: String) async throws -> TripDetail {
try await api.get("\(basePath)/trip/\(id)")
}
}

View File

@@ -0,0 +1,130 @@
import Foundation
struct Trip: Codable, Identifiable, Hashable {
let id: String
let name: String
let description: String?
let startDate: String
let endDate: String
let coverImage: String?
let createdAt: String?
var imageURL: URL? {
guard let cover = coverImage, !cover.isEmpty else { return nil }
return URL(string: "\(Config.gatewayURL)/api/trips\(cover)")
}
var dateRange: String {
let start = Self.formatDisplay(startDate)
let end = Self.formatDisplay(endDate)
return "\(start) \(end)"
}
var tripLength: String {
guard let s = Self.parseDate(startDate),
let e = Self.parseDate(endDate) else { return "" }
let days = Calendar.current.dateComponents([.day], from: s, to: e).day ?? 0
return days == 1 ? "1 day" : "\(days) days"
}
var isUpcoming: Bool {
guard let start = Self.parseDate(startDate) else { return false }
return start > Date()
}
var isActive: Bool {
guard let start = Self.parseDate(startDate),
let end = Self.parseDate(endDate) else { return false }
let now = Date()
return start <= now && now <= end
}
var isPast: Bool {
guard let end = Self.parseDate(endDate) else { return false }
return end < Date()
}
var year: String {
String(startDate.prefix(4))
}
// MARK: - Date helpers
private static let apiFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
private static let displayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM d"
return f
}()
static func parseDate(_ str: String) -> Date? {
apiFormatter.date(from: str)
}
static func formatDisplay(_ str: String) -> String {
guard let date = parseDate(str) else { return str }
return displayFormatter.string(from: date)
}
}
struct TripsResponse: Codable {
let trips: [Trip]
}
// MARK: - Trip Detail (full response from /api/trip/{id})
struct TripDetail: Codable {
let id: String
let name: String
let startDate: String
let endDate: String
let transportations: [TripTransportation]
let lodging: [TripLodging]
let locations: [TripLocation]
let notes: [TripNote]
let aiSuggestions: String?
}
struct TripTransportation: Codable, Identifiable {
let id: String
let name: String
let type: String?
let flightNumber: String?
let fromLocation: String?
let toLocation: String?
let date: String?
let endDate: String?
let startTime: String?
let endTime: String?
}
struct TripLodging: Codable, Identifiable {
let id: String
let name: String
let location: String?
let checkIn: String?
let checkOut: String?
let reservationNumber: String?
}
struct TripLocation: Codable, Identifiable {
let id: String
let name: String
let category: String?
let visitDate: String?
let address: String?
let latitude: Double?
let longitude: Double?
let startTime: String?
}
struct TripNote: Codable, Identifiable {
let id: String
let title: String?
let content: String?
}

View File

@@ -0,0 +1,46 @@
import Foundation
@MainActor
@Observable
final class TripsViewModel {
var trips: [Trip] = []
var isLoading = true
var error: String?
private let api = TripsAPI()
private var hasLoaded = false
var upcomingTrips: [Trip] {
trips.filter { $0.isUpcoming || $0.isActive }
.sorted { $0.startDate < $1.startDate }
}
var pastTrips: [Trip] {
trips.filter { $0.isPast }
.sorted { $0.startDate > $1.startDate }
}
func loadTrips() async {
guard !hasLoaded else { return }
hasLoaded = true
isLoading = true
error = nil
do {
trips = try await api.getTrips()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func refresh() async {
isLoading = true
do {
trips = try await api.getTrips()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
/// Displays past trips in a horizontal scroll with compact cards.
/// Adapted from Apple's Wishlist TripCollectionView.
struct PastTripsSection: View {
let trips: [Trip]
var namespace: Namespace.ID
var body: some View {
if !trips.isEmpty {
Section {
ScrollView(.horizontal) {
HStack(spacing: 12) {
ForEach(trips) { trip in
NavigationLink {
TripDetailView(trip: trip)
.navigationTransition(
.zoom(sourceID: trip.id, in: namespace))
} label: {
TripCard(trip: trip, size: .compact)
.matchedTransitionSource(id: trip.id, in: namespace)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollClipDisabled()
.scrollIndicators(.hidden, axes: .horizontal)
.padding(.bottom, 30)
} header: {
VStack(alignment: .leading, spacing: 0) {
Text("Past Trips")
.font(.title3)
.fontWidth(.expanded)
Text("Your travel memories")
.font(.subheadline)
.foregroundColor(.secondary)
}
.lineLimit(2)
}
.padding(.horizontal, 16)
}
}
}

View File

@@ -0,0 +1,64 @@
import SwiftUI
/// A card view displaying a trip's photo and metadata.
/// Adapted from Apple's Wishlist TripCard supports compact and expanded sizes.
struct TripCard: View {
let trip: Trip
let size: Size
var body: some View {
VStack(alignment: .leading, spacing: 5) {
TripImageView(url: trip.imageURL, fallbackName: trip.name)
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipShape(.rect(cornerRadius: 16))
.overlay(alignment: .bottomLeading) {
if !trip.tripLength.isEmpty {
Text(trip.tripLength.uppercased())
.font(.footnote)
.fontWidth(.condensed)
.foregroundStyle(.secondary)
.padding(4)
.background(.regularMaterial, in: .rect(cornerRadius: 8))
.padding([.leading, .bottom], 8)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(trip.name)
.font(.body.weight(.medium))
Text(trip.dateRange)
.font(.subheadline)
.foregroundColor(.secondary)
}
.lineLimit(2)
}
.frame(width: size.width)
}
}
// MARK: - Card sizes
extension TripCard {
enum Size {
case compact
case expanded
}
}
private extension TripCard.Size {
var width: CGFloat {
switch self {
case .compact: 180
case .expanded: 325
}
}
var height: CGFloat {
switch self {
case .compact: 200
case .expanded: 260
}
}
}

View File

@@ -0,0 +1,521 @@
import SwiftUI
/// Polished Trip Detail with continuous timeline.
/// Hero image stats overlay day-by-day timeline with type-specific cards.
struct TripDetailView: View {
let trip: Trip
@State private var detail: TripDetail?
@State private var isLoading = true
@State private var appeared = false
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 0) {
// Hero
TripImageView(url: trip.imageURL, fallbackName: trip.name)
.scaledToFill()
.containerRelativeFrame(.horizontal)
.frame(height: 510)
.clipped()
.overlay(alignment: .bottomLeading) {
tripStatsOverlay
}
// Timeline
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
} else if let detail {
let timeline = buildTimeline(detail)
if timeline.isEmpty {
emptyState
} else {
timelineView(timeline)
}
}
}
}
.contentMargins(.bottom, 50, for: .scrollContent)
.scrollEdgeEffectStyle(.soft, for: .top)
.background(Color.canvas)
.toolbar {
ToolbarItem(placement: .largeTitle) {
HStack {
Text(trip.name)
.font(.headline)
.fontWeight(.medium)
.fontWidth(.expanded)
.fixedSize()
Spacer()
}
}
}
.ignoresSafeArea(edges: .top)
.toolbarTitleDisplayMode(.inline)
.navigationTitle(trip.name)
.toolbarRole(.editor)
.task {
do { detail = try await TripsAPI().getTripDetail(id: trip.id) } catch {}
isLoading = false
withAnimation(.easeOut(duration: 0.6).delay(0.1)) { appeared = true }
}
}
// MARK: - Timeline Builder
private func buildTimeline(_ detail: TripDetail) -> [TimelineDay] {
var items: [TimelineItem] = []
let tripStart = Trip.parseDate(trip.startDate)
for t in detail.transportations {
let date = t.date ?? ""
let time = t.startTime ?? ""
items.append(TimelineItem(
date: date, time: time, type: .transport,
title: t.name,
subtitle: [t.fromLocation, t.toLocation].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ""),
detail1: t.flightNumber,
detail2: t.type,
iconName: transportIcon(t.type),
iconColor: .blue
))
}
for l in detail.lodging {
let date = String((l.checkIn ?? "").prefix(10))
let time = l.checkIn?.contains("T") == true ? String(l.checkIn!.suffix(from: l.checkIn!.index(l.checkIn!.startIndex, offsetBy: 11))) : ""
let nights: String? = {
guard let ci = l.checkIn, let co = l.checkOut,
let d1 = Trip.parseDate(String(ci.prefix(10))),
let d2 = Trip.parseDate(String(co.prefix(10))) else { return nil }
let n = Calendar.current.dateComponents([.day], from: d1, to: d2).day ?? 0
return n > 0 ? "\(n) night\(n == 1 ? "" : "s")" : nil
}()
items.append(TimelineItem(
date: date, time: time, type: .lodging,
title: l.name,
subtitle: l.location ?? "",
detail1: nights,
detail2: l.checkOut != nil ? "Check-out: \(Trip.formatDisplay(String(l.checkOut!.prefix(10))))" : nil,
iconName: "bed.double.fill",
iconColor: Color.accentWarm
))
}
for loc in detail.locations {
let raw = loc.visitDate ?? ""
let date = String(raw.prefix(10))
let time: String = raw.contains("T") ? String(raw.suffix(from: raw.index(raw.startIndex, offsetBy: 11))) : (loc.startTime ?? "")
items.append(TimelineItem(
date: date, time: time, type: .place,
title: loc.name,
subtitle: loc.address ?? "",
detail1: loc.category,
detail2: nil,
iconName: "mappin.and.ellipse",
iconColor: .red
))
}
items.sort { a, b in
if a.date == b.date { return a.time < b.time }
return a.date < b.date
}
// Group by date
var days: [TimelineDay] = []
var currentDate = ""
var currentItems: [TimelineItem] = []
for item in items {
let d = item.date.isEmpty ? "No date" : item.date
if d != currentDate {
if !currentItems.isEmpty {
let dayNum = dayNumber(currentDate, from: tripStart)
days.append(TimelineDay(date: currentDate, dayNumber: dayNum, items: currentItems))
}
currentDate = d
currentItems = [item]
} else {
currentItems.append(item)
}
}
if !currentItems.isEmpty {
let dayNum = dayNumber(currentDate, from: tripStart)
days.append(TimelineDay(date: currentDate, dayNumber: dayNum, items: currentItems))
}
return days
}
// MARK: - Timeline View
private func timelineView(_ days: [TimelineDay]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(days.enumerated()), id: \.0) { dayIdx, day in
// Day header
HStack(spacing: 12) {
// Day number badge on the timeline
ZStack {
Circle()
.fill(Color.accentWarm)
.frame(width: 40, height: 40)
Text(day.dayNumber != nil ? "\(day.dayNumber!)" : "")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 1) {
if let num = day.dayNumber {
Text("Day \(num)")
.font(.caption.weight(.bold))
.fontWidth(.expanded)
.foregroundStyle(Color.accentWarm)
}
Text(formatDayHeader(day.date))
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, dayIdx == 0 ? 20 : 28)
.padding(.bottom, 12)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
.animation(.easeOut(duration: 0.4).delay(Double(dayIdx) * 0.05), value: appeared)
// Items
ForEach(Array(day.items.enumerated()), id: \.0) { idx, item in
HStack(alignment: .top, spacing: 0) {
// Timeline rail
VStack(spacing: 0) {
// Line above icon
if dayIdx > 0 || idx > 0 {
Rectangle()
.fill(Color.textTertiary.opacity(0.2))
.frame(width: 2, height: 12)
} else {
Spacer().frame(height: 12)
}
// Icon
Circle()
.fill(item.iconColor.opacity(0.15))
.frame(width: 28, height: 28)
.overlay {
Image(systemName: item.iconName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(item.iconColor)
}
// Line below icon
if idx < day.items.count - 1 || dayIdx < days.count - 1 {
Rectangle()
.fill(Color.textTertiary.opacity(0.2))
.frame(width: 2)
.frame(maxHeight: .infinity)
}
}
.frame(width: 40)
// Card
cardView(item)
.padding(.leading, 8)
.padding(.trailing, 16)
.padding(.bottom, 6)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 15)
.animation(.easeOut(duration: 0.4).delay(Double(dayIdx) * 0.05 + Double(idx) * 0.03), value: appeared)
}
.padding(.leading, 16)
}
}
}
}
// MARK: - Type-Specific Cards
@ViewBuilder
private func cardView(_ item: TimelineItem) -> some View {
switch item.type {
case .transport:
transportCard(item)
case .lodging:
lodgingCard(item)
case .place:
placeCard(item)
}
}
private func transportCard(_ item: TimelineItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
if !item.time.isEmpty {
Text(formatTime(item.time))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(item.title)
.font(.subheadline.weight(.semibold))
}
Spacer()
Image(systemName: item.iconName)
.font(.title3)
.foregroundStyle(.blue.opacity(0.6))
}
if !item.subtitle.isEmpty {
// Route bar
HStack(spacing: 8) {
let parts = item.subtitle.components(separatedBy: "")
if parts.count == 2 {
Text(parts[0])
.font(.caption.weight(.medium))
Image(systemName: "arrow.right")
.font(.system(size: 9))
.foregroundStyle(.tertiary)
Text(parts[1])
.font(.caption.weight(.medium))
} else {
Text(item.subtitle)
.font(.caption)
}
}
.foregroundStyle(.secondary)
}
if let flight = item.detail1, !flight.isEmpty {
Text(flight)
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.08))
.clipShape(Capsule())
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
private func lodgingCard(_ item: TimelineItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
if !item.time.isEmpty {
Text(formatTime(item.time))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(item.title)
.font(.subheadline.weight(.semibold))
}
Spacer()
if let nights = item.detail1, !nights.isEmpty {
Text(nights)
.font(.caption2.weight(.bold))
.foregroundStyle(Color.accentWarm)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.accentWarm.opacity(0.1))
.clipShape(Capsule())
}
}
if !item.subtitle.isEmpty {
Label(item.subtitle, systemImage: "location")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let checkout = item.detail2, !checkout.isEmpty {
Text(checkout)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentWarm.opacity(0.04))
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
private func placeCard(_ item: TimelineItem) -> some View {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 3) {
Text(item.title)
.font(.subheadline.weight(.medium))
.lineLimit(2)
if !item.time.isEmpty {
Text(formatTime(item.time))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
if let category = item.detail1, !category.isEmpty {
Text(category.uppercased())
.font(.system(size: 9, weight: .bold))
.fontWidth(.condensed)
.foregroundStyle(Color.red.opacity(0.7))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.red.opacity(0.08))
.clipShape(Capsule())
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
// MARK: - Stats Overlay
private var tripStatsOverlay: some View {
VStack(alignment: .leading, spacing: 4) {
Text(trip.dateRange)
.font(.title3)
.fontWidth(.expanded)
.fontWeight(.regular)
HStack(spacing: 16) {
if !trip.tripLength.isEmpty {
Label(trip.tripLength, systemImage: "calendar")
.font(.footnote)
.fontWidth(.condensed)
}
if let d = detail {
if !d.lodging.isEmpty {
Label("\(d.lodging.count) stays", systemImage: "bed.double")
.font(.footnote)
.fontWidth(.condensed)
}
if !d.transportations.isEmpty {
Label("\(d.transportations.count) flights", systemImage: "airplane")
.font(.footnote)
.fontWidth(.condensed)
}
if !d.locations.isEmpty {
Label("\(d.locations.count) places", systemImage: "mappin")
.font(.footnote)
.fontWidth(.condensed)
}
}
}
.foregroundStyle(.secondary)
}
.padding()
.background {
GradientOverlay(style: .ultraThinMaterial)
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "map.fill")
.font(.system(size: 40))
.foregroundStyle(Color.accentWarm.opacity(0.3))
Text("No details yet")
.font(.headline)
.foregroundStyle(Color.textSecondary)
Text("Add lodging, flights, and places on the web")
.font(.subheadline)
.foregroundStyle(Color.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// MARK: - Helpers
private func formatDayHeader(_ date: String) -> String {
guard let d = Trip.parseDate(date) else { return date }
let f = DateFormatter()
f.dateFormat = "EEEE, MMM d"
return f.string(from: d)
}
private func formatTime(_ time: String) -> String {
let clean = time.prefix(5)
guard clean.count == 5, let hour = Int(clean.prefix(2)), let min = Int(clean.suffix(2)) else {
return String(time.prefix(5))
}
let ampm = hour >= 12 ? "PM" : "AM"
let h = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour)
return String(format: "%d:%02d %@", h, min, ampm)
}
private func dayNumber(_ dateStr: String, from start: Date?) -> Int? {
guard let start, let date = Trip.parseDate(dateStr) else { return nil }
let days = Calendar.current.dateComponents([.day], from: start, to: date).day ?? 0
return days + 1
}
private func transportIcon(_ type: String?) -> String {
switch type {
case "plane": return "airplane"
case "train": return "tram.fill"
case "car", "rental": return "car.fill"
case "bus": return "bus.fill"
case "ferry": return "ferry.fill"
default: return "arrow.triangle.swap"
}
}
}
// MARK: - Data Models
private struct TimelineDay {
let date: String
let dayNumber: Int?
let items: [TimelineItem]
}
private struct TimelineItem {
let date: String
let time: String
let type: ItemType
let title: String
let subtitle: String
let detail1: String?
let detail2: String?
let iconName: String
let iconColor: Color
enum ItemType {
case transport, lodging, place
}
}
// MARK: - Gradient Overlay
struct GradientOverlay<Style: ShapeStyle>: View {
let style: Style
var body: some View {
Rectangle()
.fill(style)
.mask {
MeshGradient(width: 2, height: 2, points: [
SIMD2<Float>(0.0, 0.0), SIMD2<Float>(1.0, 0.0),
SIMD2<Float>(0.0, 1.0), SIMD2<Float>(1.0, 1.0)
], colors: [
.clear, .clear,
.black, .clear
])
}
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
/// Displays a trip image from a URL, with gradient placeholder for missing images.
/// Adapted from Apple's Wishlist TripImageView pattern.
struct TripImageView: View {
let url: URL?
var fallbackName: String = ""
var body: some View {
if let url {
AsyncImage(url: url) { phase in
if let image = phase.image {
// Rectangle overlay pattern from Wishlist constrains AsyncImage properly
Rectangle()
.fill(.background)
.overlay {
image.resizable()
.scaledToFill()
}
.clipped()
} else if phase.error != nil {
placeholderGradient
} else {
Rectangle()
.fill(Color.surfaceCard)
.overlay {
ProgressView()
.progressViewStyle(.circular)
}
}
}
} else {
placeholderGradient
}
}
private var placeholderGradient: some View {
ZStack {
LinearGradient(
colors: [
Color.accentWarm.opacity(0.6),
Color.accentWarm.opacity(0.3),
Color.emerald.opacity(0.4)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
if !fallbackName.isEmpty {
Text(fallbackName)
.font(.title3.weight(.semibold))
.fontWidth(.expanded)
.foregroundStyle(.white)
.shadow(radius: 4)
}
}
}
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
/// Placeholder trip detail Phase 1 only.
/// Will be replaced with full itinerary/reservations/notes in Phase 2.
struct TripPlaceholderView: View {
let trip: Trip
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Hero image
TripImageView(url: trip.imageURL, fallbackName: trip.name)
.frame(height: 260)
.clipShape(.rect(cornerRadius: 20))
.padding(.horizontal)
// Trip info
VStack(spacing: 12) {
Text(trip.name)
.font(.largeTitle.weight(.bold))
.fontWidth(.expanded)
Text(trip.dateRange)
.font(.title3)
.foregroundStyle(.secondary)
if !trip.tripLength.isEmpty {
Text(trip.tripLength)
.font(.subheadline)
.foregroundStyle(.tertiary)
}
}
// Coming soon
VStack(spacing: 16) {
Image(systemName: "map.fill")
.font(.system(size: 40))
.foregroundStyle(Color.accentWarm.opacity(0.3))
Text("Trip details coming soon")
.font(.headline)
.foregroundStyle(Color.textSecondary)
Text("Itinerary, reservations, notes, and more")
.font(.subheadline)
.foregroundStyle(Color.textTertiary)
}
.padding(.top, 40)
}
.padding(.top, 20)
}
.background(Color.canvas)
.navigationTitle(trip.name)
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
/// The main Trips home screen inspired by Apple's Wishlist sample.
///
/// ViewModel owned by MainTabView (stable across navigation transitions).
/// Always renders the ScrollView no conditional loading/content flip
/// that would destroy the namespace during transitions.
struct TripsHomeView: View {
@Bindable var vm: TripsViewModel
@Namespace private var namespace
var body: some View {
NavigationStack {
ScrollView {
if vm.isLoading && vm.trips.isEmpty {
// Loading inside the ScrollView keeps view tree stable
VStack {
Spacer(minLength: 200)
ProgressView()
.controlSize(.regular)
Text("Loading trips...")
.font(.caption)
.foregroundStyle(Color.textTertiary)
.padding(.top, 8)
Spacer(minLength: 200)
}
.frame(maxWidth: .infinity)
} else {
VStack(alignment: .leading, spacing: 10) {
UpcomingTripsPageView(trips: vm.upcomingTrips, namespace: namespace)
.padding(.bottom, 20)
PastTripsSection(trips: vm.pastTrips, namespace: namespace)
}
}
}
.contentMargins(.bottom, 30, for: .scrollContent)
.ignoresSafeArea(edges: .top)
.refreshable {
await vm.refresh()
}
.background(Color.canvas)
.toolbar {
ToolbarItem(placement: .title) {
HStack {
Text("Trips")
.font(.system(size: 34, weight: .medium))
.fontWidth(.expanded)
.bold()
.fixedSize()
Spacer()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
// Phase 2: present plan trip sheet
} label: {
Image(systemName: "plus")
}
.buttonStyle(.glassProminent)
.tint(.accentColor)
}
}
.navigationTitle("Trips")
.toolbarTitleDisplayMode(.inline)
.toolbarRole(.editor)
}
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
/// Displays upcoming trips in a paged TabView the hero section.
/// Adapted from Apple's Wishlist RecentTripsPageView.
///
/// Namespace is passed from the parent (TripsHomeView) to ensure
/// stability across navigation transitions.
struct UpcomingTripsPageView: View {
let trips: [Trip]
var namespace: Namespace.ID
var body: some View {
if trips.isEmpty {
VStack(spacing: 16) {
Image(systemName: "airplane.departure")
.font(.system(size: 44))
.foregroundStyle(Color.accentWarm.opacity(0.4))
Text("No upcoming trips")
.font(.title3.weight(.medium))
.foregroundStyle(Color.textPrimary)
Text("Tap + to plan your next adventure")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
} else {
TabView {
ForEach(trips) { trip in
NavigationLink {
TripDetailView(trip: trip)
.navigationTransition(
.zoom(sourceID: trip.id, in: namespace))
} label: {
TripImageView(url: trip.imageURL, fallbackName: trip.name)
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 4) {
Text("UPCOMING")
.font(.subheadline)
.fontWeight(.bold)
.foregroundStyle(Color.emerald)
Text(trip.name)
.font(.title)
.fontWidth(.expanded)
.fontWeight(.medium)
.foregroundStyle(.primary)
Text(trip.dateRange)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.bottom, 54)
}
.matchedTransitionSource(id: trip.id, in: namespace)
}
.buttonStyle(.plain)
}
}
.tabViewStyle(.page)
.containerRelativeFrame([.horizontal, .vertical]) { length, axis in
if axis == .vertical {
return length / 1.3
} else {
return length
}
}
}
}
}

View File

@@ -6,5 +6,7 @@
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>NSCameraUsageDescription</key>
<string>Take a photo of your meal to log it with AI</string>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.quadjourney.platform</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,39 @@
import SwiftUI
import UIKit
/// UIImagePickerController wrapper for taking photos with the camera.
struct CameraView: UIViewControllerRepresentable {
let onCapture: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onCapture: onCapture)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onCapture: (UIImage) -> Void
init(onCapture: @escaping (UIImage) -> Void) {
self.onCapture = onCapture
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage {
onCapture(image)
}
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
}

View File

@@ -7,7 +7,9 @@ struct MacroBar: View {
var color: Color = .emerald var color: Color = .emerald
var unit: String = "g" var unit: String = "g"
private var progress: Double { @State private var animatedProgress: Double = 0
private var targetProgress: Double {
guard goal > 0 else { return 0 } guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1.0) return min(max(consumed / goal, 0), 1.0)
} }
@@ -22,6 +24,7 @@ struct MacroBar: View {
Text("\(Int(consumed))/\(Int(goal))\(unit)") Text("\(Int(consumed))/\(Int(goal))\(unit)")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(Color.textPrimary) .foregroundStyle(Color.textPrimary)
.contentTransition(.numericText())
} }
GeometryReader { geo in GeometryReader { geo in
@@ -32,11 +35,26 @@ struct MacroBar: View {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(color) .fill(color)
.frame(width: geo.size.width * progress, height: 8) .frame(width: geo.size.width * animatedProgress, height: 8)
.animation(.easeInOut(duration: 0.5), value: progress)
} }
} }
.frame(height: 8) .frame(height: 8)
} }
.onAppear {
animatedProgress = 0
withAnimation(.spring(response: 0.8, dampingFraction: 0.7).delay(0.3)) {
animatedProgress = targetProgress
}
}
.onChange(of: consumed) { _, _ in
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
.onChange(of: goal) { _, _ in
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
} }
} }

View File

@@ -7,7 +7,9 @@ struct MacroRing: View {
var color: Color = .emerald var color: Color = .emerald
var size: CGFloat = 100 var size: CGFloat = 100
private var progress: Double { @State private var animatedProgress: Double = 0
private var targetProgress: Double {
guard goal > 0 else { return 0 } guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1.0) return min(max(consumed / goal, 0), 1.0)
} }
@@ -18,15 +20,30 @@ struct MacroRing: View {
.stroke(color.opacity(0.15), lineWidth: lineWidth) .stroke(color.opacity(0.15), lineWidth: lineWidth)
Circle() Circle()
.trim(from: 0, to: progress) .trim(from: 0, to: animatedProgress)
.stroke( .stroke(
color, color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.6), value: progress)
} }
.frame(width: size, height: size) .frame(width: size, height: size)
.onAppear {
animatedProgress = 0
withAnimation(.spring(response: 1.0, dampingFraction: 0.7).delay(0.2)) {
animatedProgress = targetProgress
}
}
.onChange(of: consumed) { _, _ in
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
.onChange(of: goal) { _, _ in
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
animatedProgress = targetProgress
}
}
} }
} }
@@ -38,6 +55,8 @@ struct MacroRingWithLabel: View {
var size: CGFloat = 100 var size: CGFloat = 100
var lineWidth: CGFloat = 10 var lineWidth: CGFloat = 10
@State private var showValue = false
var body: some View { var body: some View {
ZStack { ZStack {
MacroRing( MacroRing(
@@ -52,10 +71,19 @@ struct MacroRingWithLabel: View {
Text("\(Int(consumed))") Text("\(Int(consumed))")
.font(.system(size: size * 0.22, weight: .bold, design: .rounded)) .font(.system(size: size * 0.22, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary) .foregroundStyle(Color.textPrimary)
.contentTransition(.numericText())
Text(label) Text(label)
.font(.system(size: size * 0.1, weight: .medium)) .font(.system(size: size * 0.1, weight: .medium))
.foregroundStyle(Color.textSecondary) .foregroundStyle(Color.textSecondary)
} }
.opacity(showValue ? 1 : 0)
.scaleEffect(showValue ? 1 : 0.5)
}
.onAppear {
showValue = false
withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.4)) {
showValue = true
}
} }
} }
} }

View File

@@ -4,45 +4,45 @@ extension Color {
// MARK: - Canvas / Background (adaptive light/dark) // MARK: - Canvas / Background (adaptive light/dark)
static let canvas = Color(UIColor { traits in static let canvas = Color(UIColor { traits in
traits.userInterfaceStyle == .dark traits.userInterfaceStyle == .dark
? UIColor(red: 0.10, green: 0.09, blue: 0.08, alpha: 1) // warm dark ? UIColor(red: 0.05, green: 0.05, blue: 0.045, alpha: 1) // #0d0d0b near-black, less warm
: UIColor(red: 0.96, green: 0.94, blue: 0.90, alpha: 1) // #F5EFE6 : UIColor(red: 0.92, green: 0.90, blue: 0.87, alpha: 1) // #EBE6DE neutral sand
}) })
// MARK: - Accent // MARK: - Accent
static let accentWarm = Color(UIColor { traits in static let accentWarm = Color(UIColor { traits in
traits.userInterfaceStyle == .dark traits.userInterfaceStyle == .dark
? UIColor(red: 0.78, green: 0.62, blue: 0.25, alpha: 1) // brighter gold for dark ? UIColor(red: 0.82, green: 0.65, blue: 0.28, alpha: 1) // brighter gold for dark
: UIColor(red: 0.545, green: 0.412, blue: 0.078, alpha: 1) // #8B6914 : UIColor(red: 0.50, green: 0.37, blue: 0.06, alpha: 1) // #805E0F slightly deeper
}) })
static let emerald = Color(red: 0.020, green: 0.588, blue: 0.412) // #059669 static let emerald = Color(red: 0.020, green: 0.588, blue: 0.412) // #059669
// MARK: - Surfaces (adaptive) // MARK: - Surfaces (adaptive)
static let surfaceCard = Color(UIColor { traits in static let surfaceCard = Color(UIColor { traits in
traits.userInterfaceStyle == .dark traits.userInterfaceStyle == .dark
? UIColor(red: 0.15, green: 0.14, blue: 0.13, alpha: 1) // warm dark card ? UIColor(red: 0.11, green: 0.105, blue: 0.09, alpha: 1) // #1c1b17 warm dark card
: UIColor(red: 1.0, green: 0.988, blue: 0.973, alpha: 1) // warm white : UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1) // #FFFFFF clean white
}) })
static let surfaceSheet = Color(UIColor { traits in static let surfaceSheet = Color(UIColor { traits in
traits.userInterfaceStyle == .dark traits.userInterfaceStyle == .dark
? UIColor(red: 0.13, green: 0.12, blue: 0.11, alpha: 1) ? UIColor(red: 0.12, green: 0.115, blue: 0.10, alpha: 1)
: UIColor(red: 0.98, green: 0.97, blue: 0.95, alpha: 1) : UIColor(red: 0.97, green: 0.96, blue: 0.94, alpha: 1)
}) })
// MARK: - Text (adaptive) // MARK: - Text (adaptive slightly more neutral for glass legibility)
static let textPrimary = Color(UIColor { traits in static let textPrimary = Color(UIColor { traits in
traits.userInterfaceStyle == .dark traits.userInterfaceStyle == .dark
? UIColor(red: 0.93, green: 0.91, blue: 0.88, alpha: 1) ? UIColor(red: 0.94, green: 0.92, blue: 0.90, alpha: 1)
: UIColor(red: 0.12, green: 0.12, blue: 0.12, alpha: 1) : UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1) // darker for contrast
}) })
static let textSecondary = Color(UIColor { traits in static let textSecondary = Color(UIColor { traits in
traits.userInterfaceStyle == .dark traits.userInterfaceStyle == .dark
? UIColor(red: 0.62, green: 0.60, blue: 0.57, alpha: 1) ? UIColor(red: 0.60, green: 0.58, blue: 0.55, alpha: 1)
: UIColor(red: 0.45, green: 0.45, blue: 0.45, alpha: 1) : UIColor(red: 0.40, green: 0.40, blue: 0.40, alpha: 1) // more neutral gray
}) })
static let textTertiary = Color(UIColor { traits in static let textTertiary = Color(UIColor { traits in
traits.userInterfaceStyle == .dark traits.userInterfaceStyle == .dark
? UIColor(red: 0.45, green: 0.43, blue: 0.40, alpha: 1) ? UIColor(red: 0.43, green: 0.42, blue: 0.40, alpha: 1)
: UIColor(red: 0.65, green: 0.65, blue: 0.65, alpha: 1) : UIColor(red: 0.58, green: 0.58, blue: 0.58, alpha: 1) // more neutral
}) })
// MARK: - Meal Colors // MARK: - Meal Colors

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,329 @@
import WidgetKit
import SwiftUI
// MARK: - App Group shared storage
//
// Auth flow:
// 1. Main app logs in gets session cookie from gateway
// 2. Main app stores cookie value in App Group UserDefaults
// 3. Widget reads cookie from App Group UserDefaults
// 4. Widget makes API calls with that cookie
//
// Data stored in App Group:
// - "widget_sessionCookie": String (the session=xxx cookie value)
// - "widget_totalCalories": Double (fallback cache)
// - "widget_calorieGoal": Double (fallback cache)
// - "widget_lastUpdate": Date (when cache was last written)
private let appGroup = "group.com.quadjourney.platform"
private let gatewayURL = "https://dash.quadjourney.com"
private var sharedDefaults: UserDefaults {
UserDefaults(suiteName: appGroup) ?? .standard
}
// MARK: - Timeline Entry
struct CalorieEntry: TimelineEntry {
let date: Date
let totalCalories: Double
let calorieGoal: Double
var progress: Double {
guard calorieGoal > 0 else { return 0 }
return min(max(totalCalories / calorieGoal, 0), 1.0)
}
var remaining: Double {
max(calorieGoal - totalCalories, 0)
}
static let placeholder = CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
}
// MARK: - Timeline Provider
struct CalorieProvider: TimelineProvider {
func placeholder(in context: Context) -> CalorieEntry {
.placeholder
}
func getSnapshot(in context: Context, completion: @escaping (CalorieEntry) -> Void) {
if context.isPreview {
completion(.placeholder)
return
}
// Return cached data for snapshot
completion(readCachedEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CalorieEntry>) -> Void) {
Task {
let entry: CalorieEntry
// Try fetching fresh data from API
if let fresh = await fetchFromAPI() {
entry = fresh
// Cache for fallback
sharedDefaults.set(fresh.totalCalories, forKey: "widget_totalCalories")
sharedDefaults.set(fresh.calorieGoal, forKey: "widget_calorieGoal")
sharedDefaults.set(Date(), forKey: "widget_lastUpdate")
} else {
// Network failed use cached data
entry = readCachedEntry()
}
// Refresh every 15 minutes (WidgetKit may throttle to ~every hour)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
// MARK: - API Fetch
private func fetchFromAPI() async -> CalorieEntry? {
guard let cookie = sharedDefaults.string(forKey: "widget_sessionCookie"),
!cookie.isEmpty else {
return nil // No auth user hasn't logged in yet
}
let today = formatDate(.now)
async let totalsData = apiGet("/api/fitness/entries/totals?date=\(today)", cookie: cookie)
async let goalData = apiGet("/api/fitness/goals/for-date?date=\(today)", cookie: cookie)
guard let totals = await totalsData,
let goal = await goalData else {
return nil
}
let calories = totals["total_calories"] as? Double
?? (totals["total_calories"] as? Int).map(Double.init)
?? 0
let goalCalories = goal["calories"] as? Double
?? (goal["calories"] as? Int).map(Double.init)
?? 2000
return CalorieEntry(date: .now, totalCalories: calories, calorieGoal: goalCalories)
}
private func apiGet(_ path: String, cookie: String) async -> [String: Any]? {
guard let url = URL(string: gatewayURL + path) else { return nil }
var request = URLRequest(url: url)
request.setValue("session=\(cookie)", forHTTPHeaderField: "Cookie")
request.timeoutInterval = 10
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { return nil }
// Session expired clear stale cookie so we don't retry
if http.statusCode == 401 {
sharedDefaults.removeObject(forKey: "widget_sessionCookie")
return nil
}
guard (200...299).contains(http.statusCode) else { return nil }
return try JSONSerialization.jsonObject(with: data) as? [String: Any]
} catch {
return nil
}
}
private func readCachedEntry() -> CalorieEntry {
let calories = sharedDefaults.double(forKey: "widget_totalCalories")
let goal = sharedDefaults.double(forKey: "widget_calorieGoal")
return CalorieEntry(
date: .now,
totalCalories: calories,
calorieGoal: goal > 0 ? goal : 2000
)
}
private func formatDate(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.string(from: date)
}
}
// MARK: - Widget Views
struct CalorieRingView: View {
let entry: CalorieEntry
let size: CGFloat
let lineWidth: CGFloat
private let ringColor = Color(red: 0.020, green: 0.588, blue: 0.412)
var body: some View {
ZStack {
Circle()
.stroke(ringColor.opacity(0.2), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: entry.progress)
.stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 1) {
Text("\(Int(entry.totalCalories))")
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
Text("/ \(Int(entry.calorieGoal))")
.font(.system(size: size * 0.1, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
}
}
.frame(width: size, height: size)
}
}
struct SmallWidgetView: View {
let entry: CalorieEntry
var body: some View {
ZStack(alignment: .bottomTrailing) {
VStack(spacing: 8) {
CalorieRingView(entry: entry, size: 80, lineWidth: 7)
Text("\(Int(entry.remaining)) left")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// + button separate tap target opens food assistant
Link(destination: URL(string: "platform://add-food")!) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color(red: 0.020, green: 0.588, blue: 0.412))
}
}
}
}
struct MediumWidgetView: View {
let entry: CalorieEntry
var body: some View {
ZStack(alignment: .bottomTrailing) {
HStack(spacing: 20) {
CalorieRingView(entry: entry, size: 100, lineWidth: 9)
VStack(alignment: .leading, spacing: 6) {
Text("Calories")
.font(.headline)
.foregroundStyle(.primary)
Text("\(Int(entry.totalCalories)) of \(Int(entry.calorieGoal))")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("\(Int(entry.remaining)) remaining")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Link(destination: URL(string: "platform://add-food")!) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color(red: 0.020, green: 0.588, blue: 0.412))
}
}
}
}
struct CircularWidgetView: View {
let entry: CalorieEntry
var body: some View {
Gauge(value: entry.progress) {
Text("\(Int(entry.totalCalories))")
.font(.system(size: 12, weight: .bold, design: .rounded))
}
.gaugeStyle(.accessoryCircular)
.tint(Color(red: 0.020, green: 0.588, blue: 0.412))
}
}
struct InlineWidgetView: View {
let entry: CalorieEntry
var body: some View {
Text("\(Int(entry.totalCalories)) / \(Int(entry.calorieGoal)) cal")
}
}
struct RectangularWidgetView: View {
let entry: CalorieEntry
var body: some View {
HStack(spacing: 8) {
Gauge(value: entry.progress) {
EmptyView()
}
.gaugeStyle(.accessoryLinear)
.tint(Color(red: 0.020, green: 0.588, blue: 0.412))
Text("\(Int(entry.totalCalories)) cal")
.font(.system(size: 13, weight: .bold, design: .rounded))
}
}
}
// MARK: - Entry View (family-aware)
struct PlatformWidgetEntryView: View {
@Environment(\.widgetFamily) var family
let entry: CalorieEntry
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .accessoryCircular:
CircularWidgetView(entry: entry)
case .accessoryInline:
InlineWidgetView(entry: entry)
case .accessoryRectangular:
RectangularWidgetView(entry: entry)
default:
SmallWidgetView(entry: entry)
}
}
}
// MARK: - Widget Configuration
struct PlatformWidget: Widget {
let kind: String = "PlatformWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalorieProvider()) { entry in
PlatformWidgetEntryView(entry: entry)
.widgetURL(URL(string: "platform://fitness"))
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Calories")
.description("Today's calorie progress ring.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.accessoryCircular,
.accessoryInline,
.accessoryRectangular,
])
}
}
#Preview(as: .systemSmall) {
PlatformWidget()
} timeline: {
CalorieEntry(date: .now, totalCalories: 845, calorieGoal: 2000)
}

View File

@@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct PlatformWidgetBundle: WidgetBundle {
var body: some Widget {
PlatformWidget()
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.quadjourney.platform</string>
</array>
</dict>
</plist>