Root-cause investigation identified 5 architectural issues. This commit
fixes all of them with structural changes, not patches.
## 1. Persistent ArticleRenderer (fixes first-article freeze)
BEFORE: Every article tap created a new WKWebView with a new
WKWebViewConfiguration and a new WKProcessPool. Each spawned a
WebContent process (~3s). The "warmer" used a different config,
warming a different process — useless.
AFTER: Single ArticleRenderer singleton owns one WKWebView with
one shared WKProcessPool + WKWebViewConfiguration. Created at app
launch via `_ = ArticleRenderer.shared` in ContentView.task.
ArticleWebView wraps the shared WKWebView in a container UIView.
SwiftUI owns the container lifecycle, not the WKWebView's.
Zero process launches after first warm-up.
## 2. Stable Reader layout (fixes tab jitter)
BEFORE: Sub-tabs and feed chips were conditionally rendered
(`if !vm.isLoading || !vm.entries.isEmpty`). When loading finished,
~80px of UI appeared suddenly, causing layout shift that rippled
to the tab bar.
AFTER: Sub-tabs and feed chip bar ALWAYS render. Feed chip bar has
fixed height (44px). No conditional wrappers in the layout hierarchy.
Content area shows LoadingView during fetch. Chrome never changes shape.
## 3. Local-first state updates (fixes mark-read lag)
BEFORE: markAsRead made 3 sequential API calls (mark, re-fetch entry,
re-fetch counters). toggleRead and toggleStar did the same. Each
action had 3 network round-trips before UI updated.
AFTER: Mutate local entries array immediately (status/starred are
now var). API sync happens in background via Task.detached. UI updates
instantly. Counter refresh happens async.
## 4. Atomic list replacement (fixes empty flash)
BEFORE: loadEntries(reset:true) set `entries = []` then
`entries = newList`. Two mutations = empty state flash + full
LazyVStack teardown/rebuild.
AFTER: Never clear entries. Fetch completes, then single atomic
`entries = newList`. SwiftUI diffs by Identifiable.id — only
changed rows update.
## 5. Reserved thumbnail space (fixes card layout jump)
BEFORE: AsyncImage default case was EmptyView() (0px). When image
loaded, 180px appeared. Cards jumped.
AFTER: Default case renders a placeholder Rectangle at 180px.
Card height is stable from first render.
## Additional: Pre-load moved off TabView
`.task { await readerVM.loadInitial() }` moved from TabView
(caused observable mutations during TabView body evaluation,
contributing to tab bar jitter) to the outer ZStack.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
5.4 KiB
Swift
171 lines
5.4 KiB
Swift
import SwiftUI
|
|
import ConfettiSwiftUI
|
|
|
|
struct ContentView: View {
|
|
@Environment(AuthManager.self) private var auth
|
|
|
|
var body: some View {
|
|
Group {
|
|
if auth.isCheckingAuth {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.canvas)
|
|
} else if auth.isLoggedIn {
|
|
MainTabView()
|
|
} else {
|
|
LoginView()
|
|
}
|
|
}
|
|
.task {
|
|
await auth.checkAuth()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainTabView: View {
|
|
@State private var selectedTab = 0
|
|
@State private var showAssistant = false
|
|
@State private var confettiTrigger = 0
|
|
@State private var readerVM = ReaderViewModel()
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
TabView(selection: $selectedTab) {
|
|
HomeView(selectedTab: $selectedTab)
|
|
.tabItem { Label("Home", systemImage: "house.fill") }
|
|
.tag(0)
|
|
|
|
FitnessTabView()
|
|
.tabItem { Label("Fitness", systemImage: "flame.fill") }
|
|
.tag(1)
|
|
|
|
ReaderTabView(vm: readerVM)
|
|
.tabItem { Label("Reader", systemImage: "newspaper.fill") }
|
|
.tag(2)
|
|
}
|
|
.tint(Color.accentWarm)
|
|
.modifier(TabBarMinimizeModifier())
|
|
|
|
// Floating buttons
|
|
VStack {
|
|
Spacer()
|
|
HStack(alignment: .bottom) {
|
|
// Feedback button (subtle, bottom-left)
|
|
FeedbackButton()
|
|
.padding(.leading, 20)
|
|
|
|
Spacer()
|
|
|
|
// Add food button (prominent, bottom-right)
|
|
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)
|
|
}
|
|
}
|
|
.confettiCannon(
|
|
trigger: $confettiTrigger,
|
|
num: 80,
|
|
confettis: [.shape(.circle), .shape(.roundedCross), .shape(.slimRectangle)],
|
|
colors: [.red, .orange, .yellow, .green, .blue, .purple, .pink],
|
|
confettiSize: 8,
|
|
rainHeight: 600,
|
|
openingAngle: Angle.degrees(40),
|
|
closingAngle: Angle.degrees(140),
|
|
radius: 300
|
|
)
|
|
.sheet(isPresented: $showAssistant) {
|
|
AssistantSheetView(onFoodAdded: foodAdded)
|
|
}
|
|
.task {
|
|
// Pre-warm WebKit process pool + pre-fetch reader data
|
|
_ = ArticleRenderer.shared
|
|
await readerVM.loadInitial()
|
|
}
|
|
}
|
|
|
|
private func foodAdded() {
|
|
showAssistant = false
|
|
selectedTab = 1
|
|
confettiTrigger += 1
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
}
|
|
}
|
|
|
|
// MARK: - Assistant Sheet
|
|
|
|
struct AssistantSheetView: View {
|
|
@State private var selectedMode = 0
|
|
var onFoodAdded: () -> Void = {}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(spacing: 12) {
|
|
Capsule()
|
|
.fill(Color.textTertiary.opacity(0.3))
|
|
.frame(width: 36, height: 5)
|
|
.padding(.top, 8)
|
|
|
|
Text("Add Food")
|
|
.font(.headline)
|
|
.foregroundStyle(Color.textPrimary)
|
|
|
|
HStack(spacing: 4) {
|
|
tabButton("AI Chat", icon: "sparkles", index: 0)
|
|
tabButton("Quick Add", icon: "magnifyingglass", index: 1)
|
|
}
|
|
.padding(4)
|
|
.background(Color.textTertiary.opacity(0.08))
|
|
.clipShape(Capsule())
|
|
.padding(.horizontal, 40)
|
|
}
|
|
.padding(.bottom, 12)
|
|
.background(Color.canvas)
|
|
|
|
if selectedMode == 0 {
|
|
AssistantChatView(onFoodAdded: onFoodAdded)
|
|
} else {
|
|
FoodSearchView(isSheet: true, onFoodAdded: onFoodAdded)
|
|
}
|
|
}
|
|
.background(Color.canvas)
|
|
.presentationDetents([.large])
|
|
}
|
|
|
|
private func tabButton(_ title: String, icon: String, index: Int) -> some View {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) { selectedMode = index }
|
|
} label: {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: icon).font(.caption2)
|
|
Text(title).font(.subheadline.weight(selectedMode == index ? .semibold : .regular))
|
|
}
|
|
.foregroundStyle(selectedMode == index ? Color.textPrimary : Color.textTertiary)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(selectedMode == index ? Color.surfaceCard : Color.clear)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Tab Bar Minimize (iOS 26+)
|
|
|
|
struct TabBarMinimizeModifier: ViewModifier {
|
|
func body(content: Content) -> some View {
|
|
if #available(iOS 26.0, *) {
|
|
content.tabBarMinimizeBehavior(.onScrollDown)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|