Fix all build errors in Xcode-managed nested source files

The Xcode project was created inside the existing folder structure,
producing a second copy of all source files at the deeper nested path
that Xcode actually compiles. All fixes are now applied to both paths.

Changes:
- Add `import Combine` to nested ViewModels (Portfolio, Recommendations,
  Positions, Alerts) and NotificationHandler — required for @Published
- Add `import UIKit` to NotificationPermissions — UIApplication is UIKit
- Rewrite APIClient to use `(any Encodable)?` instead of invalid
  `(some Encodable)?` syntax; add encodeAny() helper to open existential
  for JSONEncoder; remove private EmptyBody type
- Replace all `body: Optional<String>.none` / `Optional<EmptyBody>.none`
  call sites with plain `nil` across all ViewModels and Views
- Sync all fixes between nested Xcode path and outer source path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 15:51:09 -04:00
parent 14d715ed14
commit 080f10f1c5
20 changed files with 87 additions and 56 deletions

View File

@@ -19,7 +19,6 @@ final class APIClient {
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
// Try ISO8601 with fractional seconds first, then without
let formatters: [ISO8601DateFormatter] = [
{
let f = ISO8601DateFormatter()
@@ -35,11 +34,12 @@ final class APIClient {
for fmt in formatters {
if let date = fmt.date(from: str) { return date }
}
// Try plain date (YYYY-MM-DD) for date-only fields decoded as Date
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
if let date = df.date(from: str) { return date }
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot parse date: \(str)"))
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath,
debugDescription: "Cannot parse date: \(str)"))
}
return d
}()
@@ -54,7 +54,7 @@ final class APIClient {
func request<T: Decodable>(
_ endpoint: Endpoint,
body: (some Encodable)? = Optional<EmptyBody>.none
body: (any Encodable)? = nil
) async throws -> T {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
@@ -66,7 +66,7 @@ final class APIClient {
}
/// Version that doesn't return a body (e.g. DELETE 204)
func requestVoid(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws {
func requestVoid(_ endpoint: Endpoint, body: (any Encodable)? = nil) async throws {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
@@ -76,7 +76,10 @@ final class APIClient {
}
/// For device registration (no token yet)
func requestNoAuth<T: Decodable>(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws -> T {
func requestNoAuth<T: Decodable>(
_ endpoint: Endpoint,
body: (any Encodable)? = nil
) async throws -> T {
let req = try buildRequest(endpoint, deviceToken: nil, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
@@ -85,7 +88,11 @@ final class APIClient {
// Helpers
private func buildRequest(_ endpoint: Endpoint, deviceToken: String?, body: (some Encodable)?) throws -> URLRequest {
private func buildRequest(
_ endpoint: Endpoint,
deviceToken: String?,
body: (any Encodable)?
) throws -> URLRequest {
guard let url = URL(string: Constants.apiBaseURL + endpoint.path) else {
throw APIError.invalidURL
}
@@ -96,11 +103,19 @@ final class APIClient {
request.setValue(token, forHTTPHeaderField: "X-Device-Token")
}
if let body {
request.httpBody = try encoder.encode(body)
request.httpBody = try encodeAny(body)
}
return request
}
/// Opens the `any Encodable` existential so JSONEncoder can encode it.
private func encodeAny(_ value: any Encodable) throws -> Data {
func encode<T: Encodable>(_ v: T) throws -> Data {
try encoder.encode(v)
}
return try encode(value)
}
private func validateResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
@@ -121,8 +136,6 @@ final class APIClient {
// Supporting types
private struct EmptyBody: Encodable {}
enum APIError: LocalizedError {
case noDeviceToken
case invalidURL

View File

@@ -1,4 +1,5 @@
import Foundation
import UIKit
import UserNotifications
@MainActor

View File

@@ -42,9 +42,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
apns_token: token,
device_name: UIDevice.current.name
)
// Temporarily set the token so APIClient has it, but use requestNoAuth
// since this is the registration call itself
let old = LocalStore.shared.deviceToken
// Ensure token is stored before the request so APIClient can use it
LocalStore.shared.deviceToken = token
let response: DeviceResponse = try await APIClient.shared.requestNoAuth(
.registerDevice,

View File

@@ -19,7 +19,6 @@ final class APIClient {
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
// Try ISO8601 with fractional seconds first, then without
let formatters: [ISO8601DateFormatter] = [
{
let f = ISO8601DateFormatter()
@@ -35,11 +34,12 @@ final class APIClient {
for fmt in formatters {
if let date = fmt.date(from: str) { return date }
}
// Try plain date (YYYY-MM-DD) for date-only fields decoded as Date
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
if let date = df.date(from: str) { return date }
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot parse date: \(str)"))
throw DecodingError.dataCorrupted(
.init(codingPath: decoder.codingPath,
debugDescription: "Cannot parse date: \(str)"))
}
return d
}()
@@ -54,7 +54,7 @@ final class APIClient {
func request<T: Decodable>(
_ endpoint: Endpoint,
body: (some Encodable)? = Optional<EmptyBody>.none
body: (any Encodable)? = nil
) async throws -> T {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
@@ -66,7 +66,7 @@ final class APIClient {
}
/// Version that doesn't return a body (e.g. DELETE 204)
func requestVoid(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws {
func requestVoid(_ endpoint: Endpoint, body: (any Encodable)? = nil) async throws {
guard let token = LocalStore.shared.deviceToken else {
throw APIError.noDeviceToken
}
@@ -76,7 +76,10 @@ final class APIClient {
}
/// For device registration (no token yet)
func requestNoAuth<T: Decodable>(_ endpoint: Endpoint, body: (some Encodable)? = Optional<EmptyBody>.none) async throws -> T {
func requestNoAuth<T: Decodable>(
_ endpoint: Endpoint,
body: (any Encodable)? = nil
) async throws -> T {
let req = try buildRequest(endpoint, deviceToken: nil, body: body)
let (data, response) = try await session.data(for: req)
try validateResponse(response, data: data)
@@ -85,7 +88,11 @@ final class APIClient {
// Helpers
private func buildRequest(_ endpoint: Endpoint, deviceToken: String?, body: (some Encodable)?) throws -> URLRequest {
private func buildRequest(
_ endpoint: Endpoint,
deviceToken: String?,
body: (any Encodable)?
) throws -> URLRequest {
guard let url = URL(string: Constants.apiBaseURL + endpoint.path) else {
throw APIError.invalidURL
}
@@ -96,11 +103,19 @@ final class APIClient {
request.setValue(token, forHTTPHeaderField: "X-Device-Token")
}
if let body {
request.httpBody = try encoder.encode(body)
request.httpBody = try encodeAny(body)
}
return request
}
/// Opens the `any Encodable` existential so JSONEncoder can encode it.
private func encodeAny(_ value: any Encodable) throws -> Data {
func encode<T: Encodable>(_ v: T) throws -> Data {
try encoder.encode(v)
}
return try encode(value)
}
private func validateResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
@@ -121,8 +136,6 @@ final class APIClient {
// Supporting types
private struct EmptyBody: Encodable {}
enum APIError: LocalizedError {
case noDeviceToken
case invalidURL

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
import UserNotifications
/// Handles incoming push notifications both foreground and background tap.

View File

@@ -1,4 +1,5 @@
import Foundation
import UIKit
import UserNotifications
@MainActor

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
@MainActor
final class AlertsViewModel: ObservableObject {
@@ -14,7 +15,7 @@ final class AlertsViewModel: ObservableObject {
do {
alerts = try await APIClient.shared.request(
.getAlerts(unreadOnly: unreadOnly),
body: Optional<String>.none
body: nil
)
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
@@ -27,7 +28,7 @@ final class AlertsViewModel: ObservableObject {
do {
let updated: AppAlert = try await APIClient.shared.request(
.acknowledgeAlert(alert.id),
body: Optional<String>.none
body: nil
)
if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
alerts[idx] = updated

View File

@@ -45,18 +45,18 @@ final class DashboardViewModel: ObservableObject {
// Private loaders
private func loadStocks() async -> [PortfolioPosition] {
(try? await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getPortfolio, body: nil)) ?? []
}
private func loadOptions() async -> [OptionPosition] {
(try? await APIClient.shared.request(.getPositions(status: "open"), body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getPositions(status: "open"), body: nil)) ?? []
}
private func loadRecommendations() async -> [Recommendation] {
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: nil)) ?? []
}
private func loadAlerts() async -> [AppAlert] {
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: nil)) ?? []
}
}

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
@MainActor
final class PortfolioViewModel: ObservableObject {
@@ -10,7 +11,7 @@ final class PortfolioViewModel: ObservableObject {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)
positions = try await APIClient.shared.request(.getPortfolio, body: nil)
} catch {
self.error = error.localizedDescription
}
@@ -35,7 +36,7 @@ final class PortfolioViewModel: ObservableObject {
}
func add(ticker: String, shares: Int, costBasis: Double?) async {
var updated = positions
let updated = positions
struct AddBody: Encodable {
let ticker: String
let shares: Int
@@ -57,7 +58,7 @@ final class PortfolioViewModel: ObservableObject {
isLoading = true
error = nil
do {
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: Optional<String>.none)
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: nil)
positions.removeAll { $0.ticker == ticker }
} catch {
self.error = error.localizedDescription

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
@MainActor
final class PositionsViewModel: ObservableObject {
@@ -15,7 +16,7 @@ final class PositionsViewModel: ObservableObject {
do {
positions = try await APIClient.shared.request(
.getPositions(status: nil),
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription

View File

@@ -1,4 +1,5 @@
import Foundation
import Combine
@MainActor
final class RecommendationsViewModel: ObservableObject {
@@ -21,7 +22,7 @@ final class RecommendationsViewModel: ObservableObject {
do {
recommendations = try await APIClient.shared.request(
.getRecommendations(timeHorizon: nil),
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription
@@ -35,7 +36,7 @@ final class RecommendationsViewModel: ObservableObject {
do {
recommendations = try await APIClient.shared.request(
.refreshRecommendations,
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription
@@ -47,7 +48,7 @@ final class RecommendationsViewModel: ObservableObject {
do {
return try await APIClient.shared.request(
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription

View File

@@ -22,7 +22,7 @@ struct OpenPositionsView: View {
if !vm.openPositions.isEmpty {
Section("Open") {
ForEach(vm.openPositions) { position in
NavigationLink(destination: PositionDetailView(position: position, vm: vm)) {
NavigationLink(destination: PositionDetailView(position: position, parentVM: vm)) {
LoggedPositionRow(position: position)
}
}

View File

@@ -2,17 +2,17 @@ import SwiftUI
struct PositionDetailView: View {
let position: OptionPosition
@ObservedObject var vm: PositionsViewModel
/// Pass in from parent (OpenPositionsView) so close/roll updates the parent list.
/// If nil, a local vm is used (e.g. when navigating from DashboardView).
var parentVM: PositionsViewModel? = nil
@StateObject private var localVM = PositionsViewModel()
@State private var signals: SignalSnapshot? = nil
@State private var isLoadingSignals = false
@State private var showCloseConfirm = false
@State private var showRollConfirm = false
@Environment(\.dismiss) var dismiss
init(position: OptionPosition, vm: PositionsViewModel = PositionsViewModel()) {
self.position = position
self.vm = vm
}
private var vm: PositionsViewModel { parentVM ?? localVM }
var body: some View {
ScrollView {
@@ -146,7 +146,7 @@ struct PositionDetailView: View {
do {
signals = try await APIClient.shared.request(
.getSignals(position.ticker),
body: Optional<String>.none
body: nil
)
} catch {
// Non-critical just don't show signals

View File

@@ -15,7 +15,7 @@ final class AlertsViewModel: ObservableObject {
do {
alerts = try await APIClient.shared.request(
.getAlerts(unreadOnly: unreadOnly),
body: Optional<String>.none
body: nil
)
LocalStore.shared.unreadAlertCount = unreadCount
} catch {
@@ -28,7 +28,7 @@ final class AlertsViewModel: ObservableObject {
do {
let updated: AppAlert = try await APIClient.shared.request(
.acknowledgeAlert(alert.id),
body: Optional<String>.none
body: nil
)
if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
alerts[idx] = updated

View File

@@ -45,18 +45,18 @@ final class DashboardViewModel: ObservableObject {
// Private loaders
private func loadStocks() async -> [PortfolioPosition] {
(try? await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getPortfolio, body: nil)) ?? []
}
private func loadOptions() async -> [OptionPosition] {
(try? await APIClient.shared.request(.getPositions(status: "open"), body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getPositions(status: "open"), body: nil)) ?? []
}
private func loadRecommendations() async -> [Recommendation] {
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getRecommendations(timeHorizon: nil), body: nil)) ?? []
}
private func loadAlerts() async -> [AppAlert] {
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: Optional<String>.none)) ?? []
(try? await APIClient.shared.request(.getAlerts(unreadOnly: true), body: nil)) ?? []
}
}

View File

@@ -11,7 +11,7 @@ final class PortfolioViewModel: ObservableObject {
isLoading = true
error = nil
do {
positions = try await APIClient.shared.request(.getPortfolio, body: Optional<String>.none)
positions = try await APIClient.shared.request(.getPortfolio, body: nil)
} catch {
self.error = error.localizedDescription
}
@@ -58,7 +58,7 @@ final class PortfolioViewModel: ObservableObject {
isLoading = true
error = nil
do {
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: Optional<String>.none)
try await APIClient.shared.requestVoid(.deleteTicker(ticker), body: nil)
positions.removeAll { $0.ticker == ticker }
} catch {
self.error = error.localizedDescription

View File

@@ -16,7 +16,7 @@ final class PositionsViewModel: ObservableObject {
do {
positions = try await APIClient.shared.request(
.getPositions(status: nil),
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription

View File

@@ -22,7 +22,7 @@ final class RecommendationsViewModel: ObservableObject {
do {
recommendations = try await APIClient.shared.request(
.getRecommendations(timeHorizon: nil),
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription
@@ -36,7 +36,7 @@ final class RecommendationsViewModel: ObservableObject {
do {
recommendations = try await APIClient.shared.request(
.refreshRecommendations,
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription
@@ -48,7 +48,7 @@ final class RecommendationsViewModel: ObservableObject {
do {
return try await APIClient.shared.request(
.getRecommendation(ticker: ticker, strategy: selectedStrategy, timeHorizon: selectedHorizon),
body: Optional<String>.none
body: nil
)
} catch {
self.error = error.localizedDescription

View File

@@ -146,7 +146,7 @@ struct PositionDetailView: View {
do {
signals = try await APIClient.shared.request(
.getSignals(position.ticker),
body: Optional<String>.none
body: nil
)
} catch {
// Non-critical just don't show signals