Skip to content

Commit 298443f

Browse files
larpeyclaude
andcommitted
Fix sync bugs and add fuel burn, warnings, presets, PDF export
Bug fixes: - Fix SyncService station/fuel load serialization (wrong JSON format) - Fix Google Sign-In passing empty idToken (now uses GIDSignIn SDK) - Add auto-sync after login via onAuthenticated callback - Add background sync on app resume via scenePhase observer Features: - Fuel burn CG trajectory: slider input, landing weight/CG calculation, dashed trajectory line on CG envelope chart with animated landing point - Actionable remediation warnings: suggest specific fuel/weight reductions when over max gross (e.g. "Remove 8 gal fuel OR reduce seats by 47 lbs") - FAA AC 120-27F passenger weight presets: seasonal adult M/F/child buttons on seat station rows (winter Nov-Mar, summer Apr-Oct) - PDF load sheet export: professional W&B document with aircraft info, breakdown table, warnings, and share sheet integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b3c554 commit 298443f

13 files changed

Lines changed: 770 additions & 48 deletions

File tree

ios/PreFlightWB/PreFlightWB/App/PreFlightWBApp.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import SwiftData
44
@main
55
struct PreFlightWBApp: App {
66
@State private var authManager = AuthManager()
7+
@Environment(\.scenePhase) private var scenePhase
78

89
let modelContainer: ModelContainer
910

@@ -21,7 +22,27 @@ struct PreFlightWBApp: App {
2122
WindowGroup {
2223
ContentView()
2324
.environment(authManager)
25+
.onAppear {
26+
let container = modelContainer
27+
authManager.onAuthenticated = {
28+
await syncQuietly(container: container)
29+
}
30+
}
2431
}
2532
.modelContainer(modelContainer)
33+
.onChange(of: scenePhase) { _, newPhase in
34+
if newPhase == .active, authManager.isAuthenticated {
35+
Task { @MainActor in
36+
await syncQuietly(container: modelContainer)
37+
}
38+
}
39+
}
40+
}
41+
42+
@MainActor
43+
private func syncQuietly(container: ModelContainer) async {
44+
let context = container.mainContext
45+
let syncService = SyncService(modelContext: context)
46+
_ = try? await syncService.syncScenarios()
2647
}
2748
}

ios/PreFlightWB/PreFlightWB/Engine/Calculator.swift

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,20 @@ enum Calculator {
169169

170170
// OVER_MAX_GROSS or NEAR_MAX_GROSS
171171
if !isWithinWeightLimit {
172+
let excess = totalWeight - maxGross
173+
let remediation = buildWeightRemediation(
174+
excess: excess,
175+
stationDetails: stationDetails,
176+
fuelDetails: fuelDetails,
177+
fuelTanks: aircraft.fuelTanks
178+
)
172179
warnings.append(CalculationWarning(
173180
level: .danger,
174181
code: .overMaxGross,
175182
message: "Aircraft exceeds maximum takeoff weight",
176-
detail: "\(formatDecimal(totalWeight, decimals: 1)) lbs exceeds limit of \(formatWithComma(maxGross)) lbs by \(formatDecimal(totalWeight - maxGross, decimals: 1)) lbs",
177-
regulatoryRef: "FAR 91.103"
183+
detail: "\(formatDecimal(totalWeight, decimals: 1)) lbs exceeds limit of \(formatWithComma(maxGross)) lbs by \(formatDecimal(excess, decimals: 1)) lbs",
184+
regulatoryRef: "FAR 91.103",
185+
remediation: remediation
178186
))
179187
} else if weightMargin < maxGross * 0.05 {
180188
warnings.append(CalculationWarning(
@@ -264,6 +272,134 @@ enum Calculator {
264272
)
265273
}
266274

275+
// MARK: - Landing Calculation
276+
277+
/// Calculate landing weight and CG after fuel burn.
278+
///
279+
/// Distributes the fuel burn proportionally across all tanks based on
280+
/// their current load. Returns nil if no fuel burn is specified.
281+
///
282+
/// - Parameters:
283+
/// - takeoffResult: The takeoff calculation result.
284+
/// - aircraft: The aircraft definition.
285+
/// - fuelLoads: Current fuel loads in each tank.
286+
/// - fuelBurnGallons: Total gallons expected to burn during flight.
287+
/// - Returns: A `LandingResult` with landing weight, CG, and any warnings.
288+
static func calculateLanding(
289+
takeoffResult: CalculationResult,
290+
aircraft: Aircraft,
291+
fuelLoads: [FuelLoad],
292+
fuelBurnGallons: Double
293+
) -> LandingResult? {
294+
guard fuelBurnGallons > 0 else { return nil }
295+
296+
let totalFuelGallons = fuelLoads.reduce(0.0) { $0 + max(0, $1.gallons) }
297+
guard totalFuelGallons > 0 else { return nil }
298+
299+
// Distribute burn proportionally across tanks
300+
var burnedWeight = 0.0
301+
var burnedMoment = 0.0
302+
var warnings: [CalculationWarning] = []
303+
304+
let actualBurn = min(fuelBurnGallons, totalFuelGallons)
305+
if fuelBurnGallons > totalFuelGallons {
306+
warnings.append(CalculationWarning(
307+
level: .danger,
308+
code: .negativeFuel,
309+
message: "Fuel burn exceeds loaded fuel",
310+
detail: "Planned burn of \(formatDecimal(fuelBurnGallons, decimals: 0)) gal exceeds \(formatDecimal(totalFuelGallons, decimals: 0)) gal loaded",
311+
regulatoryRef: nil
312+
))
313+
}
314+
315+
for load in fuelLoads {
316+
guard let tank = aircraft.fuelTanks.first(where: { $0.id == load.tankId }) else { continue }
317+
let proportion = load.gallons / totalFuelGallons
318+
let tankBurnGallons = actualBurn * proportion
319+
let tankBurnWeight = tankBurnGallons * tank.fuelWeightPerGallon
320+
burnedWeight += tankBurnWeight
321+
burnedMoment += tankBurnWeight * tank.arm.value
322+
}
323+
324+
let landingWeight = takeoffResult.totalWeight - burnedWeight
325+
let landingMoment = takeoffResult.totalMoment - burnedMoment
326+
let landingCG = landingWeight > 0 ? landingMoment / landingWeight : 0
327+
328+
let isWithinEnvelope = Envelope.isPointInEnvelope(
329+
weight: landingWeight,
330+
cg: landingCG,
331+
envelope: aircraft.cgEnvelope
332+
)
333+
let isWithinWeightLimit = landingWeight <= aircraft.maxGrossWeight.value
334+
335+
// Check landing weight limit if aircraft has one
336+
if let maxLanding = aircraft.maxLandingWeight {
337+
if landingWeight > maxLanding.value {
338+
warnings.append(CalculationWarning(
339+
level: .warning,
340+
code: .overMaxLanding,
341+
message: "Landing weight exceeds max landing weight",
342+
detail: "\(formatDecimal(landingWeight, decimals: 0)) lbs exceeds landing limit of \(formatWithComma(maxLanding.value)) lbs",
343+
regulatoryRef: nil
344+
))
345+
}
346+
}
347+
348+
if !isWithinEnvelope {
349+
warnings.append(CalculationWarning(
350+
level: .danger,
351+
code: .cgOutOfEnvelope,
352+
message: "Landing CG outside approved envelope",
353+
detail: "CG shifts to \(formatDecimal(landingCG, decimals: 2)) in after \(formatDecimal(actualBurn, decimals: 0)) gal fuel burn",
354+
regulatoryRef: "FAR 91.103"
355+
))
356+
}
357+
358+
return LandingResult(
359+
landingWeight: landingWeight,
360+
landingCG: landingCG,
361+
fuelBurnGallons: actualBurn,
362+
fuelBurnWeight: burnedWeight,
363+
isWithinCGEnvelope: isWithinEnvelope,
364+
isWithinWeightLimit: isWithinWeightLimit,
365+
warnings: warnings
366+
)
367+
}
368+
369+
// MARK: - Remediation Helpers
370+
371+
/// Build actionable remediation text for an overweight condition.
372+
/// Suggests removing fuel or reducing the heaviest loaded station.
373+
private static func buildWeightRemediation(
374+
excess: Double,
375+
stationDetails: [StationDetail],
376+
fuelDetails: [FuelDetail],
377+
fuelTanks: [FuelTank]
378+
) -> String {
379+
var suggestions: [String] = []
380+
381+
// Suggest fuel reduction for each tank that has fuel
382+
for detail in fuelDetails where detail.weight > 0 {
383+
if let tank = fuelTanks.first(where: { $0.id == detail.tankId }) {
384+
let gallonsToRemove = ceil(excess / tank.fuelWeightPerGallon)
385+
if gallonsToRemove <= detail.gallons {
386+
suggestions.append("Remove \(formatDecimal(gallonsToRemove, decimals: 0)) gal from \(detail.name)")
387+
}
388+
}
389+
}
390+
391+
// Suggest reducing the heaviest station
392+
if let heaviest = stationDetails.filter({ $0.weight > 0 }).max(by: { $0.weight < $1.weight }),
393+
heaviest.weight >= excess {
394+
suggestions.append("Reduce \(heaviest.name) by \(formatDecimal(excess, decimals: 0)) lbs")
395+
}
396+
397+
if suggestions.isEmpty {
398+
return "Reduce total load by \(formatDecimal(excess, decimals: 0)) lbs"
399+
}
400+
return suggestions.prefix(2).joined(separator: " OR ")
401+
}
402+
267403
// MARK: - Formatting Helpers
268404

269405
/// Format a number with a fixed number of decimal places.

ios/PreFlightWB/PreFlightWB/Models/Calculation.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ struct CalculationWarning: Codable, Sendable {
8080
let message: String
8181
let detail: String?
8282
let regulatoryRef: String?
83+
let remediation: String?
84+
85+
init(level: WarningLevel, code: WarningCode, message: String, detail: String?, regulatoryRef: String?, remediation: String? = nil) {
86+
self.level = level
87+
self.code = code
88+
self.message = message
89+
self.detail = detail
90+
self.regulatoryRef = regulatoryRef
91+
self.remediation = remediation
92+
}
8393
}
8494

8595
// MARK: - Calculation Result
@@ -103,3 +113,16 @@ struct CalculationResult: Codable, Sendable {
103113

104114
let warnings: [CalculationWarning]
105115
}
116+
117+
// MARK: - Landing Result
118+
119+
/// Result of a landing weight & CG calculation after fuel burn.
120+
struct LandingResult: Sendable {
121+
let landingWeight: Double
122+
let landingCG: Double
123+
let fuelBurnGallons: Double
124+
let fuelBurnWeight: Double
125+
let isWithinCGEnvelope: Bool
126+
let isWithinWeightLimit: Bool
127+
let warnings: [CalculationWarning]
128+
}

ios/PreFlightWB/PreFlightWB/Services/AuthManager.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ final class AuthManager {
2727
/// Non-nil when the most recent auth action failed.
2828
var error: String?
2929

30+
/// Called after successful authentication to trigger sync.
31+
var onAuthenticated: (() async -> Void)?
32+
3033
// MARK: - Keys
3134

3235
private static let guestKey = "preflight_guest_mode"
@@ -56,6 +59,7 @@ final class AuthManager {
5659

5760
currentUser = user
5861
isAuthenticated = true
62+
await onAuthenticated?()
5963

6064
// Check Apple credential state if session was established via Apple Sign-In
6165
if UserDefaults.standard.bool(forKey: Self.appleAuthKey),
@@ -94,6 +98,7 @@ final class AuthManager {
9498
currentUser = response.user
9599
isAuthenticated = true
96100
isGuest = false
101+
await onAuthenticated?()
97102
} catch {
98103
self.error = "Google sign-in failed. Please try again."
99104
}
@@ -135,6 +140,7 @@ final class AuthManager {
135140
isGuest = false
136141
UserDefaults.standard.set(true, forKey: Self.appleAuthKey)
137142
UserDefaults.standard.set(appleUserId, forKey: Self.appleUserIdKey)
143+
await onAuthenticated?()
138144
} catch {
139145
self.error = "Apple sign-in failed. Please try again."
140146
}
@@ -183,6 +189,7 @@ final class AuthManager {
183189
currentUser = response.user
184190
isAuthenticated = true
185191
isGuest = false
192+
await onAuthenticated?()
186193
} catch {
187194
self.error = "Invalid or expired code. Please try again."
188195
throw error

0 commit comments

Comments
 (0)