Conversation
📝 WalkthroughWalkthroughThis pull request introduces a complete iOS product review application with full-stack architecture. Changes include foundational documentation, XcodeGen/Xcode project configuration, app entry points, core utilities, data layer infrastructure, domain models and repositories, comprehensive presentation layer with views and components, and test scaffolding. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@ios/ProductReview/App/Core/NetworkMonitor.swift`:
- Around line 25-44: The NWPathMonitor instance (monitor) is cancelled in
stopMonitoring(), which makes subsequent startMonitoring() calls no-op; modify
startMonitoring() to check whether monitor has been cancelled and if so replace
it with a new NWPathMonitor before assigning pathUpdateHandler and calling
monitor.start(queue:) (or alternatively maintain a boolean like isMonitoring to
guard against double-starts); ensure you update references to monitor, keep the
DispatchQueue (queue) reuse, and preserve existing behavior that updates
isConnected and connectionType via getConnectionType(path) in the Task
`@MainActor` block.
In `@ios/ProductReview/Data/Network/APIClient.swift`:
- Around line 180-206: The requestWithRetry function should reject
non-idempotent HTTP methods to avoid duplicate mutations; at the start of
requestWithRetry (before any retries or network calls) check the provided method
(HTTPMethod) and if it is POST, PUT, DELETE or any non-idempotent variant, throw
a suitable error (e.g., APIError.invalidRequest or add
APIError.nonIdempotentMethod) or return a failure so retries are never attempted
for those methods; update the function signature/behavior accordingly and
reference requestWithRetry and HTTPMethod when making this guard.
In `@ios/ProductReview/Domain/Model/Review.swift`:
- Around line 28-30: The computed property starsText can crash if rating is
outside 0..5 because String(repeating:count:) rejects negative counts; clamp
rating to the valid range before building the strings (e.g. compute let safe =
min(max(rating, 0), 5) or equivalent) and then use safe when calling
String(repeating: "★", count: safe) and String(repeating: "☆", count: 5 - safe)
so starsText always receives non-negative counts; update the starsText getter to
use this clamped value and reference the rating property.
In `@ios/ProductReview/Presentation/Components/EmptyStateView.swift`:
- Around line 40-44: The current EmptyStateView body uses
.accessibilityElement(children: .combine) on the whole VStack which merges the
button into the static content and makes the Button (actionTitle/action) not
independently focusable; change the accessibility grouping so only the static
content (icon/title/subtitle) is combined and labeled while leaving the Button
as its own accessibility element—locate the VStack in EmptyStateView's body and
move .accessibilityElement(children: .combine) and
.accessibilityLabel("\(title). \(subtitle)") to wrap only the static Group (or
the Image/Text trio), keep the Button (guarded by actionTitle/action) outside
that grouped element, and ensure Image has .accessibilityHidden(true) if needed.
In `@ios/ProductReview/Presentation/Views/Notification/NotificationsView.swift`:
- Around line 125-166: NotificationDetailView currently never sets its `@State`
notification and ignores notificationId, causing the ProgressView to remain; fix
by wiring the real AppNotification into the view or loading it on appear: either
change NotificationDetailView to accept let notification: AppNotification
(remove/ignore notificationId) and use that to populate the UI, or implement a
small view model (e.g., NotificationDetailViewModel) and in
NotificationDetailView's onAppear call viewModel.load(notificationId:) to set
`@State` notification; update the navigation route to pass the AppNotification (or
the id) and ensure you reference NotificationDetailView.notificationId,
NotificationDetailView.notification, AppNotification, and any new
NotificationDetailViewModel.load(...) when making the change.
- Around line 14-87: The alert uses Binding.constant(viewModel.error != nil)
which prevents SwiftUI from writing back a false to dismiss; update
NotificationsView's .alert to use a computed Binding that gets viewModel.error
!= nil and sets viewModel.error = nil when false so the alert can dismiss
properly (apply the same change for WishlistView, ProductDetailView,
ProductListView, and AIAssistantView where you used Binding.constant for
viewModel.error). Ensure you reference the viewModel.error property and the
.alert modifier on NotificationsView when making the change.
In `@ios/ProductReview/Presentation/Views/Product/ProductDetailView.swift`:
- Around line 171-175: The alert uses a read-only binding via
.constant(viewModel.error != nil) so dismissals won't write back; replace that
with a computed Binding for the .alert isPresented parameter that gets
viewModel.error != nil and in set assigns nil to viewModel.error when the new
value is false, keeping the Button("OK") action as-is; make the identical change
for the alert usage in NotificationsView so both alerts properly update
viewModel.error on dismiss.
- Around line 273-304: The VStack in ProductDetailView's body causes all
ReviewCardView rows to initialize eagerly (so the onAppear pagination check
triggers too early); replace the VStack(alignment: .leading, spacing: 12) with
LazyVStack(alignment: .leading, spacing: 12) so rows are created lazily and
onAppear on the last review only fires when it actually scrolls into view,
leaving reviews, onLoadMore, votedReviewIds, and ReviewCardView usage unchanged.
In `@ios/ProductReview/Presentation/Views/Product/ProductListView.swift`:
- Around line 107-111: The alert uses an immutable binding
(.constant(viewModel.error != nil)) so SwiftUI can't clear viewModel.error on
dismiss; replace that .constant with a computed Binding<Bool> (e.g., a Binding
whose get returns viewModel.error != nil and whose set clears viewModel.error
when set to false) so the alert can be dismissed; update the alert call in
ProductListView (the modifier using viewModel.error) to use this mutable
Binding.
In `@ios/ProductReview/Presentation/Views/Wishlist/WishlistView.swift`:
- Around line 99-103: The alert currently uses an immutable binding
(.constant(viewModel.error != nil)) so SwiftUI can't clear it on dismissal;
change the .alert call to use a computed Binding (Binding(get:set:)) that
returns viewModel.error != nil and in the setter sets viewModel.error = nil when
false (or clears the error when the alert is dismissed), or alternatively use
.alert(item:) with an optional error model; update the .alert invocation in
WishlistView (the block referencing viewModel.error) to use that computed
binding so tapping "OK" actually clears viewModel.error and prevents the alert
from immediately reappearing.
In `@ios/ProductReviewTests/ProductReviewTests.swift`:
- Around line 29-53: Tests call non-existent ProductMapper.toDomain(dto) and
ReviewMapper.toDomain(dto); update the test calls to use the existing mapper API
by replacing ProductMapper.toDomain(dto) with ProductMapper.map(dto) and
ReviewMapper.toDomain(dto) with ReviewMapper.map(dto), ensuring the DTO variable
names remain the same so the tests compile against the current mapper methods.
🟡 Minor comments (13)
ios/ProductReview/Presentation/Components/ToastView.swift-74-80 (1)
74-80:⚠️ Potential issue | 🟡 MinorAuto-dismiss timer is not cancelled if toast disappears early.
The
DispatchQueue.main.asyncAfteris scheduled on appear but never cancelled. If the toast is dismissed before the timer fires (e.g., navigation change) or if it's triggered multiple times rapidly, stale timers may cause unexpected dismissals.🛠️ Suggested fix using cancellable Task
struct ToastModifier: ViewModifier { `@Binding` var isPresented: Bool let message: String let type: ToastType let duration: Double + `@State` private var dismissTask: Task<Void, Never>? func body(content: Content) -> some View { ZStack { content VStack { if isPresented { ToastView(message: message, type: type) .transition(.move(edge: .top).combined(with: .opacity)) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { - withAnimation { - isPresented = false + dismissTask?.cancel() + dismissTask = Task { + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + guard !Task.isCancelled else { return } + await MainActor.run { + withAnimation { + isPresented = false + } } } } + .onDisappear { + dismissTask?.cancel() + } } Spacer() } .animation(.spring(), value: isPresented) } } }ios/ProductReview/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json-2-7 (1)
2-7:⚠️ Potential issue | 🟡 MinorApp icon image not linked.
The icon entry is missing a
"filename"key, so no actual image asset is referenced. The app will build without a visible icon. Either add a 1024x1024 PNG and reference it, or leave this as a placeholder with a TODO.Example with image reference
"images" : [ { "idiom" : "universal", + "filename" : "AppIcon.png", "platform" : "ios", "size" : "1024x1024" } ],OVERVIEW.md-68-68 (1)
68-68:⚠️ Potential issue | 🟡 MinorAdd language identifiers to fenced code blocks flagged by markdownlint.
MD040 is raised on these blocks. Use an explicit language (e.g.,
text,bash,dockerfile,typescript) to satisfy linting and improve readability.Also applies to: 166-166, 173-173, 246-246, 536-536
OVERVIEW.md-233-241 (1)
233-241:⚠️ Potential issue | 🟡 MinorInsert blank lines around tables to satisfy MD058.
markdownlint reports missing blank lines around tables in this section. Please surround the table with blank lines (and apply the same fix to other tables in the file).
Proposed fix (example for this table)
-**Context Providers:** -| Context | Purpose | -|---------|---------| -| ThemeContext | Dark/light mode with persistence | -| WishlistContext | Dual-source sync (local + backend) | -| NotificationContext | Notification state with optimistic updates | -| SearchContext | Search history persistence | -| ToastContext | Animated toast notifications | -| NetworkContext | Connectivity monitoring | +**Context Providers:** + +| Context | Purpose | +|---------|---------| +| ThemeContext | Dark/light mode with persistence | +| WishlistContext | Dual-source sync (local + backend) | +| NotificationContext | Notification state with optimistic updates | +| SearchContext | Search history persistence | +| ToastContext | Animated toast notifications | +| NetworkContext | Connectivity monitoring | +ios/ProductReviewUITests/ProductReviewUITests.swift-13-45 (1)
13-45:⚠️ Potential issue | 🟡 MinorReplace conditional
if existschecks with proper assertions.The
if exists { tap() }pattern allows tests to silently skip navigation without asserting success. Replace with explicit assertions before tapping, and verify navigation by checking a screen-unique element appears (more reliable thanisSelectedalone, which requires accessibility traits to be properly configured).For example:
- Assert the tab button exists before tapping
- After tapping, assert an element unique to the destination screen appears (using
waitForExistence(timeout:))- Only use
isSelectedif you've verified the app sets theselectedaccessibility trait on tab itemsios/ProductReview/Data/Local/SearchHistoryManager.swift-41-43 (1)
41-43:⚠️ Potential issue | 🟡 MinorMake removal case-insensitive to match addSearch.
Right nowremoveSearchwon’t remove entries whose casing differs.🔧 Suggested fix
- func removeSearch(_ query: String) { - recentSearches.removeAll { $0 == query } + func removeSearch(_ query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + recentSearches.removeAll { $0.lowercased() == trimmed.lowercased() } saveHistory() }ios/ProductReview/Presentation/Components/AnimatedHeartButton.swift-71-73 (1)
71-73:⚠️ Potential issue | 🟡 MinorAdd
privatekeyword to@Stateproperty to comply with SwiftLint rule.
@Statevariables are internal implementation details of a View and should be marked private to prevent external initialization.- `@State` var isLiked = false + `@State` private var isLiked = falseios/ProductReview/Presentation/Views/AI/AIAssistantView.swift-112-118 (1)
112-118:⚠️ Potential issue | 🟡 MinorUse a mutable binding for the error alert.
Binding.constantcreates a read‑only binding, so SwiftUI's alert API cannot properly manage dismissal state. The current code works in practice because the button action manually clears the error, but this violates SwiftUI's binding contract. Use a computed binding with get/set closures instead.🐛 Suggested fix
- .alert("Error", isPresented: .constant(viewModel.error != nil)) { - Button("OK") { viewModel.error = nil } - } message: { - Text(viewModel.error ?? "") - } + .alert( + "Error", + isPresented: Binding( + get: { viewModel.error != nil }, + set: { if !$0 { viewModel.error = nil } } + ) + ) { + Button("OK") { viewModel.error = nil } + } message: { + Text(viewModel.error ?? "") + }Note: This pattern is used identically in WishlistView, ProductListView, ProductDetailView, and NotificationsView. Apply the same fix across all files.
ios/ProductReview/Presentation/Views/Wishlist/WishlistView.swift-248-259 (1)
248-259:⚠️ Potential issue | 🟡 MinorBulk removal lacks error recovery and may lose data.
If any
toggleWishlistcall fails mid-loop, the UI already shows all items removed (optimistic update at Line 250), but the error only reflects the last failure. Users may lose track of which items were actually removed.Consider collecting failures and either reverting the optimistic update on partial failure or showing which specific items failed:
♻️ Suggested improvement with error aggregation
func removeMultiple(productIds: [Int]) async { // Optimistic update - products.removeAll { productIds.contains($0.id) } + let originalProducts = products + products.removeAll { productIds.contains($0.id) } + + var failedIds: [Int] = [] for id in productIds { do { try await repository.toggleWishlist(productId: id) } catch { - self.error = error.localizedDescription + failedIds.append(id) } } + + if !failedIds.isEmpty { + // Restore failed items + let failedProducts = originalProducts.filter { failedIds.contains($0.id) } + products.append(contentsOf: failedProducts) + self.error = "Failed to remove \(failedIds.count) item(s)" + } }ios/ProductReview/Data/Repository/WishlistRepository.swift-10-18 (1)
10-18:⚠️ Potential issue | 🟡 MinorData race on
cachedIdswithout synchronization.
cachedIdsis a mutableSet<Int>accessed from multiple concurrent async contexts (getWishlistIds,toggleWishlist,isInWishlist) without synchronization. This violates Swift's concurrency safety guarantees.Convert to an
actor:♻️ Suggested fix
-final class WishlistRepository: WishlistRepositoryProtocol { +actor WishlistRepository: WishlistRepositoryProtocol { private let apiClient: APIClient private var cachedIds: Set<Int> = [] private let cacheKey = AppConstants.StorageKeys.wishlist init(apiClient: APIClient = .shared) { self.apiClient = apiClient loadCachedIds() }ios/ProductReview/Presentation/Views/Notification/NotificationsView.swift-197-210 (1)
197-210:⚠️ Potential issue | 🟡 MinorAvoid decrementing unread count for already-read items.
Tapping an already-read notification currently decrementsunreadCount. Gate the decrement on the previous read state.✅ Suggested fix
- if let index = notifications.firstIndex(where: { $0.id == notificationId }) { - notifications[index].isRead = true - unreadCount = max(0, unreadCount - 1) - } + if let index = notifications.firstIndex(where: { $0.id == notificationId }) { + let wasUnread = notifications[index].isRead == false + notifications[index].isRead = true + if wasUnread { unreadCount = max(0, unreadCount - 1) } + }ios/ProductReview/Presentation/ViewModels/ProductDetailViewModel.swift-108-125 (1)
108-125:⚠️ Potential issue | 🟡 MinorHonor the active rating filter when inserting new reviews.
If a rating filter is active and the new review doesn’t match, inserting it breaks the filtered list. Consider only inserting when it matches.✅ Suggested fix
- // Insert at beginning - reviews.insert(newReview, at: 0) + // Insert only if it matches the active filter + if currentRatingFilter == nil || currentRatingFilter == newReview.rating { + reviews.insert(newReview, at: 0) + }ios/ProductReview/Presentation/Views/Product/ProductDetailView.swift-26-38 (1)
26-38:⚠️ Potential issue | 🟡 MinorAddress indefinite placeholder state when image URL is invalid.
URL(string: product.imageUrl ?? "")will passniltoAsyncImagewhen the URL string is empty or invalid (sinceURL(string: "")returnsnil). WhenAsyncImagereceives anilURL, it remains in the.emptyphase indefinitely, showing the placeholder without any indication of failure. Consider an explicit check to show a fallback view or indicate the error state clearly.
🧹 Nitpick comments (22)
ios/ProductReview/Presentation/Components/RatingStarsView.swift (1)
103-108: Mark@Stateproperty as private.SwiftUI state properties should be private to prevent external mutation that bypasses SwiftUI's state management.
🔧 Suggested fix
private struct RatingPreviewWrapper: View { - `@State` var rating = 3 + `@State` private var rating = 3 var body: some View { InteractiveRatingStars(rating: $rating) } }ios/ProductReview/Presentation/Components/ShimmerView.swift (3)
10-31: Hardcoded offset value doesn't adapt to view width.The
phase = 200magic number means the shimmer animation travels a fixed 200 points regardless of the view's actual width. For narrow views this overshoots; for wide views it may not cover the full width.Consider using
GeometryReaderto make the animation distance proportional to the view's width:♻️ Suggested adaptive shimmer implementation
struct ShimmerView: View { - `@State` private var phase: CGFloat = 0 + `@State` private var isAnimating = false var body: some View { - LinearGradient( - gradient: Gradient(colors: [ - Color.gray.opacity(0.2), - Color.gray.opacity(0.4), - Color.gray.opacity(0.2) - ]), - startPoint: .leading, - endPoint: .trailing - ) - .mask(Rectangle()) - .offset(x: phase) - .onAppear { - withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { - phase = 200 + GeometryReader { geometry in + LinearGradient( + gradient: Gradient(colors: [ + Color.gray.opacity(0.2), + Color.gray.opacity(0.4), + Color.gray.opacity(0.2) + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geometry.size.width) + .offset(x: isAnimating ? geometry.size.width : -geometry.size.width) + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + isAnimating = true + } } } + .clipped() } }
34-66: Multiple ShimmerView instances animate independently.Each
ShimmerViewmaintains its own@State phase, so the shimmer waves across image, title, rating, and price skeletons may drift out of sync over time due to slight timing differences when views appear.For a more polished look, consider either:
- Applying a single shimmer overlay to the entire skeleton card, or
- Lifting the animation state to a shared parent and passing it down.
69-93: Same hardcoded offset issue as ShimmerView.The
phase = 300has the same adaptivity concern noted above. Consider usingGeometryReaderhere as well for consistency across both shimmer implementations.ios/ProductReview/Presentation/Components/LoadingButton.swift (2)
16-36: Consider renamingButtonStyleto avoid shadowing SwiftUI'sButtonStyleprotocol.The nested enum
ButtonStyleshares its name with SwiftUI's built-inButtonStyleprotocol. While the nested scope prevents direct conflicts, this can cause confusion when reading the code or when attempting to apply native SwiftUI button styles in related code.Consider renaming to
Variant,Kind, orLoadingButtonStyleto improve clarity—similar to how the React Native counterpart inButton.tsxusesvariant.♻️ Suggested rename
- enum ButtonStyle { + enum Variant { case primary case secondary case destructive var backgroundColor: Color {Then update usages:
- let style: ButtonStyle + let style: Variant - init(_ title: String, isLoading: Bool = false, style: ButtonStyle = .primary, action: `@escaping` () -> Void) { + init(_ title: String, isLoading: Bool = false, style: Variant = .primary, action: `@escaping` () -> Void) {
57-57: Hardcoded "Loading..." string won't support localization.If the app targets multiple languages, consider using
LocalizedStringKeyorNSLocalizedStringfor the loading text to ensure it can be translated.♻️ Suggested fix for localization
- Text(isLoading ? "Loading..." : title) + Text(isLoading ? String(localized: "loading_button.loading") : title)ios/ProductReview/Presentation/Components/ToastView.swift (2)
53-53: Consider usingclipShapeinstead of deprecatedcornerRadius.
.cornerRadius()is deprecated in iOS 17+. If targeting iOS 17 or later, prefer the newer API.♻️ Suggested refactor
.padding() .background(.ultraThinMaterial) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
35-57: Consider adding accessibility announcement for VoiceOver users.The toast appears visually but may not be announced to VoiceOver users. Adding an accessibility announcement ensures all users are notified.
♿ Suggested accessibility enhancement
struct ToastView: View { let message: String let type: ToastType var body: some View { HStack(spacing: 12) { Image(systemName: type.icon) .foregroundColor(type.color) .font(.title3) + .accessibilityHidden(true) Text(message) .font(.subheadline) .foregroundColor(.primary) Spacer() } .padding() .background(.ultraThinMaterial) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) .padding(.horizontal) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(type == .error ? "Error" : type == .warning ? "Warning" : type == .success ? "Success" : "Info"): \(message)") } }ios/ProductReview/Domain/Model/Review.swift (1)
19-25: Consider caching the formatter for performance.
RelativeDateTimeFormatteris recreated on everyformattedDateaccess. For lists with many reviews, consider using a static formatter.♻️ Suggested optimization
+ private static let dateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + var formattedDate: String { guard let date = createdAt else { return "" } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: Date()) + return Self.dateFormatter.localizedString(for: date, relativeTo: Date()) }ios/ProductReview/App/Core/ThemeManager.swift (1)
68-69: Use@StateObjector inject via environment for view-owned observation.Using
@ObservedObjectwith a default value in the initializer can cause unexpected behavior with SwiftUI's view lifecycle. SinceThemeManager.sharedis a singleton, consider either:
- Using
@StateObject(though it won't re-create the singleton)- Injecting via
@EnvironmentObjectfor better testability♻️ Option 1: Use environment object pattern
struct ThemeToggleView: View { - `@ObservedObject` var themeManager = ThemeManager.shared + `@EnvironmentObject` var themeManager: ThemeManager var body: some View {Then inject at the app root:
ContentView() .environmentObject(ThemeManager.shared)ios/ProductReview/Info.plist (1)
28-29: Empty launch screen dictionary will use system default.The empty
UILaunchScreendict is valid and will display a blank launch screen. Consider adding a launch screen storyboard or configuringUIColorName/UIImageNamefor better branding.ios/ProductReview/Data/Local/ImageCache.swift (2)
24-26: Use NSCache cost to maketotalCostLimiteffective.
totalCostLimitis ignored when objects are stored without a cost. Estimate bytes and pass the cost tosetObjectso the 50 MB cap is enforced.Proposed fix
- func setImage(_ image: UIImage, for url: String) { - cache.setObject(image, forKey: url as NSString) - } + func setImage(_ image: UIImage, for url: String) { + let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4) + cache.setObject(image, forKey: url as NSString, cost: cost) + }
59-84: HardenloadImageagainst cancellation and ensure UI-state safety.Consider resetting
isLoadingwithdeferand validating task cancellation before updating state. Also verify that the task runs on the main actor (or explicitly hop toMainActor) before mutating@State.Proposed fix
- private func loadImage() async { + `@MainActor` + private func loadImage() async { guard let url = url else { return } let urlString = url.absoluteString @@ - isLoading = true + isLoading = true + defer { isLoading = false } do { let (data, _) = try await URLSession.shared.data(from: url) + if Task.isCancelled { return } if let uiImage = UIImage(data: data) { await ImageCache.shared.setImage(uiImage, for: urlString) self.image = uiImage } } catch { print("Failed to load image: \(error)") } - - isLoading = false }ios/ProductReview/Domain/Model/Notification.swift (1)
18-24: Avoid per-accessRelativeDateTimeFormatterallocation.Create a static formatter to reduce overhead when rendering lists.
Proposed fix
struct AppNotification: Identifiable, Hashable { @@ - // Formatted date - var formattedDate: String { - guard let date = createdAt else { return "" } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: Date()) - } + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + // Formatted date + var formattedDate: String { + guard let date = createdAt else { return "" } + return Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) + } }ios/ProductReview/Data/Mapper/NotificationMapper.swift (1)
12-22: Reuse ISO8601DateFormatter instances to avoid per-call allocation.
Creating and mutating a formatter on every map call is expensive; cache static formatters (or a shared helper) and reuse them for parsing consistency.♻️ Suggested refactor
enum NotificationMapper { + private static let isoFormatterWithFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + static func map(_ dto: NotificationDTO) -> AppNotification { - // Parse date from ISO 8601 string - var date: Date? = nil - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - date = formatter.date(from: dto.createdAt) - - // Try without fractional seconds if first attempt fails - if date == nil { - formatter.formatOptions = [.withInternetDateTime] - date = formatter.date(from: dto.createdAt) - } + // Parse date from ISO 8601 string + let date = isoFormatterWithFractional.date(from: dto.createdAt) + ?? isoFormatter.date(from: dto.createdAt) return AppNotification( id: dto.id,ios/ProductReview/Data/Mapper/ReviewMapper.swift (1)
10-25: Cache ISO8601DateFormatter and consider a shared parsing helper.
This repeats the same parsing pattern asNotificationMapperand allocates a formatter every call. Cache static formatters (or extract a shared date parser) to reduce overhead and keep options consistent.♻️ Suggested refactor
enum ReviewMapper { + private static let isoFormatterWithFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + static func map(_ dto: ReviewDTO) -> Review? { guard let id = dto.id else { return nil } // Parse date from ISO 8601 string var date: Date? = nil if let dateString = dto.createdAt { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - date = formatter.date(from: dateString) - - // Try without fractional seconds if first attempt fails - if date == nil { - formatter.formatOptions = [.withInternetDateTime] - date = formatter.date(from: dateString) - } + date = isoFormatterWithFractional.date(from: dateString) + ?? isoFormatter.date(from: dateString) }ios/ProductReview/Data/Repository/ProductRepository.swift (1)
37-38: Avoid sending an emptysortparameter.
You already guard emptycategory/search. Doing the same forsortprevents sendingsort=and inadvertently overriding backend defaults.🧹 Suggested tweak
- if let sort = sort { + if let sort = sort, !sort.isEmpty { params["sort"] = sort }ios/ProductReview/Presentation/Views/AI/AIAssistantView.swift (1)
138-142: Cache DateFormatter for message timestamps.
Creating aDateFormatterfor every message is expensive; cache a static instance.♻️ Suggested refactor
struct ChatMessage: Identifiable { let id = UUID() let content: String let isUser: Bool let timestamp: Date + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.timeStyle = .short + return f + }() var formattedTime: String { - let formatter = DateFormatter() - formatter.timeStyle = .short - return formatter.string(from: timestamp) + Self.timeFormatter.string(from: timestamp) } }ios/ProductReview/Presentation/ViewModels/ProductListViewModel.swift (1)
95-110: Consider simplifying the search method signature.The
searchfunction is markedasyncbut doesn't await anything directly—it just spawns a detachedTaskfor debouncing. This can be confusing for callers who might expectawait viewModel.search(query:)to complete when the search finishes.Consider removing
asyncfrom the signature since the method returns immediately after scheduling the debounced task:♻️ Suggested simplification
- func search(query: String) async { + func search(query: String) { // Cancel previous search task searchTask?.cancel() // Debounce search searchTask = Task { try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 second debounce guard !Task.isCancelled else { return } currentSearch = query.isEmpty ? nil : query currentPage = 0 isLast = false await loadProducts() } }ios/ProductReview/Domain/Model/Product.swift (1)
22-28: Consider caching the NumberFormatter for performance.
NumberFormatterinstantiation is relatively expensive. Creating one on every access toformattedPricecould impact performance when rendering lists with many products. Consider using a static cached formatter:♻️ Suggested optimization
+ private static let priceFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + return formatter + }() + // Formatted price string var formattedPrice: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "USD" - return formatter.string(from: NSNumber(value: price)) ?? "$\(price)" + Self.priceFormatter.string(from: NSNumber(value: price)) ?? "$\(price)" }ios/ProductReview/Data/Repository/WishlistRepository.swift (1)
63-76: Cache update assumes server state matches local expectation.The toggle logic assumes the local cache accurately reflects server state. If the cache is stale (e.g., user modified wishlist on another device), the local update after
toggleWishlistcould desync—adding an item that's already there or removing one that isn't.Consider fetching the updated state from the API response or refreshing the cache after toggle operations in critical flows.
ios/ProductReview/Presentation/Views/Product/ProductListView.swift (1)
14-16: Local state duplicates ViewModel state.
searchTextandselectedCategoryin the View duplicatecurrentSearchandcurrentCategoryin the ViewModel. This can lead to sync issues if either side updates independently. Consider driving the search field directly from ViewModel state or ensuring single source of truth.
| private let monitor = NWPathMonitor() | ||
| private let queue = DispatchQueue(label: "NetworkMonitor") | ||
|
|
||
| private init() { | ||
| startMonitoring() | ||
| } | ||
|
|
||
| func startMonitoring() { | ||
| monitor.pathUpdateHandler = { [weak self] path in | ||
| Task { @MainActor in | ||
| self?.isConnected = path.status == .satisfied | ||
| self?.connectionType = self?.getConnectionType(path) ?? .unknown | ||
| } | ||
| } | ||
| monitor.start(queue: queue) | ||
| } | ||
|
|
||
| func stopMonitoring() { | ||
| monitor.cancel() | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "NetworkMonitor.swift" -type fRepository: Solarity-AI/intern-project-engineering
Length of output: 131
🏁 Script executed:
fd "NetworkMonitor" --type fRepository: Solarity-AI/intern-project-engineering
Length of output: 129
🌐 Web query:
NWPathMonitor cancel() semantics can it be restarted reused after cancel
💡 Result:
NWPathMonitor.cancel() is terminal for that specific monitor instance: after you call cancel(), you can’t call start(queue:) on the same NWPathMonitor again. To “restart” monitoring, you must create a new NWPathMonitor instance and call start(queue:) on the new one. [1][2][3]
Practical pattern:
- Start once per instance:
monitor.start(queue:) - Stop permanently:
monitor.cancel() - Resume later:
monitor = NWPathMonitor(); monitor.start(queue:)[1][2]
Sources: Ross Butler’s write-up (and mirrors) explicitly note that a canceled monitor can’t be started again, and Stack Overflow answers confirm crashes/issues when reusing a canceled instance. [1][2][3]
🏁 Script executed:
cat -n ios/ProductReview/App/Core/NetworkMonitor.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 2697
🏁 Script executed:
rg "NetworkMonitor" --type swift -A 3 -B 1 | head -100Repository: Solarity-AI/intern-project-engineering
Length of output: 1719
🏁 Script executed:
rg "stopMonitoring|startMonitoring" --type swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 308
🏁 Script executed:
rg "NetworkMonitor" --type swift | grep -E "(stop|start)Monitoring"Repository: Solarity-AI/intern-project-engineering
Length of output: 64
Guard against NWPathMonitor restart after cancel().
stopMonitoring() calls cancel(), which is a terminal operation on NWPathMonitor—the same instance cannot be restarted with start(). If the public API exposes both methods, calling stop() followed by start() will silently fail to resume monitoring. Add a guard to prevent double-start or recreate the monitor instance on restart.
Defensive fix: prevent restart failures
- private let monitor = NWPathMonitor()
+ private var monitor: NWPathMonitor? = NWPathMonitor()
+ private var isMonitoring = false
@@
func startMonitoring() {
- monitor.pathUpdateHandler = { [weak self] path in
+ guard !isMonitoring else { return }
+ if monitor == nil { monitor = NWPathMonitor() }
+ monitor?.pathUpdateHandler = { [weak self] path in
Task { `@MainActor` in
self?.isConnected = path.status == .satisfied
self?.connectionType = self?.getConnectionType(path) ?? .unknown
}
}
- monitor.start(queue: queue)
+ monitor?.start(queue: queue)
+ isMonitoring = true
}
@@
func stopMonitoring() {
- monitor.cancel()
+ monitor?.cancel()
+ monitor = nil
+ isMonitoring = false
}🤖 Prompt for AI Agents
In `@ios/ProductReview/App/Core/NetworkMonitor.swift` around lines 25 - 44, The
NWPathMonitor instance (monitor) is cancelled in stopMonitoring(), which makes
subsequent startMonitoring() calls no-op; modify startMonitoring() to check
whether monitor has been cancelled and if so replace it with a new NWPathMonitor
before assigning pathUpdateHandler and calling monitor.start(queue:) (or
alternatively maintain a boolean like isMonitoring to guard against
double-starts); ensure you update references to monitor, keep the DispatchQueue
(queue) reuse, and preserve existing behavior that updates isConnected and
connectionType via getConnectionType(path) in the Task `@MainActor` block.
| func requestWithRetry<T: Decodable>( | ||
| endpoint: String, | ||
| method: HTTPMethod = .get, | ||
| queryParams: [String: String]? = nil, | ||
| body: Encodable? = nil, | ||
| maxRetries: Int = 3 | ||
| ) async throws -> T { | ||
| var lastError: Error = APIError.unknown | ||
| var delay: UInt64 = 1_000_000_000 // 1 second | ||
|
|
||
| for attempt in 0..<maxRetries { | ||
| do { | ||
| return try await request( | ||
| endpoint: endpoint, | ||
| method: method, | ||
| queryParams: queryParams, | ||
| body: body | ||
| ) | ||
| } catch let error as APIError where error.isRetryable { | ||
| lastError = error | ||
| if attempt < maxRetries - 1 { | ||
| try await Task.sleep(nanoseconds: delay) | ||
| delay *= 2 // Exponential backoff | ||
| } | ||
| } catch { | ||
| throw error // Non-retryable error, throw immediately | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, let's find and examine the file to confirm the implementation
fd -t f "APIClient.swift" --path "ios/"Repository: Solarity-AI/intern-project-engineering
Length of output: 315
🏁 Script executed:
#!/bin/bash
# Find all calls to requestWithRetry to understand usage patterns
rg -n 'requestWithRetry' --type=swift -A 5 -B 2Repository: Solarity-AI/intern-project-engineering
Length of output: 732
🏁 Script executed:
#!/bin/bash
# Find all invocations of requestWithRetry to see what methods are being passed
rg 'requestWithRetry\s*<' --type=swift -B 2 -A 8Repository: Solarity-AI/intern-project-engineering
Length of output: 969
🏁 Script executed:
#!/bin/bash
# Also search for calls using a different pattern
rg '\\.requestWithRetry' --type=swift -B 2 -A 8Repository: Solarity-AI/intern-project-engineering
Length of output: 64
🏁 Script executed:
#!/bin/bash
# Let's view the complete implementation of requestWithRetry
sed -n '180,210p' ios/ProductReview/Data/Network/APIClient.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 1116
🏁 Script executed:
#!/bin/bash
# Broader search for requestWithRetry across all Swift files
rg 'requestWithRetry' --type=swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 169
🏁 Script executed:
#!/bin/bash
# Search for any POST or other HTTP method usage in the codebase
rg 'HTTPMethod\.(post|put|patch|delete)' --type=swift -B 2 -A 2Repository: Solarity-AI/intern-project-engineering
Length of output: 64
🏁 Script executed:
#!/bin/bash
# Look at the HTTPMethod enum definition
rg -A 10 'enum HTTPMethod' --type=swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 796
🏁 Script executed:
#!/bin/bash
# Check if requestWithRetry is called anywhere - try different search patterns
rg 'requestWithRetry' --type=swift --type=md --type=txtRepository: Solarity-AI/intern-project-engineering
Length of output: 169
🏁 Script executed:
#!/bin/bash
# Search across entire repo for requestWithRetry (not just swift)
fd -type f -name "*.swift" -o -name "*.md" | xargs grep -l "requestWithRetry" 2>/dev/null || echo "No matches in broader search"Repository: Solarity-AI/intern-project-engineering
Length of output: 298
🏁 Script executed:
#!/bin/bash
# Look at the entire APIClient file to understand context and usage
wc -l ios/ProductReview/Data/Network/APIClient.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 132
🏁 Script executed:
#!/bin/bash
# Check if requestWithRetry is called internally within the APIClient class
sed -n '1,228p' ios/ProductReview/Data/Network/APIClient.swift | grep -n "requestWithRetry"Repository: Solarity-AI/intern-project-engineering
Length of output: 126
🏁 Script executed:
#!/bin/bash
# View the request() method that requestWithRetry calls
rg -n 'func request<' --type=swift -A 15 ios/ProductReview/Data/Network/APIClient.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 711
🏁 Script executed:
#!/bin/bash
# Check the entire APIClient structure to understand its public API
rg -n 'func ' --type=swift ios/ProductReview/Data/Network/APIClient.swift | head -20Repository: Solarity-AI/intern-project-engineering
Length of output: 268
Guard requestWithRetry against non-idempotent methods.
The function accepts any HTTPMethod parameter and will retry any request that throws a retryable error. If a non-idempotent method like POST, PUT, or DELETE is passed, retrying can create duplicate mutations. Although the function currently has a safe default (.get) and no calls to it with non-idempotent methods exist in the codebase, the public API surface should prevent this misuse pattern.
Add a guard to reject non-idempotent methods:
🛡️ Suggested safeguard
enum APIError: Error, LocalizedError {
+ case nonIdempotentRetry
case invalidURL
case invalidResponse
case httpError(statusCode: Int, message: String?)
@@
var errorDescription: String? {
switch self {
+ case .nonIdempotentRetry:
+ return "Retrying non-idempotent requests is not supported."
case .invalidURL:
return "Invalid URL"
@@
func requestWithRetry<T: Decodable>(
endpoint: String,
method: HTTPMethod = .get,
queryParams: [String: String]? = nil,
body: Encodable? = nil,
maxRetries: Int = 3
) async throws -> T {
+ guard method == .get else {
+ throw APIError.nonIdempotentRetry
+ }
var lastError: Error = APIError.unknown
var delay: UInt64 = 1_000_000_000 // 1 second🤖 Prompt for AI Agents
In `@ios/ProductReview/Data/Network/APIClient.swift` around lines 180 - 206, The
requestWithRetry function should reject non-idempotent HTTP methods to avoid
duplicate mutations; at the start of requestWithRetry (before any retries or
network calls) check the provided method (HTTPMethod) and if it is POST, PUT,
DELETE or any non-idempotent variant, throw a suitable error (e.g.,
APIError.invalidRequest or add APIError.nonIdempotentMethod) or return a failure
so retries are never attempted for those methods; update the function
signature/behavior accordingly and reference requestWithRetry and HTTPMethod
when making this guard.
| var starsText: String { | ||
| String(repeating: "★", count: rating) + String(repeating: "☆", count: 5 - rating) | ||
| } |
There was a problem hiding this comment.
Potential crash if rating is out of bounds.
String(repeating:count:) with a negative count will crash. If rating > 5 or rating < 0, the expression 5 - rating becomes negative.
🛡️ Proposed fix with bounds clamping
var starsText: String {
- String(repeating: "★", count: rating) + String(repeating: "☆", count: 5 - rating)
+ let clampedRating = max(0, min(5, rating))
+ return String(repeating: "★", count: clampedRating) + String(repeating: "☆", count: 5 - clampedRating)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var starsText: String { | |
| String(repeating: "★", count: rating) + String(repeating: "☆", count: 5 - rating) | |
| } | |
| var starsText: String { | |
| let clampedRating = max(0, min(5, rating)) | |
| return String(repeating: "★", count: clampedRating) + String(repeating: "☆", count: 5 - clampedRating) | |
| } |
🤖 Prompt for AI Agents
In `@ios/ProductReview/Domain/Model/Review.swift` around lines 28 - 30, The
computed property starsText can crash if rating is outside 0..5 because
String(repeating:count:) rejects negative counts; clamp rating to the valid
range before building the strings (e.g. compute let safe = min(max(rating, 0),
5) or equivalent) and then use safe when calling String(repeating: "★", count:
safe) and String(repeating: "☆", count: 5 - safe) so starsText always receives
non-negative counts; update the starsText getter to use this clamped value and
reference the rating property.
| } | ||
| .padding() | ||
| .accessibilityElement(children: .combine) | ||
| .accessibilityLabel("\(title). \(subtitle)") | ||
| } |
There was a problem hiding this comment.
Accessibility blocker: Button is not accessible to VoiceOver users.
Using .accessibilityElement(children: .combine) merges all children into a single accessibility element. This prevents the action button (lines 36-38) from being independently focusable or tappable via VoiceOver, blocking task completion for users relying on assistive technology.
♿ Proposed fix to preserve button accessibility
}
.padding()
- .accessibilityElement(children: .combine)
- .accessibilityLabel("\(title). \(subtitle)")
}
}Alternatively, if you want to group the static text while keeping the button accessible:
var body: some View {
VStack(spacing: 16) {
Group {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(.secondary)
.accessibilityHidden(true)
Text(title)
.font(.title2)
.fontWeight(.medium)
.multilineTextAlignment(.center)
Text(subtitle)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title). \(subtitle)")
if let actionTitle = actionTitle, let action = action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
.padding(.top, 8)
}
}
.padding()
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } | |
| .padding() | |
| .accessibilityElement(children: .combine) | |
| .accessibilityLabel("\(title). \(subtitle)") | |
| } | |
| } | |
| .padding() | |
| } |
🤖 Prompt for AI Agents
In `@ios/ProductReview/Presentation/Components/EmptyStateView.swift` around lines
40 - 44, The current EmptyStateView body uses .accessibilityElement(children:
.combine) on the whole VStack which merges the button into the static content
and makes the Button (actionTitle/action) not independently focusable; change
the accessibility grouping so only the static content (icon/title/subtitle) is
combined and labeled while leaving the Button as its own accessibility
element—locate the VStack in EmptyStateView's body and move
.accessibilityElement(children: .combine) and .accessibilityLabel("\(title).
\(subtitle)") to wrap only the static Group (or the Image/Text trio), keep the
Button (guarded by actionTitle/action) outside that grouped element, and ensure
Image has .accessibilityHidden(true) if needed.
| var body: some View { | ||
| Group { | ||
| if viewModel.notifications.isEmpty && !viewModel.isLoading { | ||
| // Empty state | ||
| VStack(spacing: 16) { | ||
| Image(systemName: "bell.slash") | ||
| .font(.system(size: 60)) | ||
| .foregroundColor(.secondary) | ||
| Text("No notifications") | ||
| .font(.title2) | ||
| .fontWeight(.medium) | ||
| Text("You're all caught up!") | ||
| .font(.body) | ||
| .foregroundColor(.secondary) | ||
| } | ||
| .padding() | ||
| } else { | ||
| List { | ||
| ForEach(viewModel.notifications) { notification in | ||
| NotificationRow(notification: notification) | ||
| .onTapGesture { | ||
| Task { | ||
| await viewModel.markAsRead(notificationId: notification.id) | ||
| } | ||
| navigationRouter.navigate(to: .notificationDetail(notificationId: notification.id)) | ||
| } | ||
| .swipeActions(edge: .trailing) { | ||
| Button(role: .destructive) { | ||
| Task { | ||
| await viewModel.deleteNotification(notificationId: notification.id) | ||
| } | ||
| } label: { | ||
| Label("Delete", systemImage: "trash") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| .listStyle(.plain) | ||
| } | ||
| } | ||
| .navigationTitle("Notifications") | ||
| .toolbar { | ||
| ToolbarItem(placement: .topBarTrailing) { | ||
| Menu { | ||
| Button { | ||
| Task { await viewModel.markAllAsRead() } | ||
| } label: { | ||
| Label("Mark all as read", systemImage: "checkmark.circle") | ||
| } | ||
| .disabled(viewModel.unreadCount == 0) | ||
|
|
||
| Button(role: .destructive) { | ||
| Task { await viewModel.deleteAll() } | ||
| } label: { | ||
| Label("Delete all", systemImage: "trash") | ||
| } | ||
| .disabled(viewModel.notifications.isEmpty) | ||
| } label: { | ||
| Image(systemName: "ellipsis.circle") | ||
| } | ||
| } | ||
| } | ||
| .refreshable { | ||
| await viewModel.loadNotifications() | ||
| } | ||
| .task { | ||
| await viewModel.loadNotifications() | ||
| } | ||
| .alert("Error", isPresented: .constant(viewModel.error != nil)) { | ||
| Button("OK") { viewModel.error = nil } | ||
| } message: { | ||
| Text(viewModel.error ?? "") | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's check the file structure and find the NotificationsView file
find . -name "NotificationsView.swift" -type fRepository: Solarity-AI/intern-project-engineering
Length of output: 157
🏁 Script executed:
# Let's look at the ViewModel to understand the error property structure
find . -name "*NotificationViewModel*" -o -name "*ViewModel.swift" | grep -i notificationRepository: Solarity-AI/intern-project-engineering
Length of output: 64
🏁 Script executed:
# Let's also search for other alert usages in the codebase to see patterns
rg "\.alert\(" --type swift -A 3 -B 1 | head -100Repository: Solarity-AI/intern-project-engineering
Length of output: 2796
🏁 Script executed:
# Let's examine the NotificationsView file to understand its structure
cat -n ios/ProductReview/Presentation/Views/Notification/NotificationsView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 10247
🏁 Script executed:
# Find ViewModels related to notifications
find . -name "*ViewModel.swift" -type f | xargs grep -l "notification\|Notification" | head -5Repository: Solarity-AI/intern-project-engineering
Length of output: 64
🌐 Web query:
SwiftUI Binding.constant alert isPresented best practices
💡 Result:
Using Binding.constant(...) for alert(isPresented:) is usually a code smell and should be limited to Previews / static examples, not production UI.
Why Binding.constant is problematic
SwiftUI expects isPresented to be a real source of truth it can mutate: when the user taps an alert action, the system sets the bound value back to false to dismiss the alert. If you pass a constant binding, SwiftUI can’t reliably write false back, which can lead to weird behavior (re-showing, not dismissing as expected, only triggering on changes, etc.). [1][2]
Best-practice patterns (instead of .constant(...))
1) Use a real @State / @Published Bool (simple alerts)
`@State` private var showAlert = false
Button("Delete") { showAlert = true }
.alert("Confirm", isPresented: $showAlert) {
Button("Delete", role: .destructive) { /* ... */ }
Button("Cancel", role: .cancel) { }
}SwiftUI will dismiss it and reset showAlert automatically. [2][3]
2) If your “should show alert” is a computed condition, trigger a Bool via onChange
Don’t bind the computed value via .constant(condition) (that’s widely considered an “abuse” of bindings). [1]
`@State` private var showAlert = false
`@ObservedObject` var vm: ViewModel
var body: some View {
content
.onChange(of: vm.isAllCaps) { newValue in
if newValue { showAlert = true }
}
.alert("Too loud", isPresented: $showAlert) { Button("OK", role: .cancel) {} }
}3) Prefer “data-driven” alerts for dynamic content (optional item / enum)
When the alert content varies (error/details), model it as optional state (e.g., an AlertModel?) and present when it becomes non-nil (this scales better than many booleans). [2]
When Binding.constant is fine
- SwiftUI Preview snapshots (e.g., always show the alert)
- Very constrained demo code where you don’t care about dismissal state
Rule of thumb: if a user can dismiss it, don’t use .constant(...) for isPresented. [2][3]
Sources: [1] [2] [3] [4]
Error alert won't reliably present/dismiss with Binding.constant.
Binding.constant(viewModel.error != nil) prevents SwiftUI from writing back the false value needed to dismiss the alert. Use a computed binding instead so the alert can present and dismiss correctly.
Suggested fix
- .alert("Error", isPresented: .constant(viewModel.error != nil)) {
- Button("OK") { viewModel.error = nil }
- } message: {
- Text(viewModel.error ?? "")
- }
+ .alert("Error", isPresented: Binding(
+ get: { viewModel.error != nil },
+ set: { if !$0 { viewModel.error = nil } }
+ )) {
+ Button("OK") { viewModel.error = nil }
+ } message: {
+ Text(viewModel.error ?? "")
+ }This pattern also appears in WishlistView.swift, ProductDetailView.swift, ProductListView.swift, and AIAssistantView.swift.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var body: some View { | |
| Group { | |
| if viewModel.notifications.isEmpty && !viewModel.isLoading { | |
| // Empty state | |
| VStack(spacing: 16) { | |
| Image(systemName: "bell.slash") | |
| .font(.system(size: 60)) | |
| .foregroundColor(.secondary) | |
| Text("No notifications") | |
| .font(.title2) | |
| .fontWeight(.medium) | |
| Text("You're all caught up!") | |
| .font(.body) | |
| .foregroundColor(.secondary) | |
| } | |
| .padding() | |
| } else { | |
| List { | |
| ForEach(viewModel.notifications) { notification in | |
| NotificationRow(notification: notification) | |
| .onTapGesture { | |
| Task { | |
| await viewModel.markAsRead(notificationId: notification.id) | |
| } | |
| navigationRouter.navigate(to: .notificationDetail(notificationId: notification.id)) | |
| } | |
| .swipeActions(edge: .trailing) { | |
| Button(role: .destructive) { | |
| Task { | |
| await viewModel.deleteNotification(notificationId: notification.id) | |
| } | |
| } label: { | |
| Label("Delete", systemImage: "trash") | |
| } | |
| } | |
| } | |
| } | |
| .listStyle(.plain) | |
| } | |
| } | |
| .navigationTitle("Notifications") | |
| .toolbar { | |
| ToolbarItem(placement: .topBarTrailing) { | |
| Menu { | |
| Button { | |
| Task { await viewModel.markAllAsRead() } | |
| } label: { | |
| Label("Mark all as read", systemImage: "checkmark.circle") | |
| } | |
| .disabled(viewModel.unreadCount == 0) | |
| Button(role: .destructive) { | |
| Task { await viewModel.deleteAll() } | |
| } label: { | |
| Label("Delete all", systemImage: "trash") | |
| } | |
| .disabled(viewModel.notifications.isEmpty) | |
| } label: { | |
| Image(systemName: "ellipsis.circle") | |
| } | |
| } | |
| } | |
| .refreshable { | |
| await viewModel.loadNotifications() | |
| } | |
| .task { | |
| await viewModel.loadNotifications() | |
| } | |
| .alert("Error", isPresented: .constant(viewModel.error != nil)) { | |
| Button("OK") { viewModel.error = nil } | |
| } message: { | |
| Text(viewModel.error ?? "") | |
| } | |
| } | |
| var body: some View { | |
| Group { | |
| if viewModel.notifications.isEmpty && !viewModel.isLoading { | |
| // Empty state | |
| VStack(spacing: 16) { | |
| Image(systemName: "bell.slash") | |
| .font(.system(size: 60)) | |
| .foregroundColor(.secondary) | |
| Text("No notifications") | |
| .font(.title2) | |
| .fontWeight(.medium) | |
| Text("You're all caught up!") | |
| .font(.body) | |
| .foregroundColor(.secondary) | |
| } | |
| .padding() | |
| } else { | |
| List { | |
| ForEach(viewModel.notifications) { notification in | |
| NotificationRow(notification: notification) | |
| .onTapGesture { | |
| Task { | |
| await viewModel.markAsRead(notificationId: notification.id) | |
| } | |
| navigationRouter.navigate(to: .notificationDetail(notificationId: notification.id)) | |
| } | |
| .swipeActions(edge: .trailing) { | |
| Button(role: .destructive) { | |
| Task { | |
| await viewModel.deleteNotification(notificationId: notification.id) | |
| } | |
| } label: { | |
| Label("Delete", systemImage: "trash") | |
| } | |
| } | |
| } | |
| } | |
| .listStyle(.plain) | |
| } | |
| } | |
| .navigationTitle("Notifications") | |
| .toolbar { | |
| ToolbarItem(placement: .topBarTrailing) { | |
| Menu { | |
| Button { | |
| Task { await viewModel.markAllAsRead() } | |
| } label: { | |
| Label("Mark all as read", systemImage: "checkmark.circle") | |
| } | |
| .disabled(viewModel.unreadCount == 0) | |
| Button(role: .destructive) { | |
| Task { await viewModel.deleteAll() } | |
| } label: { | |
| Label("Delete all", systemImage: "trash") | |
| } | |
| .disabled(viewModel.notifications.isEmpty) | |
| } label: { | |
| Image(systemName: "ellipsis.circle") | |
| } | |
| } | |
| } | |
| .refreshable { | |
| await viewModel.loadNotifications() | |
| } | |
| .task { | |
| await viewModel.loadNotifications() | |
| } | |
| .alert("Error", isPresented: Binding( | |
| get: { viewModel.error != nil }, | |
| set: { if !$0 { viewModel.error = nil } } | |
| )) { | |
| Button("OK") { viewModel.error = nil } | |
| } message: { | |
| Text(viewModel.error ?? "") | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@ios/ProductReview/Presentation/Views/Notification/NotificationsView.swift`
around lines 14 - 87, The alert uses Binding.constant(viewModel.error != nil)
which prevents SwiftUI from writing back a false to dismiss; update
NotificationsView's .alert to use a computed Binding that gets viewModel.error
!= nil and sets viewModel.error = nil when false so the alert can dismiss
properly (apply the same change for WishlistView, ProductDetailView,
ProductListView, and AIAssistantView where you used Binding.constant for
viewModel.error). Ensure you reference the viewModel.error property and the
.alert modifier on NotificationsView when making the change.
| .alert("Error", isPresented: .constant(viewModel.error != nil)) { | ||
| Button("OK") { viewModel.error = nil } | ||
| } message: { | ||
| Text(viewModel.error ?? "") | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "ProductDetailView.swift" -o -name "NotificationsView.swift" | head -20Repository: Solarity-AI/intern-project-engineering
Length of output: 228
🏁 Script executed:
git ls-files | grep -E "(ProductDetailView|NotificationsView)" | head -20Repository: Solarity-AI/intern-project-engineering
Length of output: 295
🏁 Script executed:
wc -l ./ios/ProductReview/Presentation/Views/Product/ProductDetailView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 156
🏁 Script executed:
sed -n '160,185p' ./ios/ProductReview/Presentation/Views/Product/ProductDetailView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 775
🏁 Script executed:
wc -l ./ios/ProductReview/Presentation/Views/Notification/NotificationsView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 161
🏁 Script executed:
grep -n "\.alert" ./ios/ProductReview/Presentation/Views/Notification/NotificationsView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 158
🏁 Script executed:
grep -n "\.constant" ./ios/ProductReview/Presentation/Views/Notification/NotificationsView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 158
🌐 Web query:
SwiftUI alert isPresented constant binding doesn't update
💡 Result:
Binding.constant(...) is read‑only. SwiftUI alerts rely on being able to write back to the binding (when the user dismisses the alert, SwiftUI sets isPresented back to false). If you pass a constant binding, that write can’t happen, so the alert state won’t update/dismiss correctly. [1]
Fix: drive the alert from mutable state (@State) or a real @Binding passed from a parent.
struct MyView: View {
`@State` private var showAlert = false
var body: some View {
Button("Show") { showAlert = true }
.alert("Title", isPresented: $showAlert) {
Button("OK", role: .cancel) { }
}
}
}Use Binding.constant(...) only for static previews / non-interactive cases, not for real presentation state. [2]
If this is inside a subview, make it a binding:
struct Child: View {
`@Binding` var showAlert: Bool
var body: some View { /* .alert(..., isPresented: $showAlert) */ }
}
// parent:
`@State` private var showAlert = false
Child(showAlert: $showAlert)Sources: [1], [2]
🏁 Script executed:
sed -n '1,50p' ./ios/ProductReview/Presentation/ViewModels/ProductDetailViewModel.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 1515
🏁 Script executed:
grep -n "error" ./ios/ProductReview/Presentation/ViewModels/ProductDetailViewModel.swift | head -10Repository: Solarity-AI/intern-project-engineering
Length of output: 474
Fix alert dismissal with computed binding instead of .constant.
The alert won't properly update when viewModel.error changes because .constant() creates a read-only binding. SwiftUI alerts require write-back capability to update their state when dismissed. Use a computed binding to synchronize the alert state with the viewModel:
.alert("Error", isPresented: Binding(
get: { viewModel.error != nil },
set: { if !$0 { viewModel.error = nil } }
)) {
Button("OK") { viewModel.error = nil }
} message: {
Text(viewModel.error ?? "")
}The same issue exists in NotificationsView and should be fixed there as well.
🤖 Prompt for AI Agents
In `@ios/ProductReview/Presentation/Views/Product/ProductDetailView.swift` around
lines 171 - 175, The alert uses a read-only binding via
.constant(viewModel.error != nil) so dismissals won't write back; replace that
with a computed Binding for the .alert isPresented parameter that gets
viewModel.error != nil and in set assigns nil to viewModel.error when the new
value is false, keeping the Button("OK") action as-is; make the identical change
for the alert usage in NotificationsView so both alerts properly update
viewModel.error on dismiss.
| var body: some View { | ||
| VStack(alignment: .leading, spacing: 12) { | ||
| Text("Reviews") | ||
| .font(.headline) | ||
|
|
||
| if reviews.isEmpty && !isLoading { | ||
| Text("No reviews yet") | ||
| .foregroundColor(.secondary) | ||
| .padding() | ||
| } | ||
|
|
||
| ForEach(reviews) { review in | ||
| ReviewCardView( | ||
| review: review, | ||
| isVoted: votedReviewIds.contains(review.id), | ||
| onHelpfulTap: { onHelpfulTap(review.id) } | ||
| ) | ||
| .onAppear { | ||
| if review.id == reviews.last?.id { | ||
| onLoadMore() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if isLoading { | ||
| ProgressView() | ||
| .frame(maxWidth: .infinity) | ||
| .padding() | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
wc -l ios/ProductReview/Presentation/Views/Product/ProductDetailView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 154
🏁 Script executed:
cat -n ios/ProductReview/Presentation/Views/Product/ProductDetailView.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 17391
🌐 Web query:
SwiftUI onAppear fires immediately non-lazy VStack versus LazyVStack when visible
💡 Result:
In SwiftUI, onAppear is not “became visible to the user”. It’s closer to “SwiftUI decided this view is now participating in rendering/layout”, and SwiftUI may call it before the first rendered frame (timing depends on the view type). [1]
Why VStack “fires immediately” in a ScrollView
With ScrollView { VStack { ... } }, all rows participate in the stack’s layout from the start, even those far off-screen. Because they’re part of the laid-out view tree, their onAppear can trigger right away. [2]
Why LazyVStack is closer to “when it becomes visible”
LazyVStack creates rows lazily, so rows generally initialize (and thus trigger onAppear) when they enter the display range. [3]
However, even LazyVStack may call onAppear a bit early due to prefetching/estimation during layout. [2]
If you need “actually on-screen” detection
Use geometry/preference-based visibility tracking (measure each row’s frame in a named coordinate space and compare against the scroll view’s visible bounds), because onAppear alone won’t reliably mean “visible to the user.” [2]
Sources: [1] [2] [3]
Replace VStack with LazyVStack to defer pagination trigger.
onAppear callbacks in a non-lazy VStack inside ScrollView fire eagerly during layout, not when the view becomes visible. This causes pagination to trigger prematurely as all review rows initialize at once. Use LazyVStack instead to defer initialization until rows actually enter the visible range.
✅ Suggested fix
- VStack(alignment: .leading, spacing: 12) {
+ LazyVStack(alignment: .leading, spacing: 12) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var body: some View { | |
| VStack(alignment: .leading, spacing: 12) { | |
| Text("Reviews") | |
| .font(.headline) | |
| if reviews.isEmpty && !isLoading { | |
| Text("No reviews yet") | |
| .foregroundColor(.secondary) | |
| .padding() | |
| } | |
| ForEach(reviews) { review in | |
| ReviewCardView( | |
| review: review, | |
| isVoted: votedReviewIds.contains(review.id), | |
| onHelpfulTap: { onHelpfulTap(review.id) } | |
| ) | |
| .onAppear { | |
| if review.id == reviews.last?.id { | |
| onLoadMore() | |
| } | |
| } | |
| } | |
| if isLoading { | |
| ProgressView() | |
| .frame(maxWidth: .infinity) | |
| .padding() | |
| } | |
| } | |
| } | |
| } | |
| var body: some View { | |
| LazyVStack(alignment: .leading, spacing: 12) { | |
| Text("Reviews") | |
| .font(.headline) | |
| if reviews.isEmpty && !isLoading { | |
| Text("No reviews yet") | |
| .foregroundColor(.secondary) | |
| .padding() | |
| } | |
| ForEach(reviews) { review in | |
| ReviewCardView( | |
| review: review, | |
| isVoted: votedReviewIds.contains(review.id), | |
| onHelpfulTap: { onHelpfulTap(review.id) } | |
| ) | |
| .onAppear { | |
| if review.id == reviews.last?.id { | |
| onLoadMore() | |
| } | |
| } | |
| } | |
| if isLoading { | |
| ProgressView() | |
| .frame(maxWidth: .infinity) | |
| .padding() | |
| } | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@ios/ProductReview/Presentation/Views/Product/ProductDetailView.swift` around
lines 273 - 304, The VStack in ProductDetailView's body causes all
ReviewCardView rows to initialize eagerly (so the onAppear pagination check
triggers too early); replace the VStack(alignment: .leading, spacing: 12) with
LazyVStack(alignment: .leading, spacing: 12) so rows are created lazily and
onAppear on the last review only fires when it actually scrolls into view,
leaving reviews, onLoadMore, votedReviewIds, and ReviewCardView usage unchanged.
| .alert("Error", isPresented: .constant(viewModel.error != nil)) { | ||
| Button("OK") { viewModel.error = nil } | ||
| } message: { | ||
| Text(viewModel.error ?? "") | ||
| } |
There was a problem hiding this comment.
Alert binding prevents dismissal.
Same issue as in WishlistView—using .constant(viewModel.error != nil) creates an immutable binding that SwiftUI cannot modify, causing the alert to reappear after dismissal.
🐛 Proposed fix
- .alert("Error", isPresented: .constant(viewModel.error != nil)) {
- Button("OK") { viewModel.error = nil }
+ .alert("Error", isPresented: Binding(
+ get: { viewModel.error != nil },
+ set: { if !$0 { viewModel.error = nil } }
+ )) {
+ Button("OK") { }
} message: {
Text(viewModel.error ?? "")
}🤖 Prompt for AI Agents
In `@ios/ProductReview/Presentation/Views/Product/ProductListView.swift` around
lines 107 - 111, The alert uses an immutable binding (.constant(viewModel.error
!= nil)) so SwiftUI can't clear viewModel.error on dismiss; replace that
.constant with a computed Binding<Bool> (e.g., a Binding whose get returns
viewModel.error != nil and whose set clears viewModel.error when set to false)
so the alert can be dismissed; update the alert call in ProductListView (the
modifier using viewModel.error) to use this mutable Binding.
| .alert("Error", isPresented: .constant(viewModel.error != nil)) { | ||
| Button("OK") { viewModel.error = nil } | ||
| } message: { | ||
| Text(viewModel.error ?? "") | ||
| } |
There was a problem hiding this comment.
Alert binding prevents dismissal.
Using .constant(viewModel.error != nil) creates an immutable binding—SwiftUI cannot set it to false when the alert is dismissed. The alert will reappear immediately after tapping "OK".
🐛 Proposed fix using a computed Binding
- .alert("Error", isPresented: .constant(viewModel.error != nil)) {
- Button("OK") { viewModel.error = nil }
+ .alert("Error", isPresented: Binding(
+ get: { viewModel.error != nil },
+ set: { if !$0 { viewModel.error = nil } }
+ )) {
+ Button("OK") { }
} message: {
Text(viewModel.error ?? "")
}🤖 Prompt for AI Agents
In `@ios/ProductReview/Presentation/Views/Wishlist/WishlistView.swift` around
lines 99 - 103, The alert currently uses an immutable binding
(.constant(viewModel.error != nil)) so SwiftUI can't clear it on dismissal;
change the .alert call to use a computed Binding (Binding(get:set:)) that
returns viewModel.error != nil and in the setter sets viewModel.error = nil when
false (or clears the error when the alert is dismissed), or alternatively use
.alert(item:) with an optional error model; update the .alert invocation in
WishlistView (the block referencing viewModel.error) to use that computed
binding so tapping "OK" actually clears viewModel.error and prevents the alert
from immediately reappearing.
| // When | ||
| let product = ProductMapper.toDomain(dto) | ||
|
|
||
| // Then | ||
| XCTAssertEqual(product.id, 1) | ||
| XCTAssertEqual(product.name, "Test Product") | ||
| XCTAssertEqual(product.price, 99.99) | ||
| } | ||
|
|
||
| func testReviewMapperMapsCorrectly() throws { | ||
| // Given | ||
| let dto = ReviewDTO( | ||
| id: 1, | ||
| productId: 1, | ||
| rating: 5, | ||
| title: "Great Product", | ||
| comment: "Highly recommended", | ||
| userName: "John", | ||
| createdAt: "2024-01-01T00:00:00", | ||
| helpfulCount: 10 | ||
| ) | ||
|
|
||
| // When | ||
| let review = ReviewMapper.toDomain(dto) | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, search for toDomain usage/definitions
echo "=== Searching for toDomain in swift files ==="
rg -nP --type=swift '\btoDomain\b' ios
echo -e "\n=== Looking for ProductMapper definition ==="
rg -l "ProductMapper" ios --type=swift | head -5
echo -e "\n=== Looking for ReviewMapper definition ==="
rg -l "ReviewMapper" ios --type=swift | head -5Repository: Solarity-AI/intern-project-engineering
Length of output: 788
🏁 Script executed:
#!/bin/bash
echo "=== ProductMapper.swift ==="
cat -n ios/ProductReview/Data/Mapper/ProductMapper.swift
echo -e "\n=== ReviewMapper.swift ==="
cat -n ios/ProductReview/Data/Mapper/ReviewMapper.swiftRepository: Solarity-AI/intern-project-engineering
Length of output: 2925
Fix mapper method names: compile error.
The test calls ProductMapper.toDomain(dto) and ReviewMapper.toDomain(dto) on lines 30 and 52, but these methods do not exist. Both mappers expose only map() methods. This will cause a compilation failure.
✅ Proposed fix
- let product = ProductMapper.toDomain(dto)
+ let product = ProductMapper.map(dto)
@@
- let review = ReviewMapper.toDomain(dto)
+ let review = ReviewMapper.map(dto)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // When | |
| let product = ProductMapper.toDomain(dto) | |
| // Then | |
| XCTAssertEqual(product.id, 1) | |
| XCTAssertEqual(product.name, "Test Product") | |
| XCTAssertEqual(product.price, 99.99) | |
| } | |
| func testReviewMapperMapsCorrectly() throws { | |
| // Given | |
| let dto = ReviewDTO( | |
| id: 1, | |
| productId: 1, | |
| rating: 5, | |
| title: "Great Product", | |
| comment: "Highly recommended", | |
| userName: "John", | |
| createdAt: "2024-01-01T00:00:00", | |
| helpfulCount: 10 | |
| ) | |
| // When | |
| let review = ReviewMapper.toDomain(dto) | |
| // When | |
| let product = ProductMapper.map(dto) | |
| // Then | |
| XCTAssertEqual(product.id, 1) | |
| XCTAssertEqual(product.name, "Test Product") | |
| XCTAssertEqual(product.price, 99.99) | |
| } | |
| func testReviewMapperMapsCorrectly() throws { | |
| // Given | |
| let dto = ReviewDTO( | |
| id: 1, | |
| productId: 1, | |
| rating: 5, | |
| title: "Great Product", | |
| comment: "Highly recommended", | |
| userName: "John", | |
| createdAt: "2024-01-01T00:00:00", | |
| helpfulCount: 10 | |
| ) | |
| // When | |
| let review = ReviewMapper.map(dto) |
🤖 Prompt for AI Agents
In `@ios/ProductReviewTests/ProductReviewTests.swift` around lines 29 - 53, Tests
call non-existent ProductMapper.toDomain(dto) and ReviewMapper.toDomain(dto);
update the test calls to use the existing mapper API by replacing
ProductMapper.toDomain(dto) with ProductMapper.map(dto) and
ReviewMapper.toDomain(dto) with ReviewMapper.map(dto), ensuring the DTO variable
names remain the same so the tests compile against the current mapper methods.
Summary by CodeRabbit
Release Notes
New Features
Documentation