diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 97106ed7..3be9908d 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -155,7 +155,10 @@ slow. store is injected as a no-op or skipped at the call site (Spotlight indexing in `AppDelegate`), and Settings hides the groups that would reach past it (`SettingsDestination.isAvailableInDemoMode`). A new persisting surface needs - the same treatment. + the same treatment. The user-requested annual PDF export is the sole explicit + exception: it may create/share a temporary sample document, but its demo + filename and metadata say demo and every page carries a non-removable + `SAMPLE — DEMO DATA` watermark. - **`WhereModel` decides when a scope routes its logs.** A scope holds its log store from birth and routes only while active, so one that opens while shadowed is remembered rather than attached. Guard: diff --git a/Where/TODOs.md b/Where/TODOs.md index d2bf0cbc..96f36cdd 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -22,6 +22,7 @@ The item format and the placement rule live in the root - perf(WhereCore) [needs-design]: Performance pass — how often is the app booting? Can we only do it on changes of, say, 1 km or more? (human) ## P1s (Should do) +- feat(Where) [needs-design]: Add a January 1 “Your last year at a glance” annual recap — each year, surface a dismissible in-app banner that opens a native previous-year summary, deliver one owned local notification, and offer the existing annual PDF export from that summary. `MainTabs` owns foreground activation and top-level presentation (`WhereUI/Sources/MainTabs.swift:41-83`); the existing notification schedulers demonstrate stable identifiers plus calendar triggers (`WhereCore/Sources/Reminders/DailySummaryScheduler.swift:56-66`, `WhereCore/Sources/Reminders/LoggingReminderScheduler.swift:248-263`); and the completed export path already reads one captured annual audit and renders/shares it (`WhereCore/Sources/Reporting/ReportReader.swift:48-84`, `WhereUI/Sources/Model/YearExportModel.swift:113-158`, `WhereUI/Sources/Year/YearView.swift:57-88`). Add a Core-owned annual-recap eligibility/scheduling collaborator and a UI model/view that reuse those report/export semantics instead of building a second summary pipeline; persist the last presented or acknowledged recap year through `WherePreferences` (`WhereCore/Sources/Preferences/WherePreferences.swift:102-127`) so foregrounding cannot repeat the banner. Decide the availability window (January 1 only versus first foreground afterward), dismissal/replay behavior, notification-authorization policy, and tap routing; cover Gregorian year rollover/time zones, idempotent scheduling, acknowledgement, empty/demo data, native summary rendering, and the PDF handoff with Core, UI, notification, and snapshot tests. (human 2026-08-01) - refactor(WhereCore) [needs-design]: Scope diagnostic emission so Flyover's unactivated sibling demo world cannot write its activity through the process-global `WhereLog` / `Periscope.shared` facade into the active real scope's durable diagnostic store. `WhereFlyoverWorld.build()` correctly gives the sibling a private `Periscope` and never starts its sink, but static `WhereLog` channels still bypass that injection; carry the scope's logging system through services/models or add a task-/environment-scoped routing context before treating Flyover's diagnostic activity as isolated. Domain data, preferences, widgets, notifications, and location remain in memory/no-op already. (`WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift`, `WhereCore/Sources/Logging/WhereLog.swift`; agent 2026-07-29) - fix(WhereUI) [quick-win]: `CalendarDay.displayDate` resolves through `Calendar.current` (`DateRangeFormatting.swift:33`), so every day label that flows through it — relabel, logged days, resolution details, the region drill-in — renders a wrong date on a non-Gregorian device: `startOfDay(in:)` interprets the day's Gregorian Y-M-D as *that* calendar's components, so a Buddhist-era device resolves 2026-07-26 to a date ~543 years off. `DateRangeFormatting.abbreviated` (`:6`, `:19`) and `PresenceTimeline.stints` (`PresenceTimeline.swift:37`) also *default* to `.current`, and `PresenceTimelineList` (`:12`) doesn't pass `report.calendar`. Take an explicit calendar (Gregorian + current time zone) in the helper and thread the report's calendar from the call sites. The `where.gregorian_calendar` Bumper rule that should catch this is blind to the implicit-member form — filed in the root [`TODOs.md`](../TODOs.md). (audit 2026-07-26) - fix(WhereCore) [needs-design]: `WhereServices.setPrimaryRegions(_:)` (`:285`) commits atomically but skips `DayJournal.reconcileAfterDayChange()` — widgets/reminders/summary don't refresh until foreground/configure. Route picker commits through the unified fan-out, or document the intentional deferral. (audit 2026-07-26) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index f295fc6e..a22851a8 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -32,6 +32,10 @@ internal shape. `WhereServices`; the separate DEBUG Inspector runtime uses `SwiftDataStore.makeContainer`, `inspectorModelTypes`, and `inspectorStoreURL` as its schema/storage adapter. +- **Annual audits are one captured policy snapshot.** Read through + `WhereStore.auditRecords(in:manualDays:)`, then attribute every row and total + against that value's tracked-region set; never compose an export from + separately timed UI reads. - **Each process opens its on-disk store once and injects it** — the app's launch opens it; the App Intents stack shares it via `WhereServices.forIntents(sharingStoreOf:)`. A second container over the diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index c8a4acc5..638d9fb9 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -61,7 +61,13 @@ one it belongs to rather than to a god-object: - **`ReportReader`** — the pure read path: `yearReport(for:)`, the year's raw manual entries `manualDays(inYear:)`, per-region `locations(in:year:)`, and - `representativeCoordinates(for:)`. + `representativeCoordinates(for:)`. `auditReport(for:)` returns a + `YearAuditReport`: one consistent annual snapshot containing the finalized + report, attributed samples, manual/evidence metadata, reporting timezone, + tracked-region set, and boundary provenance. Production obtains its + `YearAuditRecords` through one `SwiftDataStore` actor turn/read context and + freezes the region set into an immutable attributor before computing rows and + totals. - **`YearReport` / `DayPresence` / `RegionDayLocations`** — the aggregated, snapshot-stable value types the UI renders, each keyed by a timezone-independent **`CalendarDay`** (`DayPresence.day`). A day counts for a @@ -160,9 +166,22 @@ let services = try await WhereServices.make( // Read a year, aggregated with the injected calendar + region attribution. let report = try await services.reports.yearReport(for: 2026) +// Read a transparent audit snapshot whose source rows and totals share one +// captured attribution policy. +let annualAuditReport = try await services.reports.auditReport(for: 2026) + // Write a manual day (the caller supplies the ManualEntryAudit); the journal // commits, then reconciles reminders + widgets. -try await services.journal.addManualDay(date: day, regions: [.california], audit: audit) +let manualEntryAudit = ManualEntryAudit( + recordedAt: Date(), + note: "Backfilled from travel records.", + location: nil, +) +try await services.journal.addManualDay( + date: day, + regions: [.california], + audit: manualEntryAudit, +) // Refresh whenever anything changes — local edits, live GPS, or a synced import. for await _ in services.dataChangeUpdates() { diff --git a/Where/WhereCore/Sources/Logging/ReportReaderLog.swift b/Where/WhereCore/Sources/Logging/ReportReaderLog.swift index 0b0c0bc7..ff595906 100644 --- a/Where/WhereCore/Sources/Logging/ReportReaderLog.swift +++ b/Where/WhereCore/Sources/Logging/ReportReaderLog.swift @@ -14,6 +14,8 @@ struct ReportReaderLog: LogEvent { enum SpanName: Hashable { /// The workhorse: a year's samples + manual days, aggregated. case yearReport + /// One annual audit snapshot plus attribution against its captured set. + case auditReport /// The single-read bundle a data-issue scan needs. case dataIssueReads /// A year of coordinates inside one region, grouped by day. diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index e3cea5c9..beff58ae 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -471,6 +471,82 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + /// Fetch every annual-audit table through the same actor-confined context + /// without an `await` between fetches. That makes the returned value a + /// coherent SwiftData snapshot even if another task is waiting to commit. + public func auditRecords( + in interval: DateInterval, + manualDays dayRange: ClosedRange, + ) async throws -> YearAuditRecords { + let context = readContext() + let start = interval.start + let end = interval.end + let low = dayRange.lowerBound.description + let high = dayRange.upperBound.description + + var sampleDescriptor = FetchDescriptor( + predicate: #Predicate { + if let timestamp = $0.timestamp { + timestamp >= start && timestamp < end + } else { + false + } + }, + sortBy: [SortDescriptor(\.timestamp)], + ) + sampleDescriptor.includePendingChanges = true + let samples = try context.fetch(sampleDescriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + + var manualDescriptor = FetchDescriptor( + predicate: #Predicate { + if let dayKey = $0.dayKey { + dayKey >= low && dayKey <= high + } else { + false + } + }, + sortBy: [SortDescriptor(\.dayKey)], + ) + manualDescriptor.includePendingChanges = true + let manualDays = try context.fetch(manualDescriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + + var evidenceDescriptor = FetchDescriptor( + predicate: #Predicate { + if let capturedAt = $0.capturedAt { + capturedAt >= start && capturedAt < end + } else { + false + } + }, + sortBy: [SortDescriptor(\.capturedAt)], + ) + evidenceDescriptor.includePendingChanges = true + let evidence = try context.fetch(evidenceDescriptor).compactMap { record in + let value = record.toValue() + if value == nil { Self.logFault(forCorrupt: record) } + return value + } + + var trackedDescriptor = FetchDescriptor() + trackedDescriptor.includePendingChanges = true + let trackedIDs = try context.fetch(trackedDescriptor).compactMap(\.regionID) + + return YearAuditRecords( + samples: samples, + manualDays: manualDays, + evidence: evidence, + trackedRegions: Self.resolvedTrackedRegions(ids: trackedIDs), + ) + } + public func write(evidence: Evidence, blob: Data?) async throws { let context = mutationContext() let id = evidence.id @@ -751,10 +827,14 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { var descriptor = FetchDescriptor() descriptor.includePendingChanges = true let ids = try context.fetch(descriptor).compactMap(\.regionID) + return Self.resolvedTrackedRegions(ids: ids) + } + + private static func resolvedTrackedRegions(ids: [String]) -> Set { // No rows means the user hasn't chosen yet — fall back to the default // set (applied identically in every process). Once any row exists, the // tracked set is exactly the persisted rows. - guard !ids.isEmpty else { return Self.defaultTrackedRegions } + guard !ids.isEmpty else { return defaultTrackedRegions } // Unknown ids (e.g. a region dropped from the catalog) are filtered out // rather than crashing. Surface it: a stored id we can't resolve is a // degraded state — and if *every* id drops, the resulting empty set makes @@ -769,7 +849,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } if !unknown.isEmpty { - Self.logger { + logger { .ignoredUnknownTrackedRegions(ids: unknown.sorted()) } } diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index f7f1300c..9cf717e8 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -44,6 +44,14 @@ public protocol WhereStore: Sendable { func samples(in interval: DateInterval) async throws -> [LocationSample] func allSamples() async throws -> [LocationSample] + /// Every table needed by an annual audit, captured as one logical read. + /// `SwiftDataStore` overrides this to use one actor turn/read context; the + /// protocol default composes ordinary reads for lightweight stores. + func auditRecords( + in interval: DateInterval, + manualDays dayRange: ClosedRange, + ) async throws -> YearAuditRecords + func write(evidence: Evidence, blob: Data?) async throws func evidence(in interval: DateInterval) async throws -> [Evidence] /// Every evidence record in the store, regardless of `capturedAt`. Used @@ -135,6 +143,22 @@ extension WhereStore { [.california, .newYork, .canada, .europeanUnion] } + public func auditRecords( + in interval: DateInterval, + manualDays dayRange: ClosedRange, + ) async throws -> YearAuditRecords { + let samples = try await samples(in: interval) + let manualDays = try await manualDays(in: dayRange) + let evidence = try await evidence(in: interval) + let trackedRegions = try await trackedRegions() + return YearAuditRecords( + samples: samples, + manualDays: manualDays, + evidence: evidence, + trackedRegions: trackedRegions, + ) + } + /// Default: the out-of-the-box set. `SwiftDataStore` overrides this with the /// persisted rows; in-memory test fakes inherit the default. public func trackedRegions() async throws -> Set { diff --git a/Where/WhereCore/Sources/Reporting/ReportReader.swift b/Where/WhereCore/Sources/Reporting/ReportReader.swift index 151fd5cb..ab669041 100644 --- a/Where/WhereCore/Sources/Reporting/ReportReader.swift +++ b/Where/WhereCore/Sources/Reporting/ReportReader.swift @@ -45,6 +45,45 @@ public struct ReportReader: Sendable { } } + /// Capture every record needed for an audit in one store read, then freeze + /// the tracked-region set into an immutable attributor so rows, daily + /// presence, and totals cannot disagree if settings change concurrently. + public func auditReport(for year: Int) async throws -> YearAuditReport { + try await Self.logger.measure(.auditReport, budget: .seconds(2)) { + let records = try await store.auditRecords( + in: aggregator.yearInterval(year: year), + manualDays: dayRange(for: year), + ) + let trackedRegions = Region.inCanonicalOrder(records.trackedRegions) + let capturedAttributor = RegionAttributor(for: trackedRegions) + let attributedSamples = records.samples.map { + YearAuditAttributedSample( + sample: $0, + region: capturedAttributor.region(at: $0.coordinate), + ) + } + let report = aggregator.report( + for: year, + samples: records.samples, + manualDays: records.manualDays, + attributor: capturedAttributor, + ) + let trackedSet = records.trackedRegions + let sources = RegionDataSource.all.filter { source in + !trackedSet.isDisjoint(with: source.regions) + } + return YearAuditReport( + report: report, + samples: attributedSamples, + manualDays: records.manualDays, + evidence: records.evidence, + trackedRegions: trackedRegions, + timeZone: aggregator.timeZone, + regionDataSources: sources, + ) + } + } + /// Everything a data-issue scan needs from a **single** year-samples read: /// the aggregated `report`, the `.other` day coordinates border-drift checks /// use, and the raw GPS fixes (lazily grouped in `DaySamples`) the diff --git a/Where/WhereCore/Sources/Reporting/YearAuditReport.swift b/Where/WhereCore/Sources/Reporting/YearAuditReport.swift new file mode 100644 index 00000000..3bfe8c24 --- /dev/null +++ b/Where/WhereCore/Sources/Reporting/YearAuditReport.swift @@ -0,0 +1,118 @@ +import Foundation +import RegionKit + +/// Every persisted value needed to build an annual audit report, captured by a +/// store in one logical read. Production's `SwiftDataStore` fetches these +/// tables through one actor-confined read context; lightweight stores inherit +/// the composable `WhereStore` default. +public struct YearAuditRecords: Hashable, Sendable { + public let samples: [LocationSample] + public let manualDays: [DayPresence] + public let evidence: [Evidence] + public let trackedRegions: Set + + public init( + samples: [LocationSample], + manualDays: [DayPresence], + evidence: [Evidence], + trackedRegions: Set, + ) { + self.samples = samples.sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } + return $0.id.uuidString < $1.id.uuidString + } + self.manualDays = manualDays.sorted { $0.day < $1.day } + self.evidence = evidence.sorted { + if $0.capturedAt != $1.capturedAt { return $0.capturedAt < $1.capturedAt } + return $0.id.uuidString < $1.id.uuidString + } + self.trackedRegions = trackedRegions + } +} + +/// A raw location sample paired with the region assigned by the report's +/// captured attribution policy. +public struct YearAuditAttributedSample: Hashable, Sendable { + public let sample: LocationSample + public let region: Region + + public init(sample: LocationSample, region: Region) { + self.sample = sample + self.region = region + } +} + +/// The auditable reasons a finalized day appears in an annual report. A day +/// can carry several bases (for example GPS plus an additive manual backfill). +public enum YearAuditDayBasis: Hashable, Sendable { + case gps + case manualCoordinate + case evidenceDerivedCoordinate + case additiveManualEntry + case authoritativeManualOverride +} + +/// A consistent annual snapshot: the finalized jurisdiction-day report and +/// all source records used to explain it. `timeZone` is captured because raw +/// GPS instants do not persist the device's historical time zone; every day in +/// this value was bucketed using this one reporting time zone. +public struct YearAuditReport: Hashable, Sendable { + public let report: YearReport + public let samples: [YearAuditAttributedSample] + public let manualDays: [DayPresence] + public let evidence: [Evidence] + public let trackedRegions: [Region] + public let timeZone: TimeZone + public let regionDataSources: [RegionDataSource] + + public init( + report: YearReport, + samples: [YearAuditAttributedSample], + manualDays: [DayPresence], + evidence: [Evidence], + trackedRegions: [Region], + timeZone: TimeZone, + regionDataSources: [RegionDataSource], + ) { + self.report = report + self.samples = samples.sorted { + if $0.sample.timestamp != $1.sample.timestamp { + return $0.sample.timestamp < $1.sample.timestamp + } + return $0.sample.id.uuidString < $1.sample.id.uuidString + } + self.manualDays = manualDays.sorted { $0.day < $1.day } + self.evidence = evidence.sorted { + if $0.capturedAt != $1.capturedAt { return $0.capturedAt < $1.capturedAt } + return $0.id.uuidString < $1.id.uuidString + } + self.trackedRegions = trackedRegions + self.timeZone = timeZone + self.regionDataSources = regionDataSources + } + + /// The source categories supporting the finalized presence on `day`. + /// Authoritative manual entries remain explicit even though their regions + /// replace the coordinates' attribution in `report`. + public func bases(on day: CalendarDay, calendar: Calendar) -> Set { + var result: Set = [] + for attributed in samples + where CalendarDay(from: attributed.sample.timestamp, in: calendar) == day + { + switch attributed.sample.source { + case .gpsVisit, .gpsSignificantChange: + result.insert(.gps) + case .manual: + result.insert(.manualCoordinate) + case .evidenceImplied: + result.insert(.evidenceDerivedCoordinate) + } + } + for manual in manualDays where manual.day == day { + result + .insert(manual + .isAuthoritative ? .authoritativeManualOverride : .additiveManualEntry) + } + return result + } +} diff --git a/Where/WhereCore/Tests/YearAuditReportTests.swift b/Where/WhereCore/Tests/YearAuditReportTests.swift new file mode 100644 index 00000000..d4ad1e6e --- /dev/null +++ b/Where/WhereCore/Tests/YearAuditReportTests.swift @@ -0,0 +1,183 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct YearAuditReportTests { + private static let calendar = WhereCoreTestSupport.calendar() + + private static func makeReader() throws -> (ReportReader, SwiftDataStore) { + let store = try SwiftDataStore.inMemory() + let reader = ReportReader( + store: store, + aggregator: DayAggregator(calendar: calendar, timeZone: WhereCoreTestSupport.pacific), + attributor: RegionAttributor.shared, + ) + return (reader, store) + } + + @Test func snapshotScopesEveryTableToTheYearAndOrdersTiesByUUID() async throws { + let (reader, store) = try Self.makeReader() + let timestamp = WhereCoreTestSupport.iso("2026-05-02T10:00:00-07:00") + let earlierID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let laterID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")) + let evidenceID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000003")) + let audit = ManualEntryAudit( + recordedAt: WhereCoreTestSupport.iso("2026-06-01T09:00:00-07:00"), + note: "Reviewed travel records.", + location: CapturedLocation( + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 9, + timestamp: WhereCoreTestSupport.iso("2026-06-01T08:59:58-07:00"), + ), + ) + + try await store.perform { + try await store.add(sample: LocationSample( + id: laterID, + timestamp: timestamp, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 12, + source: .gpsVisit, + )) + try await store.add(sample: LocationSample( + id: earlierID, + timestamp: timestamp, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 8, + source: .gpsSignificantChange, + )) + try await store.add(sample: LocationSample( + timestamp: WhereCoreTestSupport.iso("2025-12-31T10:00:00-08:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 5, + source: .gpsVisit, + )) + try await store.setManualDay(DayPresence( + day: CalendarDay(year: 2026, month: 6, day: 1), + regions: [.newYork], + audit: audit, + )) + try await store.setManualDay(DayPresence( + day: CalendarDay(year: 2025, month: 6, day: 1), + regions: [.canada], + )) + try await store.write( + evidence: Evidence( + id: evidenceID, + kind: .boardingPass, + capturedAt: timestamp, + region: .california, + note: "SFO arrival", + contentType: .pdf, + ), + blob: Data("excluded attachment bytes".utf8), + ) + try await store.write( + evidence: Evidence( + kind: .photo, + capturedAt: WhereCoreTestSupport.iso("2027-01-01T10:00:00-08:00"), + contentType: .image, + ), + blob: nil, + ) + } + + let result = try await reader.auditReport(for: 2026) + + #expect(result.samples.map(\.sample.id) == [earlierID, laterID]) + #expect(result.manualDays.count == 1) + #expect(result.manualDays.first?.audit == audit) + #expect(result.evidence.map(\.id) == [evidenceID]) + #expect(result.evidence.first?.note == "SFO arrival") + #expect(result.report.days.map(\.day) == [ + CalendarDay(year: 2026, month: 5, day: 2), + CalendarDay(year: 2026, month: 6, day: 1), + ]) + } + + @Test func capturedTrackedSetDrivesAttributionTotalsAndDayBases() async throws { + let (reader, store) = try Self.makeReader() + let evidenceID = UUID() + let firstDay = WhereCoreTestSupport.iso("2026-03-01T12:00:00-08:00") + try await store.perform { + try await store.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: nil, order: 0), + ]) + try await store.add(sample: LocationSample( + timestamp: firstDay, + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 10, + source: .gpsVisit, + )) + try await store.add(sample: LocationSample( + timestamp: firstDay.addingTimeInterval(60), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 10, + source: .manual, + )) + try await store.add(sample: LocationSample( + timestamp: firstDay.addingTimeInterval(120), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 10, + source: .evidenceImplied(id: evidenceID, kind: .hotelReceipt), + )) + try await store.setManualDay(DayPresence( + day: CalendarDay(year: 2026, month: 3, day: 1), + regions: [.canada], + isAuthoritative: false, + )) + try await store.setManualDay(DayPresence( + day: CalendarDay(year: 2026, month: 3, day: 2), + regions: [.newYork], + isAuthoritative: true, + )) + } + + let result = try await reader.auditReport(for: 2026) + let march1 = CalendarDay(year: 2026, month: 3, day: 1) + let march2 = CalendarDay(year: 2026, month: 3, day: 2) + + #expect(result.trackedRegions == [.california]) + #expect(result.samples.first?.region == .other) + #expect(result.report.totals == [ + .other: 1, + .california: 1, + .canada: 1, + .newYork: 1, + ]) + #expect(result.bases(on: march1, calendar: Self.calendar) == [ + .gps, + .manualCoordinate, + .evidenceDerivedCoordinate, + .additiveManualEntry, + ]) + #expect(result.bases(on: march2, calendar: Self.calendar) == [ + .authoritativeManualOverride, + ]) + + // Later settings changes cannot retroactively alter the returned value. + try await store.perform { + try await store.setPrimaryRegions([ + PrimaryRegion(region: .newYork, appearance: nil, order: 0), + ]) + } + #expect(result.trackedRegions == [.california]) + #expect(result.samples.first?.region == .other) + } + + @Test func emptyYearStillCarriesPolicyTimezoneAndProvenance() async throws { + let (reader, _) = try Self.makeReader() + + let result = try await reader.auditReport(for: 2026) + + #expect(result.report.year == 2026) + #expect(result.report.days.isEmpty) + #expect(result.samples.isEmpty) + #expect(result.manualDays.isEmpty) + #expect(result.evidence.isEmpty) + #expect(Set(result.trackedRegions) == SwiftDataStore.defaultTrackedRegions) + #expect(result.timeZone == WhereCoreTestSupport.pacific) + #expect(!result.regionDataSources.isEmpty) + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 6fb1049a..71363013 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -4,7 +4,7 @@ WhereUI is the SwiftUI layer of the Where feature: the screens, the shared components and widget views, and the `@Observable` view models that orchestrate `WhereCore` for them (`WhereModel`, the `WhereSession` coordinator, and the scoped `YearReportModel` / `ResolveModel` / -`BackupModel` / `RemindersSettingsModel`). Layering, localization, preview, +`BackupModel` / `YearExportModel` / `RemindersSettingsModel`). Layering, localization, preview, and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) — read that and the root [`AGENTS.md`](../../AGENTS.md) first. @@ -13,6 +13,9 @@ and testing conventions live in the feature [`Where/AGENTS.md`](../AGENTS.md) - Presentation layer only — no domain rules, persistence, or store I/O here ([Layering](../AGENTS.md#layering)). Dependencies live in the root [`Package.swift`](../../Package.swift). +- Annual export rendering belongs here, but audit semantics do not: consume one + `YearAuditReport`, render it off the main actor, and keep identity fields, + raw coordinates, notes, and rows out of logs. - Composition is the one exception: `WhereScope` and `WhereModel` decide which world the app is logged in to and assemble it. That's launch wiring, not domain logic — see [Scopes and the launch](../AGENTS.md#scopes-and-the-launch). diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 4e0daddd..49281333 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -75,11 +75,21 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's - **Scope-tiered models** — scene-scoped **`YearReportModel`** (the selected year's `YearReport`, its `LoadState`, and the manual-day edit intents), plus view-scoped **`ResolveModel`** (data-issue triage), **`BackupModel`** - (export/import), and **`RemindersSettingsModel`** (notification prefs). Each - orchestrates `WhereServices`; none reimplements Core rules. + (export/import), **`YearExportModel`** (ephemeral audit-PDF configuration, + cancellable progress, and bounded temporary-file lifetime), and + **`RemindersSettingsModel`** (notification prefs). Each orchestrates + `WhereServices`; none reimplements Core rules. ### Reusable views & styling +- **Annual audit export** — the Your Year toolbar opens `YearExportView`, whose + independent year/paper/privacy choices generate a print-ready report through + `YearPDFRenderer`. The renderer pre-lays out searchable portrait pages plus + an optional landscape raw-GPS appendix, then `YearPDFPreviewView` displays + the completed file with reusable `PDFDocumentView` and shares its typed + `.pdf` `FileRepresentation` through `ShareLink`. Demo files carry a distinct + filename, metadata warning, and non-removable watermark on every page. + - **`OnboardingView`** — the first-run flow, registered for the launch's `OnboardingGate` and handed its `LifecycleGateHandle`. The gate roots the trunk, so there is no session (and no open store) behind it: a paged intro, diff --git a/Where/WhereUI/SnapshotTests/YearExportViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/YearExportViewSnapshotTests.swift new file mode 100644 index 00000000..c0429885 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/YearExportViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct YearExportViewSnapshotTests { + @Test func yearExport() async { + await assertSnapshots(of: YearExportView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/YearPDFPageSnapshotTests.swift b/Where/WhereUI/SnapshotTests/YearPDFPageSnapshotTests.swift new file mode 100644 index 00000000..145f01aa --- /dev/null +++ b/Where/WhereUI/SnapshotTests/YearPDFPageSnapshotTests.swift @@ -0,0 +1,109 @@ +import PDFKit +import RegionKit +import SnapshotKitTesting +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct YearPDFPageSnapshotTests { + @Test func representativePages() async throws { + let file = try await YearPDFRenderer().render(document: Self.document) { _ in } + defer { try? FileManager.default.removeItem(at: file.storageDirectory) } + let pdf = try #require(PDFDocument(url: file.url)) + + for (name, index) in [ + ("cover", 0), + ("daily", 3), + ("manual", 4), + ("evidence", 5), + ("raw-gps", 7), + ] { + let page = try #require(pdf.page(at: index)) + let image = page.thumbnail(of: page.bounds(for: .mediaBox).size, for: .mediaBox) + assertSnapshot( + of: image, + as: .image(precision: 0.99, perceptualPrecision: 0.99), + named: name, + ) + } + } + + private static var document: YearPDFDocument { + let timestamp = date("2024-06-01T12:00:00-07:00") + let attributed = (0 ..< 30).map { index in + YearAuditAttributedSample( + sample: LocationSample( + id: UUID(uuidString: String( + format: "00000000-0000-0000-0000-%012d", + index + 1, + ))!, + timestamp: timestamp.addingTimeInterval(TimeInterval(index)), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 8, + source: index.isMultiple(of: 2) ? .gpsVisit : .gpsSignificantChange, + ), + region: .california, + ) + } + let manual = DayPresence( + day: CalendarDay(year: 2024, month: 6, day: 2), + regions: [.newYork], + audit: ManualEntryAudit( + recordedAt: date("2024-07-01T09:00:00-07:00"), + note: "Verified against travel documents.", + location: nil, + ), + ) + let report = YearAuditReport( + report: YearReport( + year: 2024, + days: [ + DayPresence( + day: CalendarDay(year: 2024, month: 6, day: 1), + regions: [.california], + ), + DayPresence( + day: CalendarDay(year: 2024, month: 6, day: 2), + regions: [.newYork], + ), + ], + totals: [.california: 1, .newYork: 1], + ), + samples: attributed, + manualDays: [manual], + evidence: [ + Evidence( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + kind: .boardingPass, + capturedAt: timestamp, + region: .california, + note: "Flight confirmation", + contentType: .pdf, + ), + ], + trackedRegions: [.california, .newYork, .canada, .europeanUnion], + timeZone: timeZone, + regionDataSources: RegionDataSource.all, + ) + return YearPDFDocument( + audit: report, + generatedAt: date("2026-07-31T15:04:05.123-07:00"), + reportID: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, + preparedFor: "Alex Example", + reference: "Tax file 2024", + pageSize: .letter, + includeRawGPS: true, + isDemo: false, + buildInfo: .current(bundle: .main), + ) + } + + private static let timeZone = TimeZone(identifier: "America/Los_Angeles")! + + private static func date(_ value: String) -> Date { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value)! + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad.png new file mode 100644 index 00000000..29668216 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c19e191fc513deb41445e1590abe61a07f63720e2e2dcbd2945ad74df5bb7d4c +size 342967 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_accessibility.png new file mode 100644 index 00000000..498cf0d1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:758c1eb59c2ff4bad17949b73ead8f113a71dfbaa407a14025918e04272c9b70 +size 629935 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_ax5.png new file mode 100644 index 00000000..44e779c4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79710dde21d2f3cac76809442c6955af36993c15fea6dc34aa3729e2fd5a21e3 +size 511560 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_contrast.png new file mode 100644 index 00000000..727b241d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86c540f0c9b367f1c27fbc32b475c858fcd2fb98ec6a3133e469bee287ab3c48 +size 341861 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_dark.png new file mode 100644 index 00000000..01fb43be --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6548723ce33a6a84aacc0e911697df9d6ad36e97d7d14fa7b6ee04a938cac46 +size 339771 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone.png new file mode 100644 index 00000000..f8b7d55f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4eea7d5bd99b24d2d22606c3cda01ed798193046ab81cc55b7a7354095f24b33 +size 229187 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_accessibility.png new file mode 100644 index 00000000..d17c08fe --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58a22be268e70224abab02b7c19c3d0c4cc51c370f1e7444371d23624c32f29b +size 484414 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_ax5.png new file mode 100644 index 00000000..355927c2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f30b45db8eaeac689abe822e346f68723c51e24aa518c248dc03fc3b0a7541bd +size 222547 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_contrast.png new file mode 100644 index 00000000..32d718df --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34e32c5d8b56ab98262ba68031b05d68fbc0babbeb2d926de0787ce9d377d016 +size 229491 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_dark.png new file mode 100644 index 00000000..103f30a7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0141371505275a18ae9179a7e2cc60c6b2d5edbc69c9a549b236c92da3b7dc61 +size 225393 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Raw-GPS-warning_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Raw-GPS-warning_iPhone.png new file mode 100644 index 00000000..ca18830f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Raw-GPS-warning_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24439cefbee92930557e8315068fc5323609db46c6bcb7f7eb042828a9e8a99b +size 249477 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Raw-GPS-warning_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Raw-GPS-warning_iPhone_dark.png new file mode 100644 index 00000000..cf712232 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearExportViewSnapshotTests/yearExport.Raw-GPS-warning_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd22afdaf567c68b6075572f5518888b0351ff6573cd5637056d53bf8000b77d +size 246343 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.cover.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.cover.png new file mode 100644 index 00000000..cda0cd79 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.cover.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e505da9b250c9e05b2410610a36cd88a4db2a2f82fc3c7d7a980c855d84d977 +size 35633 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.daily.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.daily.png new file mode 100644 index 00000000..824db6d7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.daily.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:662e4fec1c5ce681fac88df2f0dd041423ca6bc3da64b50d844e31e4def7245e +size 25189 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.evidence.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.evidence.png new file mode 100644 index 00000000..92550f0b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.evidence.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a42be63bd3d206451241ac8294de2e55def59da21f9529f15c37f198a2a3477c +size 24130 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.manual.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.manual.png new file mode 100644 index 00000000..d480b095 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.manual.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39f43e57cd43fc45669036de75ddf2575b957530175482c9813f4136968b178d +size 25266 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.raw-gps.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.raw-gps.png new file mode 100644 index 00000000..afbf2ad5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearPDFPageSnapshotTests/representativePages.raw-gps.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e789b5ab8ce948bf304ca15957d6f69a19ccf5e8d5dc80932b831c1215dff64f +size 93807 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png index 38ab407d..7fbe1a7c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c36ff8383cc51f788115b9ae0aa7fe33662ae6389e079ef3e85269491f780e2 -size 266947 +oid sha256:51bc7b8b619c538ff066f1929ff0968825dfb1a095efc2015f1c89d377dd092f +size 276155 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png index 1be8b55a..b6642e75 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Empty_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54e9f3fca9658a369823756b32fab42927ff44529e81a19f9e2e7a133ffb8a89 -size 260456 +oid sha256:3f8ac1d856ecaacd012d58eabc5e877fd8e52a4dc33930797f6e39e1a50aecc6 +size 269871 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png index 7a0e86b0..6f90f123 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c99bdee6dd2c23aa4c25792063d4114be45113089ece2e48ba29ffe791ab881d -size 483436 +oid sha256:d18e2b2d3b622f4f6a8d2c8464f6e3e9cc2a2d24dfa25078f0b96857fa79e776 +size 496129 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png index b654909f..c86e4610 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:653af5d0c915e52ae50c26baa4adb49c706de25869f4fc9d8c63be2fa297bfd5 -size 343215 +oid sha256:e0bc158e12ae18a651dd7a6bdfeac24988be23c9dd051e8208600e2245f5e71f +size 351573 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png index 86e34693..9b9c7227 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b964570700ba0a5ce9846fe2aba0345924135e8ad9d81e5b9cad1c03c48705e -size 601893 +oid sha256:07f3e1f2d566ceb533c219ecde765dd3abd167807de19a21c4f708a90b6ac7db +size 614923 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png index 4bb80560..2102fa92 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef3286cdee7f78af8860ccfc7d594e351359f5a2fb0e43c8bb73785b573305a7 -size 476706 +oid sha256:0874351a5c5b66864ba112a3ec480ee7c63412d1563ba75500a15cc7183b3d68 +size 487076 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png index fbc90744..7caf2e8f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d74f4fe06ec8e51f754bc26ac5c9e29e68bfe4eba7972ca6c4790489df28b09f -size 478250 +oid sha256:c77c107734fe5f33ac118cd62eb7031cb5b1d94950b421d9ad966ca94e0ca6c1 +size 491243 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png index f0b1645c..e11c2bbc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b64ba2fd768f0b7252d5c35f94c5f180a77e4d35bbea26658ec131a34628b580 -size 276870 +oid sha256:b1237ee3d5dc4457dd2cafeff571cd983556fd56d607a99525d28e02eab6f9ca +size 286269 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png index aba4ca34..af7bcd4f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40f16d6973b20c931e77ec24c733e2e9f61d2d7b33d9e921d1d15f029b99f7ac -size 209468 +oid sha256:26b1792b655104a0e30604d8999f4ed57a01bf652e1a733b2d6de2a8b1b82c92 +size 225239 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png index 268fc3ef..cd339885 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d4fee3229f9a7c904144c8960b2422b196c29c04edc71c85443179ece3a850a -size 267395 +oid sha256:fb44434898a0b64d930b5c1700fdc37fa0323584d98c53228f2b0a563b379076 +size 277196 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png index d006cd43..e7495e96 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55beddd007619b7500ecf1b24e62595805f7b4ebafd7da914f39b2d740761665 -size 269184 +oid sha256:e746359eef140c161b0838353e480012e39f869d2eff4e3a70f5c46cccf99f88 +size 277150 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png index cfb8bf13..28cb14ac 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/year.Loaded_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2105f5870233277326e52e2cb4150aeeac5547206a7c48c841404b543161fed -size 280470 +oid sha256:2fa0ca0d252e2eadd5a623fd324f26dcdf54092581fff790cfa0d138ab9dfe3a +size 289922 diff --git a/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift b/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift index 8a0ec552..d44f4edd 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift @@ -1,4 +1,3 @@ -import PDFKit import SwiftUI import UIKit import WhereCore @@ -77,38 +76,6 @@ struct EvidenceBlobPreview: View { } } -/// Thin `PDFView` wrapper so evidence PDFs preview inline (auto-scaled, paged). -private struct PDFDocumentView: UIViewRepresentable { - let data: Data - - /// Remembers the bytes the current `PDFDocument` was built from, so - /// `updateUIView` rebuilds only on a genuine change. Comparing against - /// `PDFView.document?.dataRepresentation()` doesn't work: PDFKit re-encodes - /// on export, so its byte count differs from the input and the document - /// would be rebuilt on every layout pass — resetting the user's scroll/zoom. - final class Coordinator { - var data: Data? - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeUIView(context: Context) -> PDFView { - let view = PDFView() - view.autoScales = true - view.document = PDFDocument(data: data) - context.coordinator.data = data - return view - } - - func updateUIView(_ view: PDFView, context: Context) { - guard context.coordinator.data != data else { return } - context.coordinator.data = data - view.document = PDFDocument(data: data) - } -} - #if DEBUG #Preview("Plain text") { EvidenceBlobPreview( diff --git a/Where/WhereUI/Sources/Logging/YearExportModelLog.swift b/Where/WhereUI/Sources/Logging/YearExportModelLog.swift new file mode 100644 index 00000000..2d358ab1 --- /dev/null +++ b/Where/WhereUI/Sources/Logging/YearExportModelLog.swift @@ -0,0 +1,49 @@ +import PeriscopeCore + +/// Privacy-minimized annual-export events. Identity fields, notes, coordinates, +/// raw rows, filenames, and file paths never enter this scope. +enum YearExportModelLog: LogEvent { + enum SpanName: Hashable { + case generation + } + + case generated( + year: Int, + dayCount: Int, + manualCount: Int, + evidenceCount: Int, + gpsCount: Int, + includedGPS: Bool, + pageCount: Int, + ) + case failed(year: Int, failureType: String) + case cleanupFailed(failureType: String) + + static let eventName = "YearExport" + + var level: LogLevel { + switch self { + case .generated: .info + case .failed, .cleanupFailed: .warning + } + } + + var message: String { + switch self { + case let .generated( + year, + dayCount, + manualCount, + evidenceCount, + gpsCount, + includedGPS, + pageCount, + ): + "Generated annual report for \(year) (\(dayCount) days, \(manualCount) manual, \(evidenceCount) evidence, \(gpsCount) GPS, raw GPS: \(includedGPS), \(pageCount) pages)" + case let .failed(year, failureType): + "Annual report generation failed for \(year) (\(failureType))" + case let .cleanupFailed(failureType): + "Annual report cleanup failed (\(failureType))" + } + } +} diff --git a/Where/WhereUI/Sources/Model/YearExportModel.swift b/Where/WhereUI/Sources/Model/YearExportModel.swift new file mode 100644 index 00000000..dedfd197 --- /dev/null +++ b/Where/WhereUI/Sources/Model/YearExportModel.swift @@ -0,0 +1,262 @@ +import Foundation +import Observation +import PeriscopeCore +import WhereCore + +/// Sheet-scoped annual-export state. Form values live only here, generation is +/// one explicit state machine, and finished files have a bounded lifetime. +@MainActor +@Observable +final class YearExportModel { + enum GenerationState: Equatable { + case idle + case generating(completedPages: Int, totalPages: Int?) + case ready(YearPDFFile) + case failed + } + + private(set) var state: GenerationState = .idle + + var selectedYear: Int { + didSet { + guard oldValue != selectedYear else { return } + invalidateReadyFile() + } + } + + var preparedFor = "" { + didSet { + guard oldValue != preparedFor else { return } + invalidateReadyFile() + } + } + + var reference = "" { + didSet { + guard oldValue != reference else { return } + invalidateReadyFile() + } + } + + var pageSize: YearPDFPageSize { + didSet { + guard oldValue != pageSize else { return } + invalidateReadyFile() + } + } + + var includeRawGPS = false { + didSet { + guard oldValue != includeRawGPS else { return } + invalidateReadyFile() + } + } + + let availableYears: [Int] + + private let reader: ReportReader + private let now: @Sendable () -> Date + private let buildInfo: BuildInfo + private let renderer: any YearPDFRendering + private let retention: Duration + private let cleanup: @Sendable (URL) throws -> Void + + @ObservationIgnored private var cleanupTask: Task? + + private static let logger = WhereLog.session(YearExportModelLog.self) + + init( + reader: ReportReader, + displayedYear: Int, + calendar: Calendar, + locale: Locale, + buildInfo: BuildInfo, + now: @escaping @Sendable () -> Date, + renderer: any YearPDFRendering = YearPDFRenderer(), + retention: Duration = .seconds(600), + cleanup: @escaping @Sendable (URL) throws -> Void = { + try FileManager.default.removeItem(at: $0) + }, + ) { + self.reader = reader + self.now = now + self.buildInfo = buildInfo + self.renderer = renderer + self.retention = retention + self.cleanup = cleanup + let referenceDate = now() + selectedYear = YearExportDefaults.selectedYear( + displayedYear: displayedYear, + now: referenceDate, + calendar: calendar, + ) + availableYears = YearExportDefaults.availableYears( + displayedYear: displayedYear, + now: referenceDate, + calendar: calendar, + ) + pageSize = YearPDFPageSize.defaultValue(for: locale) + } + + var progressFraction: Double? { + guard case let .generating(completed, total?) = state, total > 0 else { return nil } + return min(max(Double(completed) / Double(total), 0), 1) + } + + var isShowingFailure: Bool { + get { state == .failed } + set { + if !newValue, state == .failed { state = .idle } + } + } + + /// Build and render one immutable report. Cancellation returns to `idle`, + /// while a recoverable failure becomes an honest retryable state. + func generate(isDemo: Bool) async -> YearPDFFile? { + guard !isGenerating else { return nil } + cleanupTask?.cancel() + cleanupTask = nil + invalidateReadyFile() + + let year = selectedYear + let preparedFor = Self.nonempty(preparedFor) + let reference = Self.nonempty(reference) + let pageSize = pageSize + let includeRawGPS = includeRawGPS + state = .generating(completedPages: 0, totalPages: nil) + let (updates, continuation) = AsyncStream.makeStream() + let observer = Task { @MainActor [weak self] in + for await update in updates { + guard let self else { return } + state = .generating( + completedPages: update.completedPages, + totalPages: update.totalPages, + ) + } + } + defer { observer.cancel() } + + do { + let generatedAt = now() + let audit = try await reader.auditReport(for: year) + try Task.checkCancellation() + let document = YearPDFDocument( + audit: audit, + generatedAt: generatedAt, + reportID: UUID(), + preparedFor: preparedFor, + reference: reference, + pageSize: pageSize, + includeRawGPS: includeRawGPS, + isDemo: isDemo, + buildInfo: buildInfo, + ) + let file = try await Self.logger.measure(.generation, budget: .seconds(10)) { + try await renderer.render(document: document) { + continuation.yield($0) + } + } + try discardIfCancelled(file) + continuation.finish() + await observer.value + try discardIfCancelled(file) + state = .ready(file) + + let gpsCount = audit.samples.count { $0.sample.source.isGPS } + Self.logger { + .generated( + year: year, + dayCount: audit.report.days.count, + manualCount: audit.manualDays.count, + evidenceCount: audit.evidence.count, + gpsCount: gpsCount, + includedGPS: document.includeRawGPS, + pageCount: file.pageCount, + ) + } + return file + } catch is CancellationError { + continuation.finish() + state = .idle + return nil + } catch { + continuation.finish() + state = .failed + Self.logger { + .failed(year: year, failureType: String(reflecting: type(of: error))) + } + return nil + } + } + + var isGenerating: Bool { + if case .generating = state { true } else { false } + } + + /// A retry is just another generation pass; the caller uses this to return + /// the error presentation to an editable state first. + func acknowledgeFailure() { + guard state == .failed else { return } + state = .idle + } + + /// Keep a successfully shared file available briefly after the sheet goes + /// away, giving an in-progress share destination time to copy it. + func sheetDidDismiss() { + guard case let .ready(file) = state else { return } + cleanupTask?.cancel() + let retention = retention + let cleanup = cleanup + cleanupTask = Task { [weak self] in + do { + try await Task.sleep(for: retention) + try Task.checkCancellation() + try cleanup(file.storageDirectory) + guard let self else { return } + if state == .ready(file) { + state = .idle + } + } catch is CancellationError { + return + } catch { + Self.logger { + .cleanupFailed(failureType: String(reflecting: type(of: error))) + } + } + } + } + + /// Used whenever form input changes or a replacement generation starts. + /// Superseded output is no longer shareable, so remove it immediately. + private func invalidateReadyFile() { + guard case let .ready(file) = state else { return } + cleanupTask?.cancel() + cleanupTask = nil + removeImmediately(file) + state = .idle + } + + /// Cancellation is cooperative, including while waiting for the progress + /// observer to drain. A completed but no-longer-wanted file is removed + /// before cancellation returns the state machine to `idle`. + private func discardIfCancelled(_ file: YearPDFFile) throws { + guard Task.isCancelled else { return } + removeImmediately(file) + throw CancellationError() + } + + private func removeImmediately(_ file: YearPDFFile) { + do { + try cleanup(file.storageDirectory) + } catch { + Self.logger { + .cleanupFailed(failureType: String(reflecting: type(of: error))) + } + } + } + + private static func nonempty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 95c54b18..9fe7b17c 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -993,6 +993,1227 @@ } } }, + "export.report.demo.filename" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where Demo Presence Report" + } + } + } + }, + "export.report.demo.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demo exports are sample documents. Every page is permanently watermarked SAMPLE — DEMO DATA." + } + } + } + }, + "export.report.demo.warning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SAMPLE — DEMO DATA" + } + } + } + }, + "export.report.details.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report details" + } + } + } + }, + "export.report.failure.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where could not create the PDF. Your data was not changed. Check available storage and try again." + } + } + } + }, + "export.report.failure.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't Create Report" + } + } + } + }, + "export.report.filename" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where Presence Report" + } + } + } + }, + "export.report.generate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generate PDF" + } + } + } + }, + "export.report.generating" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generating report..." + } + } + } + }, + "export.report.paper" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paper size" + } + } + } + }, + "export.report.paper.a4" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A4" + } + } + } + }, + "export.report.paper.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paper" + } + } + } + }, + "export.report.paper.letter" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "US Letter" + } + } + } + }, + "export.report.pdf.accuracy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accuracy" + } + } + } + }, + "export.report.pdf.additiveBackfill" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Additive backfill" + } + } + } + }, + "export.report.pdf.affectedDates" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affected dates" + } + } + } + }, + "export.report.pdf.appBuild" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App build" + } + } + } + }, + "export.report.pdf.appVersion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App version" + } + } + } + }, + "export.report.pdf.approximateGeometry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Approximate" + } + } + } + }, + "export.report.pdf.approximateGeometryWarning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "IMPORTANT: One or more tracked regions use coarse, hand-drawn approximate geometry. Those boundaries are not authoritative and are not suitable as independent proof of residency." + } + } + } + }, + "export.report.pdf.assignedRegion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assigned region" + } + } + } + }, + "export.report.pdf.attributedRegion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attributed region" + } + } + } + }, + "export.report.pdf.auditDisclaimer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This transparent audit report describes information available on this device when generated. It is not certified, tamper-evident, independently verified, or a calculation of hours or elapsed time." + } + } + } + }, + "export.report.pdf.authoritativeGeometry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authoritative source, simplified" + } + } + } + }, + "export.report.pdf.authoritativeOverride" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authoritative manual override" + } + } + } + }, + "export.report.pdf.basis" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basis" + } + } + } + }, + "export.report.pdf.basis.additive" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "additive backfill" + } + } + } + }, + "export.report.pdf.basis.evidenceCoordinate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "evidence-derived coordinate" + } + } + } + }, + "export.report.pdf.basis.gps" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS" + } + } + } + }, + "export.report.pdf.basis.manualCoordinate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "manual coordinate" + } + } + } + }, + "export.report.pdf.basis.override" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "authoritative manual override" + } + } + } + }, + "export.report.pdf.captureDate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture date" + } + } + } + }, + "export.report.pdf.contentType" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content type" + } + } + } + }, + "export.report.pdf.continued" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "continued" + } + } + } + }, + "export.report.pdf.count" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Count" + } + } + } + }, + "export.report.pdf.coverageSummary" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coverage Summary" + } + } + } + }, + "export.report.pdf.dailyPresence" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily Presence Table" + } + } + } + }, + "export.report.pdf.dailyPresenceExplanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every logged calendar date is listed with its final regions and the source categories that contributed to the day. Final regions reflect additive backfills and authoritative manual overrides." + } + } + } + }, + "export.report.pdf.date" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" + } + } + } + }, + "export.report.pdf.daysWithoutPresence" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Days without presence" + } + } + } + }, + "export.report.pdf.demoDisclaimer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This report contains generated sample data, not the user's location history. It must not be presented as proof of presence." + } + } + } + }, + "export.report.pdf.distinctLoggedDays" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distinct logged days" + } + } + } + }, + "export.report.pdf.editLocation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Captured edit location" + } + } + } + }, + "export.report.pdf.evidenceCoordinate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evidence-derived coordinates" + } + } + } + }, + "export.report.pdf.evidenceExplanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The index lists evidence metadata only. Attachment bytes are not embedded in this PDF and remain separate on the device." + } + } + } + }, + "export.report.pdf.evidenceId" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evidence ID" + } + } + } + }, + "export.report.pdf.evidenceIndex" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evidence Index" + } + } + } + }, + "export.report.pdf.evidenceRecords" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evidence records" + } + } + } + }, + "export.report.pdf.fidelity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fidelity" + } + } + } + }, + "export.report.pdf.finalRegions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Final regions" + } + } + } + }, + "export.report.pdf.generated" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generated" + } + } + } + }, + "export.report.pdf.geometrySource" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geometry source" + } + } + } + }, + "export.report.pdf.gpsSignificantChange" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS significant-change samples" + } + } + } + }, + "export.report.pdf.gpsVisit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS visit samples" + } + } + } + }, + "export.report.pdf.jurisdictionDays" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jurisdiction-days" + } + } + } + }, + "export.report.pdf.kind" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kind" + } + } + } + }, + "export.report.pdf.latitude" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latitude" + } + } + } + }, + "export.report.pdf.longitude" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longitude" + } + } + } + }, + "export.report.pdf.manualAuditExplanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contiguous manual records with identical mode, regions, and audit metadata are consolidated without dropping any affected dates." + } + } + } + }, + "export.report.pdf.manualCoordinate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual coordinate samples" + } + } + } + }, + "export.report.pdf.manualDayRecords" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual-day records" + } + } + } + }, + "export.report.pdf.manualEntryAudit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual-Entry Audit" + } + } + } + }, + "export.report.pdf.metersAbbreviation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "m" + } + } + } + }, + "export.report.pdf.method.currentDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current-device snapshot: this report records data available in Where on this device at generation time. Data held only on another device or not yet synchronized is not represented." + } + } + } + }, + "export.report.pdf.method.editsImports" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "User edits and imports: manual entries, evidence-derived coordinates, restored backups, and synchronized records can change the result. This report exposes those source categories but cannot independently validate them." + } + } + } + }, + "export.report.pdf.method.manual" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual behavior: additive entries union their regions with coordinate-derived regions. Authoritative manual overrides replace the coordinate-derived regions for their date; raw coordinates remain stored separately." + } + } + } + }, + "export.report.pdf.method.noCertification" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limitations: this standard PDF is not PDF/A, encrypted, digitally signed, tamper-evident, backend-verified, or independently certified. Evidence attachments are not embedded." + } + } + } + }, + "export.report.pdf.method.other" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other: the region named Other means a coordinate fell outside every tracked region loaded for this report, or a user explicitly assigned an unlisted place. It does not identify a specific jurisdiction." + } + } + } + }, + "export.report.pdf.method.timezone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timezone bucketing: GPS samples store instants but not the device's historical timezone. Where assigns them to Gregorian calendar dates using the reporting timezone printed on the cover. Travel near midnight can therefore bucket differently from the timezone present at capture." + } + } + } + }, + "export.report.pdf.methodology" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Methodology and Limitations" + } + } + } + }, + "export.report.pdf.missingDateRanges" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missing date ranges" + } + } + } + }, + "export.report.pdf.mode" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode" + } + } + } + }, + "export.report.pdf.nonExclusiveExplanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Counts are non-exclusive jurisdiction-days. A calendar date can count in multiple regions, so regional totals can exceed the number of distinct logged days. These totals do not represent hours, nights, or elapsed duration." + } + } + } + }, + "export.report.pdf.none" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + } + } + }, + "export.report.pdf.notAssigned" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not assigned" + } + } + } + }, + "export.report.pdf.notAvailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not available" + } + } + } + }, + "export.report.pdf.note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note" + } + } + } + }, + "export.report.pdf.of" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "of" + } + } + } + }, + "export.report.pdf.page" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page" + } + } + } + }, + "export.report.pdf.rawGpsAppendix" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raw Device-GPS Appendix" + } + } + } + }, + "export.report.pdf.rawGpsExplanation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every gpsVisit and gpsSignificantChange sample in the selected year is included below without sampling or truncation, ordered by timestamp and UUID. Manual and evidence-derived coordinates are excluded." + } + } + } + }, + "export.report.pdf.recordedAt" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recorded at" + } + } + } + }, + "export.report.pdf.region" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region" + } + } + } + }, + "export.report.pdf.regionalTotals" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regional Totals" + } + } + } + }, + "export.report.pdf.regions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regions" + } + } + } + }, + "export.report.pdf.reportId" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report ID" + } + } + } + }, + "export.report.pdf.reportingTimeZone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reporting timezone" + } + } + } + }, + "export.report.pdf.sampleUuid" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sample UUID" + } + } + } + }, + "export.report.pdf.source" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source" + } + } + } + }, + "export.report.pdf.sourceLinks" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source links" + } + } + } + }, + "export.report.pdf.storedSource" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stored source" + } + } + } + }, + "export.report.pdf.subject" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where annual jurisdiction-day presence audit" + } + } + } + }, + "export.report.pdf.timestamp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timestamp" + } + } + } + }, + "export.report.pdf.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where Presence Report" + } + } + } + }, + "export.report.pdf.todayIncomplete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The generation date appears in recorded presence, but today is an incomplete calendar day and is excluded from the missing-day gap calculation." + } + } + } + }, + "export.report.pdf.trackedRegions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracked regions" + } + } + } + }, + "export.report.pdf.type.image" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Image" + } + } + } + }, + "export.report.pdf.type.other" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" + } + } + } + }, + "export.report.pdf.type.pdf" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PDF" + } + } + } + }, + "export.report.pdf.type.rawData" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raw data" + } + } + } + }, + "export.report.pdf.type.text" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plain text" + } + } + } + }, + "export.report.preparedFor" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prepared for (optional)" + } + } + } + }, + "export.report.preview.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report Preview" + } + } + } + }, + "export.report.rawGps.toggle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Include raw device GPS" + } + } + } + }, + "export.report.rawGps.warning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This discloses every stored device-GPS coordinate for the year. The appendix can make the PDF very large, and email providers may reject attachments over their size limits. Off by default every time." + } + } + } + }, + "export.report.reference" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reference (optional)" + } + } + } + }, + "export.report.share" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" + } + } + } + }, + "export.report.share.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "After generation, review the PDF before sharing it through Mail, AirDrop, Files, Messages, or another installed app." + } + } + } + }, + "export.report.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export Annual Report" + } + } + } + }, + "export.report.toolbar" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export Report" + } + } + } + }, + "export.report.year" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Year" + } + } + } + }, "launch.accessibilityLabel" : { "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/PDFDocumentView.swift b/Where/WhereUI/Sources/Shared/PDFDocumentView.swift new file mode 100644 index 00000000..f3c41c67 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/PDFDocumentView.swift @@ -0,0 +1,81 @@ +import PDFKit +import SwiftUI +import UIKit + +/// Reusable paged `PDFView` bridge for both evidence bytes and completed files. +/// The source identity is cached so ordinary SwiftUI updates do not rebuild the +/// `PDFDocument` and reset the reader's current page or zoom. +struct PDFDocumentView: UIViewRepresentable { + enum Source: Hashable { + case data(Data) + case file(URL) + } + + let source: Source + + init(source: Source) { + self.source = source + } + + init(data: Data) { + source = .data(data) + } + + init(url: URL) { + source = .file(url) + } + + final class Coordinator { + var source: Source? + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> PDFView { + let view = PDFView() + view.autoScales = true + view.displayMode = .singlePage + view.displayDirection = .horizontal + view.usePageViewController(true, withViewOptions: [ + UIPageViewController.OptionsKey.interPageSpacing: 12, + ]) + view.document = document + context.coordinator.source = source + return view + } + + func updateUIView(_ view: PDFView, context: Context) { + guard context.coordinator.source != source else { return } + context.coordinator.source = source + view.document = document + } + + private var document: PDFDocument? { + switch source { + case let .data(data): PDFDocument(data: data) + case let .file(url): PDFDocument(url: url) + } + } +} + +#if DEBUG + extension PDFDocumentView { + static let previewData: Data = { + let bounds = CGRect(x: 0, y: 0, width: 612, height: 792) + return UIGraphicsPDFRenderer(bounds: bounds).pdfData { context in + context.beginPage() + let title = String(localized: .exportReportPreviewTitle) as NSString + title.draw( + at: CGPoint(x: 48, y: 64), + withAttributes: [.font: UIFont.boldSystemFont(ofSize: 24)], + ) + } + }() + } + + #Preview { + PDFDocumentView(data: PDFDocumentView.previewData) + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearExportDefaults.swift b/Where/WhereUI/Sources/Year/YearExportDefaults.swift new file mode 100644 index 00000000..2924effc --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearExportDefaults.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Pure presentation defaults for a newly opened annual-export sheet. +enum YearExportDefaults { + static func selectedYear( + displayedYear: Int, + now: Date, + calendar: Calendar, + ) -> Int { + let currentYear = calendar.component(.year, from: now) + guard displayedYear == currentYear else { return displayedYear } + let month = calendar.component(.month, from: now) + return month <= 3 ? currentYear - 1 : currentYear + } + + static func availableYears( + displayedYear: Int, + now: Date, + calendar: Calendar, + ) -> [Int] { + let currentYear = calendar.component(.year, from: now) + var years = Set((currentYear - 5) ... currentYear) + years.insert(displayedYear) + return years.sorted(by: >) + } +} diff --git a/Where/WhereUI/Sources/Year/YearExportView.swift b/Where/WhereUI/Sources/Year/YearExportView.swift new file mode 100644 index 00000000..5eadcb4a --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearExportView.swift @@ -0,0 +1,184 @@ +import SnapshotKit +import SwiftUI + +/// Annual-report configuration sheet. Its model is created afresh by +/// `YearView`, so opt-in GPS disclosure and identity fields never persist +/// between presentations. +struct YearExportView: View { + @Bindable var model: YearExportModel + let isDemo: Bool + + @State private var path: [YearPDFFile] = [] + @State private var generationTask: Task? + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack(path: $path) { + Form { + reportSection + .disabled(model.isGenerating) + paperSection + .disabled(model.isGenerating) + rawGPSSection + .disabled(model.isGenerating) + generationSection + } + .navigationTitle(String(localized: .exportReportTitle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + if model.isGenerating { + Button(String(localized: .commonCancel)) { + generationTask?.cancel() + } + } else { + Button(String(localized: .commonDone)) { + dismiss() + } + } + } + } + .navigationDestination(for: YearPDFFile.self) { file in + YearPDFPreviewView(file: file) + } + .alert( + String(localized: .exportReportFailureTitle), + isPresented: $model.isShowingFailure, + ) { + Button(String(localized: .commonOk), role: .cancel) {} + } message: { + Text(String(localized: .exportReportFailureMessage)) + } + } + .interactiveDismissDisabled(model.isGenerating) + } + + private var reportSection: some View { + Section { + Picker(String(localized: .exportReportYear), selection: $model.selectedYear) { + ForEach(model.availableYears, id: \.self) { year in + Text(WhereFormat.yearText(year)).tag(year) + } + } + TextField( + String(localized: .exportReportPreparedFor), + text: $model.preparedFor, + ) + .textContentType(.name) + TextField( + String(localized: .exportReportReference), + text: $model.reference, + ) + } header: { + Text(String(localized: .exportReportDetailsHeader)) + } footer: { + if isDemo { + Label( + String(localized: .exportReportDemoFooter), + systemImage: "exclamationmark.triangle.fill", + ) + } + } + } + + private var paperSection: some View { + Section(String(localized: .exportReportPaperHeader)) { + Picker(String(localized: .exportReportPaper), selection: $model.pageSize) { + Text(String(localized: .exportReportPaperLetter)).tag(YearPDFPageSize.letter) + Text(String(localized: .exportReportPaperA4)).tag(YearPDFPageSize.a4) + } + .pickerStyle(.segmented) + } + } + + private var rawGPSSection: some View { + Section { + Toggle(isOn: $model.includeRawGPS) { + Label( + String(localized: .exportReportRawGpsToggle), + systemImage: "location.fill", + ) + } + } footer: { + Text(String(localized: .exportReportRawGpsWarning)) + } + } + + private var generationSection: some View { + Section { + Button { + startGeneration() + } label: { + if model.isGenerating { + VStack(alignment: .leading, spacing: 6) { + Label( + String(localized: .exportReportGenerating), + systemImage: "doc.text.magnifyingglass", + ) + if let progress = model.progressFraction { + ProgressView(value: progress) + } else { + ProgressView() + } + } + } else { + Label( + String(localized: .exportReportGenerate), + systemImage: "doc.richtext", + ) + } + } + .disabled(model.isGenerating) + } footer: { + Text(String(localized: .exportReportShareFooter)) + } + } + + private func startGeneration() { + generationTask?.cancel() + generationTask = Task { + if let file = await model.generate(isDemo: isDemo), !Task.isCancelled { + path.append(file) + } + } + } +} + +#if DEBUG + extension YearExportView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + whereSnapshot(name: "Default", configurations: .screenDefaults) { + let report = PreviewSupport.loadedYearReportModel() + YearExportView( + model: YearExportModel( + reader: report.services.reports, + displayedYear: report.selectedYear, + calendar: report.calendar, + locale: Locale(identifier: "en_US"), + buildInfo: .current(bundle: .main), + now: report.now, + ), + isDemo: false, + ) + } + whereSnapshot(name: "Raw GPS warning", configurations: .phoneLightDark) { + let report = PreviewSupport.loadedYearReportModel() + let model = YearExportModel( + reader: report.services.reports, + displayedYear: report.selectedYear, + calendar: report.calendar, + locale: Locale(identifier: "en_US"), + buildInfo: .current(bundle: .main), + now: report.now, + ) + model.includeRawGPS = true + return YearExportView(model: model, isDemo: true) + } + } + } + + #Preview { + YearExportView.snapshotPreviews + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearPDFDocument.swift b/Where/WhereUI/Sources/Year/YearPDFDocument.swift new file mode 100644 index 00000000..48111d2b --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearPDFDocument.swift @@ -0,0 +1,17 @@ +import Foundation +import WhereCore + +/// Immutable inputs for one PDF generation pass. The report already carries a +/// captured attribution policy and time zone; identity fields remain only in +/// this ephemeral value and are never persisted or logged. +struct YearPDFDocument { + let audit: YearAuditReport + let generatedAt: Date + let reportID: UUID + let preparedFor: String? + let reference: String? + let pageSize: YearPDFPageSize + let includeRawGPS: Bool + let isDemo: Bool + let buildInfo: BuildInfo +} diff --git a/Where/WhereUI/Sources/Year/YearPDFFile.swift b/Where/WhereUI/Sources/Year/YearPDFFile.swift new file mode 100644 index 00000000..de1d0bce --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearPDFFile.swift @@ -0,0 +1,19 @@ +import CoreTransferable +import Foundation +import UniformTypeIdentifiers + +/// A completed PDF on disk, exported with its exact content type so every +/// standard share destination receives a real PDF file and suggested name. +struct YearPDFFile: Hashable, Transferable { + let url: URL + let storageDirectory: URL + let suggestedFilename: String + let pageCount: Int + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(exportedContentType: .pdf) { file in + SentTransferredFile(file.url) + } + .suggestedFileName { $0.suggestedFilename } + } +} diff --git a/Where/WhereUI/Sources/Year/YearPDFPageSize.swift b/Where/WhereUI/Sources/Year/YearPDFPageSize.swift new file mode 100644 index 00000000..09dfc31c --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearPDFPageSize.swift @@ -0,0 +1,34 @@ +import CoreGraphics +import Foundation + +/// Paper size for the annual report's portrait body. The raw-GPS appendix +/// swaps these dimensions to landscape without changing the selected paper. +enum YearPDFPageSize: String, CaseIterable, Hashable, Identifiable { + case letter + case a4 + + var id: Self { + self + } + + var portraitBounds: CGRect { + switch self { + case .letter: + CGRect(x: 0, y: 0, width: 612, height: 792) + case .a4: + CGRect(x: 0, y: 0, width: 595.28, height: 841.89) + } + } + + var landscapeBounds: CGRect { + let portrait = portraitBounds + return CGRect(x: 0, y: 0, width: portrait.height, height: portrait.width) + } + + static func defaultValue(for locale: Locale) -> Self { + switch locale.region?.identifier { + case "US", "CA": .letter + case .none, .some: .a4 + } + } +} diff --git a/Where/WhereUI/Sources/Year/YearPDFPreviewView.swift b/Where/WhereUI/Sources/Year/YearPDFPreviewView.swift new file mode 100644 index 00000000..01f95f99 --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearPDFPreviewView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// Paged in-app preview for a completed annual report. Sharing uses the typed +/// `YearPDFFile` representation, so Mail, Files, AirDrop, Messages, and other +/// installed destinations all receive the same finished PDF. +struct YearPDFPreviewView: View { + private let source: PDFDocumentView.Source + private let file: YearPDFFile? + + init(file: YearPDFFile) { + source = .file(file.url) + self.file = file + } + + #if DEBUG + init(previewData: Data) { + source = .data(previewData) + file = nil + } + #endif + + var body: some View { + PDFDocumentView(source: source) + .navigationTitle(String(localized: .exportReportPreviewTitle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let file { + ToolbarItem(placement: .topBarTrailing) { + ShareLink( + item: file, + preview: SharePreview(file.suggestedFilename), + ) { + Label( + String(localized: .exportReportShare), + systemImage: "square.and.arrow.up", + ) + } + } + } + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + YearPDFPreviewView(previewData: PDFDocumentView.previewData) + } + } +#endif diff --git a/Where/WhereUI/Sources/Year/YearPDFRenderer.swift b/Where/WhereUI/Sources/Year/YearPDFRenderer.swift new file mode 100644 index 00000000..0fcbac6b --- /dev/null +++ b/Where/WhereUI/Sources/Year/YearPDFRenderer.swift @@ -0,0 +1,1407 @@ +import CoreGraphics +import Foundation +import RegionKit +import UIKit +import WhereCore + +struct YearPDFProgress: Hashable { + let completedPages: Int + let totalPages: Int +} + +protocol YearPDFRendering: Sendable { + func render( + document: YearPDFDocument, + progress: @escaping @Sendable (YearPDFProgress) -> Void, + ) async throws -> YearPDFFile +} + +/// Deterministic two-pass annual-report renderer. It lays out every page first, +/// then writes searchable text to PDF off the main actor, checking cancellation +/// before each page and removing any partial output on failure. +struct YearPDFRenderer: YearPDFRendering { + @concurrent + func render( + document: YearPDFDocument, + progress: @escaping @Sendable (YearPDFProgress) -> Void, + ) async throws -> YearPDFFile { + try Task.checkCancellation() + let pages = try Layout.makePages(for: document) + try Task.checkCancellation() + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("where-annual-reports", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let filename = Self.filename(for: document) + let url = directory.appendingPathComponent(filename) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + ) + let format = UIGraphicsPDFRendererFormat() + format.documentInfo = Self.metadata(for: document) + let renderer = UIGraphicsPDFRenderer( + bounds: document.pageSize.portraitBounds, + format: format, + ) + progress(YearPDFProgress(completedPages: 0, totalPages: pages.count)) + var cancelled = false + try renderer.writePDF(to: url) { context in + for (index, page) in pages.enumerated() { + guard !Task.isCancelled else { + cancelled = true + return + } + context.beginPage(withBounds: page.bounds, pageInfo: [:]) + page.draw(in: context.cgContext) + Self.drawFooter( + pageNumber: index + 1, + pageCount: pages.count, + bounds: page.bounds, + ) + if document.isDemo { + Self.drawDemoWatermark(in: page.bounds) + } + progress(YearPDFProgress( + completedPages: index + 1, + totalPages: pages.count, + )) + } + } + if cancelled || Task.isCancelled { + throw CancellationError() + } + return YearPDFFile( + url: url, + storageDirectory: directory, + suggestedFilename: filename, + pageCount: pages.count, + ) + } catch { + if FileManager.default.fileExists(atPath: directory.path) { + do { + try FileManager.default.removeItem(at: directory) + } catch { + throw PartialOutputCleanupError() + } + } + throw error + } + } + + private static func filename(for document: YearPDFDocument) -> String { + let prefix = document.isDemo + ? Copy.demoFilenamePrefix + : Copy.filenamePrefix + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = document.audit.timeZone + formatter.dateFormat = "yyyyMMdd-HHmmss" + return "\(prefix) \(document.audit.report.year) \(formatter.string(from: document.generatedAt)).pdf" + } + + private static func metadata(for document: YearPDFDocument) -> [String: Any] { + let year = String(document.audit.report.year) + let title = document.isDemo + ? "\(Copy.demoWarning) - \(Copy.reportTitle) \(year)" + : "\(Copy.reportTitle) \(year)" + let creatorVersion = document.buildInfo.version.map { " \($0)" } ?? "" + return [ + kCGPDFContextTitle as String: title, + kCGPDFContextSubject as String: "\(Copy.subject) \(year)", + kCGPDFContextCreator as String: "Where\(creatorVersion)", + kCGPDFContextKeywords as String: "WhereReportID=\(document.reportID.uuidString)", + "CreationDate": document.generatedAt, + "WhereReportID": document.reportID.uuidString, + "WhereDemoData": document.isDemo ? "true" : "false", + ] + } + + private static func drawFooter(pageNumber: Int, pageCount: Int, bounds: CGRect) { + let text = "\(Copy.page) \(pageNumber) \(Copy.of) \(pageCount)" + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7), + .foregroundColor: UIColor.secondaryLabel, + ] + let size = (text as NSString).size(withAttributes: attributes) + (text as NSString).draw( + at: CGPoint(x: bounds.midX - size.width / 2, y: bounds.maxY - 22), + withAttributes: attributes, + ) + } + + private static func drawDemoWatermark(in bounds: CGRect) { + let context = UIGraphicsGetCurrentContext() + context?.saveGState() + context?.translateBy(x: bounds.midX, y: bounds.midY) + context?.rotate(by: -.pi / 5) + let text = Copy.demoWarning as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: min(bounds.width, bounds.height) / 14), + .foregroundColor: UIColor.systemRed.withAlphaComponent(0.12), + ] + let size = text.size(withAttributes: attributes) + text.draw( + at: CGPoint(x: -size.width / 2, y: -size.height / 2), + withAttributes: attributes, + ) + context?.restoreGState() + } +} + +private struct PartialOutputCleanupError: Error {} + +// MARK: - Pre-layout + +extension YearPDFRenderer { + fileprivate struct Layout { + private static let margin: CGFloat = 44 + private static let footerReserve: CGFloat = 38 + private static let columnGap: CGFloat = 5 + + var document: YearPDFDocument + var pages: [Page] = [] + var pageIndex: Int? + var cursorY: CGFloat = margin + var currentSection: String? + + static func makePages(for document: YearPDFDocument) throws -> [Page] { + var layout = Layout(document: document) + try layout.cover() + try layout.coverageSummary() + try layout.regionalTotals() + try layout.dailyPresence() + try layout.manualEntryAudit() + try layout.evidenceIndex() + try layout.methodology() + if document.includeRawGPS { + try layout.rawGPSAppendix() + } + return layout.pages + } + + private var contentWidth: CGFloat { + currentPage.bounds.width - Self.margin * 2 + } + + private var contentBottom: CGFloat { + currentPage.bounds.height - Self.footerReserve + } + + private var currentPage: Page { + get { pages[pageIndex!] } + set { pages[pageIndex!] = newValue } + } + + private mutating func startPage( + orientation: Orientation, + continuedSection: String? = nil, + ) { + let bounds = orientation == .portrait + ? document.pageSize.portraitBounds + : document.pageSize.landscapeBounds + pages.append(Page(bounds: bounds, commands: [])) + pageIndex = pages.indices.last + cursorY = Self.margin + if let continuedSection { + addLine( + "\(continuedSection) - \(Copy.continued)", + style: .runningHeader, + color: .secondary, + ) + cursorY += 5 + } + } + + private mutating func beginSection(_ title: String, orientation: Orientation = .portrait) { + currentSection = title + startPage(orientation: orientation) + addLine(title, style: .sectionTitle, color: .accent) + cursorY += 10 + } + + private mutating func ensureSpace(_ height: CGFloat, orientation: Orientation) { + guard pageIndex != nil else { + startPage(orientation: orientation) + return + } + let currentOrientation: Orientation = currentPage.bounds.width > currentPage.bounds + .height + ? .landscape + : .portrait + if currentOrientation != orientation || cursorY + height > contentBottom { + startPage(orientation: orientation, continuedSection: currentSection) + } + } + + private mutating func addLine( + _ text: String, + style: TextStyle, + color: PDFColor = .primary, + x: CGFloat? = nil, + width: CGFloat? = nil, + alignment: NSTextAlignment = .left, + ) { + let lineHeight = style.lineHeight + let rect = CGRect( + x: x ?? Self.margin, + y: cursorY, + width: width ?? contentWidth, + height: lineHeight, + ) + currentPage.commands.append(.text(TextCommand( + text: text, + rect: rect, + style: style, + color: color, + alignment: alignment, + ))) + cursorY += lineHeight + } + + private mutating func paragraph( + _ text: String, + style: TextStyle = .body, + color: PDFColor = .primary, + spacingAfter: CGFloat = 8, + orientation: Orientation = .portrait, + ) { + let lines = Self.wrap(text, width: contentWidth, style: style) + for line in lines { + ensureSpace(style.lineHeight, orientation: orientation) + addLine(line, style: style, color: color) + } + cursorY += spacingAfter + } + + private mutating func labelValue( + _ label: String, + _ value: String, + orientation: Orientation = .portrait, + ) { + let labelWidth: CGFloat = min(132, contentWidth * 0.28) + let valueWidth = contentWidth - labelWidth - 8 + let labelLines = Self.wrap(label, width: labelWidth, style: .label) + let valueLines = Self.wrap(value, width: valueWidth, style: .body) + let lineCount = max(labelLines.count, valueLines.count) + var lineOffset = 0 + while lineOffset < lineCount { + let availableHeight = contentBottom - cursorY - 4 + let availableLines = Int(floor(availableHeight / TextStyle.body.lineHeight)) + if availableLines < 1 { + startPage(orientation: orientation, continuedSection: currentSection) + continue + } + let chunkCount = min(availableLines, lineCount - lineOffset) + let startY = cursorY + // Repeat the label on a continuation page so a split value does + // not lose its meaning; the value itself is never truncated. + let visibleLabel = lineOffset == 0 ? labelLines : ["\(label) - \(Copy.continued)"] + for (index, line) in visibleLabel.prefix(chunkCount).enumerated() { + let rect = CGRect( + x: Self.margin, + y: startY + CGFloat(index) * TextStyle.body.lineHeight, + width: labelWidth, + height: TextStyle.body.lineHeight, + ) + currentPage.commands.append(.text(TextCommand( + text: line, + rect: rect, + style: .label, + color: .secondary, + alignment: .left, + ))) + } + let sliceEnd = min(lineOffset + chunkCount, valueLines.count) + if lineOffset < sliceEnd { + for (index, line) in valueLines[lineOffset ..< sliceEnd].enumerated() { + let rect = CGRect( + x: Self.margin + labelWidth + 8, + y: startY + CGFloat(index) * TextStyle.body.lineHeight, + width: valueWidth, + height: TextStyle.body.lineHeight, + ) + currentPage.commands.append(.text(TextCommand( + text: line, + rect: rect, + style: .body, + color: .primary, + alignment: .left, + ))) + } + } + cursorY += CGFloat(chunkCount) * TextStyle.body.lineHeight + 4 + lineOffset += chunkCount + } + } + + private mutating func table( + headers: [String], + widths: [CGFloat], + rows: [[String]], + orientation: Orientation = .portrait, + style: TextStyle = .table, + ) throws { + precondition(headers.count == widths.count) + precondition(rows.allSatisfy { $0.count == headers.count }) + var rowNumber = 0 + + func columnFrames(totalWidth: CGFloat) -> [CGRect] { + let available = totalWidth - Self.columnGap * CGFloat(widths.count - 1) + var x = Self.margin + return widths.map { fraction in + defer { x += available * fraction + Self.columnGap } + return CGRect(x: x, y: 0, width: available * fraction, height: 0) + } + } + + let columns = columnFrames(totalWidth: contentWidth) + + func headerHeight() -> CGFloat { + let counts = zip(headers, columns).map { + Self.wrap($0.0, width: $0.1.width - 8, style: .tableHeader).count + } + return CGFloat(max(counts.max() ?? 1, 1)) * TextStyle.tableHeader.lineHeight + 8 + } + + func drawHeader() { + let height = headerHeight() + ensureSpace(height + style.lineHeight + 6, orientation: orientation) + let rect = CGRect(x: Self.margin, y: cursorY, width: contentWidth, height: height) + currentPage.commands.append(.fill(rect, .tableHeaderFill)) + for (header, column) in zip(headers, columns) { + let lines = Self.wrap(header, width: column.width - 8, style: .tableHeader) + for (index, line) in lines.enumerated() { + let textRect = CGRect( + x: column.minX + 4, + y: cursorY + 4 + CGFloat(index) * TextStyle.tableHeader.lineHeight, + width: column.width - 8, + height: TextStyle.tableHeader.lineHeight, + ) + currentPage.commands.append(.text(TextCommand( + text: line, + rect: textRect, + style: .tableHeader, + color: .white, + alignment: .left, + ))) + } + } + cursorY += height + } + + drawHeader() + for row in rows { + try Task.checkCancellation() + let wrapped = zip(row, columns).map { + Self.wrap($0.0, width: $0.1.width - 8, style: style) + } + let lineCount = max(wrapped.map(\.count).max() ?? 1, 1) + var lineOffset = 0 + while lineOffset < lineCount { + let availableHeight = contentBottom - cursorY - 8 + let availableLines = Int(floor(availableHeight / style.lineHeight)) + if availableLines < 1 { + startPage(orientation: orientation, continuedSection: currentSection) + drawHeader() + continue + } + let chunkCount = min(availableLines, lineCount - lineOffset) + let height = CGFloat(chunkCount) * style.lineHeight + 8 + let rowRect = CGRect( + x: Self.margin, + y: cursorY, + width: contentWidth, + height: height, + ) + if rowNumber.isMultiple(of: 2) { + currentPage.commands.append(.fill(rowRect, .alternateRow)) + } + currentPage.commands.append(.stroke(rowRect, .tableRule)) + for (lines, column) in zip(wrapped, columns) { + let sliceEnd = min(lineOffset + chunkCount, lines.count) + guard lineOffset < sliceEnd else { continue } + for (localIndex, line) in lines[lineOffset ..< sliceEnd].enumerated() { + let rect = CGRect( + x: column.minX + 4, + y: cursorY + 4 + CGFloat(localIndex) * style.lineHeight, + width: column.width - 8, + height: style.lineHeight, + ) + currentPage.commands.append(.text(TextCommand( + text: line, + rect: rect, + style: style, + color: .primary, + alignment: .left, + ))) + } + } + cursorY += height + lineOffset += chunkCount + } + rowNumber += 1 + } + cursorY += 8 + } + + private static func wrap(_ text: String, width: CGFloat, style: TextStyle) -> [String] { + let normalized = text.replacingOccurrences(of: "\t", with: " ") + let paragraphs = normalized.split(separator: "\n", omittingEmptySubsequences: false) + var result: [String] = [] + for paragraph in paragraphs { + let value = String(paragraph) + if value.isEmpty { + result.append("") + continue + } + var line = "" + for character in value { + let candidate = line + String(character) + if style.width(of: candidate) <= width || line.isEmpty { + line = candidate + } else { + let breakIndex = line.lastIndex(where: { $0.isWhitespace }) + if let breakIndex { + let prefix = String(line[.. rhs.value } + return lhs.key.localizedName < rhs.key.localizedName + } + .map { [$0.key.localizedName, String($0.value)] } + try table( + headers: [Copy.region, Copy.jurisdictionDays], + widths: [0.76, 0.24], + rows: rows.isEmpty ? [[Copy.none, "0"]] : rows, + ) + } + + private mutating func dailyPresence() throws { + try Task.checkCancellation() + beginSection(Copy.dailyPresence) + paragraph(Copy.dailyPresenceExplanation) + let calendar = Self.calendar(timeZone: document.audit.timeZone) + let rows = document.audit.report.days.map { day in + let regions = Region.inCanonicalOrder(day.regions) + .map(\.localizedName) + .joined(separator: ", ") + let bases = document.audit.bases(on: day.day, calendar: calendar) + .sorted(by: { Self.basisOrder($0) < Self.basisOrder($1) }) + .map(Self.basisName) + .joined(separator: ", ") + return [day.day.description, regions, bases] + } + try table( + headers: [Copy.date, Copy.finalRegions, Copy.basis], + widths: [0.20, 0.36, 0.44], + rows: rows.isEmpty ? [[Copy.none, Copy.none, Copy.none]] : rows, + ) + } + + private mutating func manualEntryAudit() throws { + try Task.checkCancellation() + beginSection(Copy.manualEntryAudit) + paragraph(Copy.manualAuditExplanation) + let rows = Self.groupedManualDays(document.audit.manualDays).map { group in + [ + Self.dayRange(group.firstDay, group.lastDay), + group.isAuthoritative ? Copy.authoritativeOverride : Copy.additiveBackfill, + group.regions.map(\.localizedName).joined(separator: ", "), + group.audit.map { + Self.timestamp($0.recordedAt, timeZone: document.audit.timeZone) + } ?? Copy.notAvailable, + group.audit.flatMap(\.note).flatMap(Self.nonempty) ?? Copy.none, + Self.auditLocation(group.audit, timeZone: document.audit.timeZone), + ] + } + try table( + headers: [ + Copy.affectedDates, + Copy.mode, + Copy.regions, + Copy.recordedAt, + Copy.note, + Copy.editLocation, + ], + widths: [0.16, 0.14, 0.17, 0.18, 0.17, 0.18], + rows: rows.isEmpty + ? [[Copy.none, Copy.none, Copy.none, Copy.none, Copy.none, Copy.none]] + : rows, + style: .tableSmall, + ) + } + + private mutating func evidenceIndex() throws { + try Task.checkCancellation() + beginSection(Copy.evidenceIndex) + paragraph(Copy.evidenceExplanation) + let rows = document.audit.evidence.map { evidence in + [ + evidence.id.uuidString, + Self.timestamp(evidence.capturedAt, timeZone: document.audit.timeZone), + WhereFormat.evidenceKind(evidence.kind), + evidence.region?.localizedName ?? Copy.notAssigned, + Self.contentType(evidence.contentType), + Self.nonempty(evidence.note ?? "") ?? Copy.none, + ] + } + try table( + headers: [ + Copy.evidenceID, + Copy.captureDate, + Copy.kind, + Copy.assignedRegion, + Copy.contentType, + Copy.note, + ], + widths: [0.20, 0.17, 0.13, 0.16, 0.12, 0.22], + rows: rows.isEmpty + ? [[Copy.none, Copy.none, Copy.none, Copy.none, Copy.none, Copy.none]] + : rows, + style: .tableSmall, + ) + } + + private mutating func methodology() throws { + try Task.checkCancellation() + beginSection(Copy.methodology) + paragraph(Copy.methodCurrentDevice) + paragraph(Copy.methodEditsImports) + paragraph(Copy.methodTimezone) + paragraph(Copy.methodManual) + paragraph(Copy.methodOther) + paragraph(Copy.methodNoCertification) + + if document.audit.regionDataSources.contains(where: { $0.fidelity == .approximate }) { + paragraph(Copy.approximateGeometryWarning, style: .warningBody, color: .warning) + } + + let sourceRows = document.audit.regionDataSources.map { source in + let fidelity = source.fidelity == .authoritative + ? Copy.authoritativeGeometry + : Copy.approximateGeometry + let regions = source.regions + .filter { document.audit.trackedRegions.contains($0) } + .map(\.localizedName) + .joined(separator: ", ") + let links = [source.sourceURL, source.obtainedFromURL] + .compactMap { $0?.absoluteString } + .joined(separator: "\n") + return [source.name, fidelity, regions, links.isEmpty ? Copy.notAvailable : links] + } + try table( + headers: [Copy.geometrySource, Copy.fidelity, Copy.regions, Copy.sourceLinks], + widths: [0.28, 0.16, 0.24, 0.32], + rows: sourceRows, + style: .tableSmall, + ) + } + + private mutating func rawGPSAppendix() throws { + try Task.checkCancellation() + beginSection(Copy.rawGPSAppendix, orientation: .landscape) + paragraph( + Copy.rawGPSExplanation, + style: .body, + orientation: .landscape, + ) + let rows = document.audit.samples + .filter(\.sample.source.isGPS) + .map { attributed in + let sample = attributed.sample + return [ + sample.id.uuidString, + Self.timestamp(sample.timestamp, timeZone: document.audit.timeZone), + sample.source.discriminator, + attributed.region.localizedName, + String( + format: "%.6f", + locale: Locale(identifier: "en_US_POSIX"), + sample.coordinate.latitude, + ), + String( + format: "%.6f", + locale: Locale(identifier: "en_US_POSIX"), + sample.coordinate.longitude, + ), + String( + format: "%.0f %@", + locale: Locale(identifier: "en_US_POSIX"), + sample.horizontalAccuracy, + Copy.metersAbbreviation, + ), + ] + } + try table( + headers: [ + Copy.sampleUUID, + Copy.timestamp, + Copy.storedSource, + Copy.attributedRegion, + Copy.latitude, + Copy.longitude, + Copy.accuracy, + ], + widths: [0.25, 0.19, 0.13, 0.13, 0.10, 0.11, 0.09], + rows: rows.isEmpty + ? [[ + Copy.none, + Copy.none, + Copy.none, + Copy.none, + Copy.none, + Copy.none, + Copy.none, + ]] + : rows, + orientation: .landscape, + style: .tableTiny, + ) + } + + // MARK: Formatting helpers + + private static func calendar(timeZone: TimeZone) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + private static func coverageCutoff( + reportYear: Int, + generatedAt: Date, + timeZone: TimeZone, + ) -> CalendarDay { + let today = CalendarDay(from: generatedAt, in: calendar(timeZone: timeZone)) + if reportYear < today.year { return CalendarDay.lastDay(ofYear: reportYear) } + if reportYear == today.year { return today.adding(days: -1) } + return CalendarDay(year: reportYear, month: 1, day: 1).adding(days: -1) + } + + private static func missingRanges(_ ranges: [MissingDayRange]) -> String { + guard !ranges.isEmpty else { return Copy.none } + return ranges.map { range in + if range.start == range.end { return range.start.description } + return "\(range.start.description) - \(range.end.description) (\(range.dayCount))" + }.joined(separator: ", ") + } + + private static func timestamp(_ date: Date, timeZone: TimeZone) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = timeZone + return formatter.string(from: date) + } + + private static func timeZoneDescription(_ timeZone: TimeZone, at date: Date) -> String { + let seconds = timeZone.secondsFromGMT(for: date) + let sign = seconds < 0 ? "-" : "+" + let absolute = abs(seconds) + let hours = absolute / 3600 + let minutes = (absolute % 3600) / 60 + return "\(timeZone.identifier) (UTC\(sign)\(String(format: "%02d:%02d", hours, minutes)))" + } + + private static func basisOrder(_ basis: YearAuditDayBasis) -> Int { + switch basis { + case .gps: 0 + case .manualCoordinate: 1 + case .evidenceDerivedCoordinate: 2 + case .additiveManualEntry: 3 + case .authoritativeManualOverride: 4 + } + } + + private static func basisName(_ basis: YearAuditDayBasis) -> String { + switch basis { + case .gps: Copy.basisGPS + case .manualCoordinate: Copy.basisManualCoordinate + case .evidenceDerivedCoordinate: Copy.basisEvidenceCoordinate + case .additiveManualEntry: Copy.basisAdditive + case .authoritativeManualOverride: Copy.basisOverride + } + } + + private static func contentType(_ type: EvidenceContentType) -> String { + switch type { + case .pdf: Copy.typePDF + case .image: Copy.typeImage + case .plainText: Copy.typeText + case .rawData: Copy.typeRawData + case let .other(label): nonempty(label ?? "") ?? Copy.typeOther + } + } + + private static func auditLocation( + _ audit: ManualEntryAudit?, + timeZone: TimeZone, + ) -> String { + guard let location = audit?.location else { return Copy.notAvailable } + return [ + timestamp(location.timestamp, timeZone: timeZone), + String( + format: "%.6f, %.6f", + locale: Locale(identifier: "en_US_POSIX"), + location.coordinate.latitude, + location.coordinate.longitude, + ), + String( + format: "%.0f %@", + locale: Locale(identifier: "en_US_POSIX"), + location.horizontalAccuracy, + Copy.metersAbbreviation, + ), + ].joined(separator: "\n") + } + + private static func nonempty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func groupedManualDays(_ days: [DayPresence]) -> [ManualGroup] { + var groups: [ManualGroup] = [] + for day in days.sorted(by: { $0.day < $1.day }) { + let regions = Region.inCanonicalOrder(day.regions) + if let last = groups.last, + last.lastDay.adding(days: 1) == day.day, + last.isAuthoritative == day.isAuthoritative, + last.regions == regions, + last.audit == day.audit + { + groups[groups.count - 1] = ManualGroup( + firstDay: last.firstDay, + lastDay: day.day, + regions: regions, + isAuthoritative: day.isAuthoritative, + audit: day.audit, + ) + } else { + groups.append(ManualGroup( + firstDay: day.day, + lastDay: day.day, + regions: regions, + isAuthoritative: day.isAuthoritative, + audit: day.audit, + )) + } + } + return groups + } + + private static func dayRange(_ first: CalendarDay, _ last: CalendarDay) -> String { + first == last ? first.description : "\(first.description) - \(last.description)" + } + } + + fileprivate struct ManualGroup { + let firstDay: CalendarDay + let lastDay: CalendarDay + let regions: [Region] + let isAuthoritative: Bool + let audit: ManualEntryAudit? + } + + fileprivate enum Orientation { + case portrait + case landscape + } + + fileprivate struct Page { + let bounds: CGRect + var commands: [DrawCommand] + + func draw(in context: CGContext) { + for command in commands { + switch command { + case let .fill(rect, color): + context.setFillColor(color.uiColor.cgColor) + context.fill(rect) + case let .stroke(rect, color): + context.setStrokeColor(color.uiColor.cgColor) + context.setLineWidth(0.35) + context.stroke(rect) + case let .text(command): + command.draw() + } + } + } + } + + fileprivate enum DrawCommand { + case fill(CGRect, PDFColor) + case stroke(CGRect, PDFColor) + case text(TextCommand) + } + + fileprivate struct TextCommand { + let text: String + let rect: CGRect + let style: TextStyle + let color: PDFColor + let alignment: NSTextAlignment + + func draw() { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = alignment + paragraph.lineBreakMode = .byClipping + (text as NSString).draw( + in: rect, + withAttributes: [ + .font: style.font, + .foregroundColor: color.uiColor, + .paragraphStyle: paragraph, + ], + ) + } + } + + fileprivate enum TextStyle { + case coverTitle + case coverYear + case demoTitle + case sectionTitle + case runningHeader + case body + case warningBody + case label + case tableHeader + case table + case tableSmall + case tableTiny + + var font: UIFont { + switch self { + case .coverTitle: UIFont.systemFont(ofSize: 28, weight: .bold) + case .coverYear: UIFont.systemFont(ofSize: 44, weight: .light) + case .demoTitle: UIFont.systemFont(ofSize: 18, weight: .black) + case .sectionTitle: UIFont.systemFont(ofSize: 20, weight: .bold) + case .runningHeader: UIFont.systemFont(ofSize: 9, weight: .semibold) + case .body: UIFont.systemFont(ofSize: 9.5) + case .warningBody: UIFont.systemFont(ofSize: 9.5, weight: .semibold) + case .label: UIFont.systemFont(ofSize: 9.5, weight: .semibold) + case .tableHeader: UIFont.systemFont(ofSize: 7.5, weight: .bold) + case .table: UIFont.systemFont(ofSize: 7.5) + case .tableSmall: UIFont.systemFont(ofSize: 6.7) + case .tableTiny: UIFont.monospacedSystemFont(ofSize: 6.2, weight: .regular) + } + } + + var lineHeight: CGFloat { + ceil(font.lineHeight + 1) + } + + func width(of text: String) -> CGFloat { + ceil((text as NSString).size(withAttributes: [.font: font]).width) + } + } + + fileprivate enum PDFColor { + case primary + case secondary + case accent + case warning + case white + case tableHeaderFill + case alternateRow + case tableRule + + var uiColor: UIColor { + switch self { + case .primary: .black + case .secondary: UIColor(white: 0.32, alpha: 1) + case .accent: UIColor(red: 0.08, green: 0.27, blue: 0.48, alpha: 1) + case .warning: UIColor(red: 0.72, green: 0.08, blue: 0.08, alpha: 1) + case .white: .white + case .tableHeaderFill: UIColor(red: 0.12, green: 0.28, blue: 0.45, alpha: 1) + case .alternateRow: UIColor(white: 0.95, alpha: 1) + case .tableRule: UIColor(white: 0.78, alpha: 1) + } + } + } +} + +extension YearPDFRenderer { + fileprivate enum Copy { + static var reportTitle: String { + String(localized: .exportReportPdfTitle) + } + + static var subject: String { + String(localized: .exportReportPdfSubject) + } + + static var filenamePrefix: String { + String(localized: .exportReportFilename) + } + + static var demoFilenamePrefix: String { + String(localized: .exportReportDemoFilename) + } + + static var demoWarning: String { + String(localized: .exportReportDemoWarning) + } + + static var demoDisclaimer: String { + String(localized: .exportReportPdfDemoDisclaimer) + } + + static var auditDisclaimer: String { + String(localized: .exportReportPdfAuditDisclaimer) + } + + static var preparedFor: String { + String(localized: .exportReportPreparedFor) + } + + static var reference: String { + String(localized: .exportReportReference) + } + + static var generated: String { + String(localized: .exportReportPdfGenerated) + } + + static var reportID: String { + String(localized: .exportReportPdfReportId) + } + + static var reportingTimeZone: String { + String(localized: .exportReportPdfReportingTimeZone) + } + + static var appVersion: String { + String(localized: .exportReportPdfAppVersion) + } + + static var appBuild: String { + String(localized: .exportReportPdfAppBuild) + } + + static var notAvailable: String { + String(localized: .exportReportPdfNotAvailable) + } + + static var none: String { + String(localized: .exportReportPdfNone) + } + + static var page: String { + String(localized: .exportReportPdfPage) + } + + static var of: String { + String(localized: .exportReportPdfOf) + } + + static var continued: String { + String(localized: .exportReportPdfContinued) + } + + static var coverageSummary: String { + String(localized: .exportReportPdfCoverageSummary) + } + + static var distinctLoggedDays: String { + String(localized: .exportReportPdfDistinctLoggedDays) + } + + static var daysWithoutPresence: String { + String(localized: .exportReportPdfDaysWithoutPresence) + } + + static var missingDateRanges: String { + String(localized: .exportReportPdfMissingDateRanges) + } + + static var source: String { + String(localized: .exportReportPdfSource) + } + + static var count: String { + String(localized: .exportReportPdfCount) + } + + static var gpsVisit: String { + String(localized: .exportReportPdfGpsVisit) + } + + static var gpsSignificantChange: String { + String(localized: .exportReportPdfGpsSignificantChange) + } + + static var manualCoordinate: String { + String(localized: .exportReportPdfManualCoordinate) + } + + static var evidenceCoordinate: String { + String(localized: .exportReportPdfEvidenceCoordinate) + } + + static var manualDayRecords: String { + String(localized: .exportReportPdfManualDayRecords) + } + + static var evidenceRecords: String { + String(localized: .exportReportPdfEvidenceRecords) + } + + static var trackedRegions: String { + String(localized: .exportReportPdfTrackedRegions) + } + + static var todayIncomplete: String { + String(localized: .exportReportPdfTodayIncomplete) + } + + static var regionalTotals: String { + String(localized: .exportReportPdfRegionalTotals) + } + + static var nonExclusiveExplanation: String { + String(localized: .exportReportPdfNonExclusiveExplanation) + } + + static var region: String { + String(localized: .exportReportPdfRegion) + } + + static var jurisdictionDays: String { + String(localized: .exportReportPdfJurisdictionDays) + } + + static var dailyPresence: String { + String(localized: .exportReportPdfDailyPresence) + } + + static var dailyPresenceExplanation: String { + String(localized: .exportReportPdfDailyPresenceExplanation) + } + + static var date: String { + String(localized: .exportReportPdfDate) + } + + static var finalRegions: String { + String(localized: .exportReportPdfFinalRegions) + } + + static var basis: String { + String(localized: .exportReportPdfBasis) + } + + static var basisGPS: String { + String(localized: .exportReportPdfBasisGps) + } + + static var basisManualCoordinate: String { + String(localized: .exportReportPdfBasisManualCoordinate) + } + + static var basisEvidenceCoordinate: String { + String(localized: .exportReportPdfBasisEvidenceCoordinate) + } + + static var basisAdditive: String { + String(localized: .exportReportPdfBasisAdditive) + } + + static var basisOverride: String { + String(localized: .exportReportPdfBasisOverride) + } + + static var manualEntryAudit: String { + String(localized: .exportReportPdfManualEntryAudit) + } + + static var manualAuditExplanation: String { + String(localized: .exportReportPdfManualAuditExplanation) + } + + static var affectedDates: String { + String(localized: .exportReportPdfAffectedDates) + } + + static var mode: String { + String(localized: .exportReportPdfMode) + } + + static var regions: String { + String(localized: .exportReportPdfRegions) + } + + static var recordedAt: String { + String(localized: .exportReportPdfRecordedAt) + } + + static var note: String { + String(localized: .exportReportPdfNote) + } + + static var editLocation: String { + String(localized: .exportReportPdfEditLocation) + } + + static var authoritativeOverride: String { + String(localized: .exportReportPdfAuthoritativeOverride) + } + + static var additiveBackfill: String { + String(localized: .exportReportPdfAdditiveBackfill) + } + + static var evidenceIndex: String { + String(localized: .exportReportPdfEvidenceIndex) + } + + static var evidenceExplanation: String { + String(localized: .exportReportPdfEvidenceExplanation) + } + + static var evidenceID: String { + String(localized: .exportReportPdfEvidenceId) + } + + static var captureDate: String { + String(localized: .exportReportPdfCaptureDate) + } + + static var kind: String { + String(localized: .exportReportPdfKind) + } + + static var assignedRegion: String { + String(localized: .exportReportPdfAssignedRegion) + } + + static var contentType: String { + String(localized: .exportReportPdfContentType) + } + + static var notAssigned: String { + String(localized: .exportReportPdfNotAssigned) + } + + static var typePDF: String { + String(localized: .exportReportPdfTypePdf) + } + + static var typeImage: String { + String(localized: .exportReportPdfTypeImage) + } + + static var typeText: String { + String(localized: .exportReportPdfTypeText) + } + + static var typeRawData: String { + String(localized: .exportReportPdfTypeRawData) + } + + static var typeOther: String { + String(localized: .exportReportPdfTypeOther) + } + + static var methodology: String { + String(localized: .exportReportPdfMethodology) + } + + static var methodCurrentDevice: String { + String(localized: .exportReportPdfMethodCurrentDevice) + } + + static var methodEditsImports: String { + String(localized: .exportReportPdfMethodEditsImports) + } + + static var methodTimezone: String { + String(localized: .exportReportPdfMethodTimezone) + } + + static var methodManual: String { + String(localized: .exportReportPdfMethodManual) + } + + static var methodOther: String { + String(localized: .exportReportPdfMethodOther) + } + + static var methodNoCertification: String { + String(localized: .exportReportPdfMethodNoCertification) + } + + static var approximateGeometryWarning: String { + String(localized: .exportReportPdfApproximateGeometryWarning) + } + + static var authoritativeGeometry: String { + String(localized: .exportReportPdfAuthoritativeGeometry) + } + + static var approximateGeometry: String { + String(localized: .exportReportPdfApproximateGeometry) + } + + static var geometrySource: String { + String(localized: .exportReportPdfGeometrySource) + } + + static var fidelity: String { + String(localized: .exportReportPdfFidelity) + } + + static var sourceLinks: String { + String(localized: .exportReportPdfSourceLinks) + } + + static var rawGPSAppendix: String { + String(localized: .exportReportPdfRawGpsAppendix) + } + + static var rawGPSExplanation: String { + String(localized: .exportReportPdfRawGpsExplanation) + } + + static var sampleUUID: String { + String(localized: .exportReportPdfSampleUuid) + } + + static var timestamp: String { + String(localized: .exportReportPdfTimestamp) + } + + static var storedSource: String { + String(localized: .exportReportPdfStoredSource) + } + + static var attributedRegion: String { + String(localized: .exportReportPdfAttributedRegion) + } + + static var latitude: String { + String(localized: .exportReportPdfLatitude) + } + + static var longitude: String { + String(localized: .exportReportPdfLongitude) + } + + static var accuracy: String { + String(localized: .exportReportPdfAccuracy) + } + + static var metersAbbreviation: String { + String(localized: .exportReportPdfMetersAbbreviation) + } + } +} diff --git a/Where/WhereUI/Sources/Year/YearView.swift b/Where/WhereUI/Sources/Year/YearView.swift index b19ec049..d9c94082 100644 --- a/Where/WhereUI/Sources/Year/YearView.swift +++ b/Where/WhereUI/Sources/Year/YearView.swift @@ -11,8 +11,11 @@ struct YearView: View { @State private var mode: YearMode @State private var showingRecentActivity = false + @State private var showingExport = false + @State private var exportModel: YearExportModel? @Environment(\.stylesheet) private var stylesheet + @Environment(\.isInDemoMode) private var isInDemoMode init(report: YearReportModel, initialMode: YearMode = .calendar) { self.report = report @@ -51,11 +54,38 @@ struct YearView: View { } .accessibilityIdentifier("where_recent_activity_button") } + ToolbarItem(placement: .topBarTrailing) { + Button { + exportModel = YearExportModel( + reader: report.services.reports, + displayedYear: report.selectedYear, + calendar: report.calendar, + locale: .current, + buildInfo: .current(bundle: .main), + now: report.now, + ) + showingExport = true + } label: { + Label( + String(localized: .exportReportToolbar), + systemImage: "square.and.arrow.up", + ) + } + .accessibilityIdentifier("where_export_report_button") + } } } .sheet(isPresented: $showingRecentActivity) { RecentActivitySummaryView(report: report) } + .sheet(isPresented: $showingExport, onDismiss: { + exportModel?.sheetDidDismiss() + exportModel = nil + }) { + if let exportModel { + YearExportView(model: exportModel, isDemo: isInDemoMode) + } + } } } diff --git a/Where/WhereUI/Tests/YearExportDefaultsTests.swift b/Where/WhereUI/Tests/YearExportDefaultsTests.swift new file mode 100644 index 00000000..8eab738a --- /dev/null +++ b/Where/WhereUI/Tests/YearExportDefaultsTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import WhereUI + +struct YearExportDefaultsTests { + @Test func currentYearDefaultsToPreviousFromJanuaryThroughMarch() throws { + let calendar = YearPDFTestSupport.calendar + for month in 1 ... 3 { + let now = try #require(calendar.date(from: DateComponents( + year: 2026, + month: month, + day: 15, + ))) + #expect(YearExportDefaults.selectedYear( + displayedYear: 2026, + now: now, + calendar: calendar, + ) == 2025) + } + } + + @Test func currentYearRollsToCurrentInApril() throws { + let calendar = YearPDFTestSupport.calendar + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + #expect(YearExportDefaults.selectedYear( + displayedYear: 2026, + now: now, + calendar: calendar, + ) == 2026) + } + + @Test func nonCurrentDisplayedYearHasPriorityAndRemainsAvailableOutsideRange() throws { + let calendar = YearPDFTestSupport.calendar + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 2, day: 1))) + #expect(YearExportDefaults.selectedYear( + displayedYear: 2018, + now: now, + calendar: calendar, + ) == 2018) + #expect(YearExportDefaults.availableYears( + displayedYear: 2018, + now: now, + calendar: calendar, + ) == [2026, 2025, 2024, 2023, 2022, 2021, 2018]) + } +} diff --git a/Where/WhereUI/Tests/YearExportModelTests.swift b/Where/WhereUI/Tests/YearExportModelTests.swift new file mode 100644 index 00000000..f07d6db9 --- /dev/null +++ b/Where/WhereUI/Tests/YearExportModelTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct YearExportModelTests { + @Test func eachPresentationStartsWithEphemeralFieldsAndRawGPSOff() throws { + let first = try makeModel(renderer: ScriptedYearPDFRenderer([.success])) + first.preparedFor = "Accountant" + first.reference = "Ref" + first.includeRawGPS = true + + let second = try makeModel(renderer: ScriptedYearPDFRenderer([.success])) + + #expect(second.preparedFor.isEmpty) + #expect(second.reference.isEmpty) + #expect(!second.includeRawGPS) + } + + @Test func progressAndSuccessProduceAReadyFile() async throws { + let renderer = ScriptedYearPDFRenderer([.slowSuccess]) + let model = try makeModel(renderer: renderer) + + let task = Task { await model.generate(isDemo: false) } + try await waitUntil { model.progressFraction == 0.5 } + let file = try #require(await task.value) + + #expect(model.state == .ready(file)) + #expect(file.pageCount == 2) + #expect(FileManager.default.fileExists(atPath: file.url.path)) + try? FileManager.default.removeItem(at: file.storageDirectory) + } + + @Test func cancellationReturnsToIdleAndDoesNotCreateAFile() async throws { + let renderer = ScriptedYearPDFRenderer([.wait]) + let model = try makeModel(renderer: renderer) + let task = Task { await model.generate(isDemo: false) } + try await waitUntil { await renderer.started } + + task.cancel() + #expect(await task.value == nil) + #expect(model.state == .idle) + #expect(await renderer.createdFiles.isEmpty) + } + + @Test func cancellationWhileProgressDrainsPurgesTheCompletedFile() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let canceller = GenerationCanceller() + let renderer = PostRenderCancellationRenderer( + directory: directory, + scheduleCancellation: { + Task { @MainActor in + await Task.yield() + canceller.cancel() + } + }, + ) + let model = try makeModel(renderer: renderer) + let task = Task { await model.generate(isDemo: false) } + canceller.action = { task.cancel() } + + #expect(await task.value == nil) + #expect(model.state == .idle) + #expect(FileManager.default.fileExists(atPath: directory.path) == false) + } + + @Test func failureCanBeAcknowledgedAndRetried() async throws { + let renderer = ScriptedYearPDFRenderer([.failure, .success]) + let model = try makeModel(renderer: renderer) + + #expect(await model.generate(isDemo: false) == nil) + #expect(model.state == .failed) + #expect(model.isShowingFailure) + model.isShowingFailure = false + #expect(model.state == .idle) + + let file = try #require(await model.generate(isDemo: false)) + #expect(model.state == .ready(file)) + try? FileManager.default.removeItem(at: file.storageDirectory) + } + + @Test func changingFormInputPurgesSupersededFileImmediately() async throws { + let model = try makeModel(renderer: ScriptedYearPDFRenderer([.success])) + let file = try #require(await model.generate(isDemo: false)) + #expect(FileManager.default.fileExists(atPath: file.url.path)) + + model.reference = "Changed" + + #expect(model.state == .idle) + #expect(!FileManager.default.fileExists(atPath: file.storageDirectory.path)) + } + + @Test func successfulFileIsRetainedThenCleanedAfterDismissal() async throws { + let model = try makeModel( + renderer: ScriptedYearPDFRenderer([.success]), + retention: .milliseconds(20), + ) + let file = try #require(await model.generate(isDemo: true)) + + model.sheetDidDismiss() + #expect(FileManager.default.fileExists(atPath: file.url.path)) + try await waitUntil { + !FileManager.default.fileExists(atPath: file.storageDirectory.path) + } + #expect(model.state == .idle) + } + + private func makeModel( + renderer: any YearPDFRendering, + retention: Duration = .seconds(600), + ) throws -> YearExportModel { + let store = try SwiftDataStore.inMemory() + let services = WhereServices(store: store, locationSource: ScriptedLocationSource()) + return YearExportModel( + reader: services.reports, + displayedYear: 2024, + calendar: YearPDFTestSupport.calendar, + locale: Locale(identifier: "en_US"), + buildInfo: YearPDFTestSupport.buildInfo, + now: { YearPDFTestSupport.date("2026-07-31T12:00:00-07:00") }, + renderer: renderer, + retention: retention, + ) + } + + private func waitUntil( + timeout: Duration = .seconds(2), + _ condition: @escaping @MainActor () async -> Bool, + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if await condition() { return } + try await Task.sleep(for: .milliseconds(2)) + } + Issue.record("Condition did not become true before timeout") + } +} + +private actor ScriptedYearPDFRenderer: YearPDFRendering { + enum Behavior { + case success + case slowSuccess + case failure + case wait + } + + private var behaviors: [Behavior] + private(set) var started = false + private(set) var createdFiles: [YearPDFFile] = [] + + init(_ behaviors: [Behavior]) { + self.behaviors = behaviors + } + + func render( + document: YearPDFDocument, + progress: @escaping @Sendable (YearPDFProgress) -> Void, + ) async throws -> YearPDFFile { + started = true + let behavior = behaviors.isEmpty ? .success : behaviors.removeFirst() + switch behavior { + case .failure: + throw ScriptedFailure() + case .wait: + try await Task.sleep(for: .seconds(60)) + throw CancellationError() + case .slowSuccess: + progress(YearPDFProgress(completedPages: 1, totalPages: 2)) + try await Task.sleep(for: .milliseconds(50)) + return try makeFile(document: document, progress: progress) + case .success: + return try makeFile(document: document, progress: progress) + } + } + + private func makeFile( + document: YearPDFDocument, + progress: @escaping @Sendable (YearPDFProgress) -> Void, + ) throws -> YearPDFFile { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("fixture.pdf") + try Data("fixture".utf8).write(to: url) + progress(YearPDFProgress(completedPages: 2, totalPages: 2)) + let prefix = document.isDemo ? "Where Demo Presence Report" : "Where Presence Report" + let file = YearPDFFile( + url: url, + storageDirectory: directory, + suggestedFilename: "\(prefix) 2024 fixture.pdf", + pageCount: 2, + ) + createdFiles.append(file) + return file + } +} + +private struct ScriptedFailure: Error {} + +@MainActor +private final class GenerationCanceller { + var action: @MainActor @Sendable () -> Void = {} + + func cancel() { + action() + } +} + +/// Runs on `generate`'s caller actor, queues cancellation behind the final +/// progress update, and returns a completed file before either queued task can +/// run. This deterministically exercises cancellation during progress drain. +private struct PostRenderCancellationRenderer: YearPDFRendering { + let directory: URL + let scheduleCancellation: @Sendable () -> Void + + func render( + document _: YearPDFDocument, + progress: @escaping @Sendable (YearPDFProgress) -> Void, + ) async throws -> YearPDFFile { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("fixture.pdf") + try Data("fixture".utf8).write(to: url) + progress(YearPDFProgress(completedPages: 1, totalPages: 1)) + scheduleCancellation() + return YearPDFFile( + url: url, + storageDirectory: directory, + suggestedFilename: "Where Presence Report fixture.pdf", + pageCount: 1, + ) + } +} diff --git a/Where/WhereUI/Tests/YearPDFPageSizeTests.swift b/Where/WhereUI/Tests/YearPDFPageSizeTests.swift new file mode 100644 index 00000000..d218b40f --- /dev/null +++ b/Where/WhereUI/Tests/YearPDFPageSizeTests.swift @@ -0,0 +1,15 @@ +import Foundation +import Testing +@testable import WhereUI + +struct YearPDFPageSizeTests { + @Test func localeDefaultsAndExplicitMediaBoxes() { + #expect(YearPDFPageSize.defaultValue(for: Locale(identifier: "en_US")) == .letter) + #expect(YearPDFPageSize.defaultValue(for: Locale(identifier: "en_CA")) == .letter) + #expect(YearPDFPageSize.defaultValue(for: Locale(identifier: "en_GB")) == .a4) + #expect(YearPDFPageSize.letter.portraitBounds.width == 612) + #expect(YearPDFPageSize.letter.landscapeBounds.width == 792) + #expect(YearPDFPageSize.a4.portraitBounds.height > YearPDFPageSize.letter.portraitBounds + .height) + } +} diff --git a/Where/WhereUI/Tests/YearPDFRendererTests.swift b/Where/WhereUI/Tests/YearPDFRendererTests.swift new file mode 100644 index 00000000..4200824f --- /dev/null +++ b/Where/WhereUI/Tests/YearPDFRendererTests.swift @@ -0,0 +1,232 @@ +import PDFKit +import RegionKit +import Testing +import UIKit +import WhereCore +@testable import WhereUI + +struct YearPDFRendererTests { + @Test func standardReportHasMetadataOrderedSectionsPortraitPagesAndSearchableText( + ) async throws { + let input = YearPDFTestSupport.document(includeRawGPS: false) + let file = try await YearPDFRenderer().render(document: input) { _ in } + defer { try? FileManager.default.removeItem(at: file.storageDirectory) } + let pdf = try #require(PDFDocument(url: file.url)) + + #expect(pdf.pageCount == file.pageCount) + #expect(pdf.pageCount >= 7) + let attributes = pdf.documentAttributes + #expect( + attributes?[PDFDocumentAttribute.titleAttribute] as? String + == "Where Presence Report 2024", + ) + #expect( + (attributes?[PDFDocumentAttribute.subjectAttribute] as? String)?.contains("2024") + == true, + ) + #expect(attributes?[PDFDocumentAttribute.creatorAttribute] as? String == "Where 3.4") + #expect(attributes?[PDFDocumentAttribute.creationDateAttribute] as? Date != nil) + #expect( + (attributes?[PDFDocumentAttribute.keywordsAttribute] as? [String])?.contains( + "WhereReportID=20000000-0000-0000-0000-000000000002", + ) == true, + ) + + let text = pdfText(pdf) + let sections = [ + "Coverage Summary", + "Regional Totals", + "Daily Presence Table", + "Manual-Entry Audit", + "Evidence Index", + "Methodology and Limitations", + ] + let offsets = try sections.map { section in + try #require(text.range(of: section)?.lowerBound) + } + #expect(zip(offsets, offsets.dropFirst()).allSatisfy { $0 < $1 }) + #expect(!text.contains("Raw Device-GPS Appendix")) + #expect(!text.contains("00000000-0000-0000-0000-000000000001")) + + for index in 0 ..< pdf.pageCount { + let page = try #require(pdf.page(at: index)) + let bounds = page.bounds(for: .mediaBox) + #expect(bounds.height > bounds.width) + #expect(page.string?.contains("Page \(index + 1) of \(pdf.pageCount)") == true) + } + } + + @Test func rawAppendixUsesLandscapeAndContainsEveryGPSUUIDExactlyOnce() async throws { + let input = YearPDFTestSupport.document(includeRawGPS: true, gpsCount: 1000) + let recorder = ProgressRecorder() + let file = try await YearPDFRenderer().render(document: input) { update in + recorder.append(update) + } + defer { try? FileManager.default.removeItem(at: file.storageDirectory) } + let pdf = try #require(PDFDocument(url: file.url)) + + #expect(recorder.values.first?.completedPages == 0) + #expect(recorder.values.last == YearPDFProgress( + completedPages: pdf.pageCount, + totalPages: pdf.pageCount, + )) + let pageOrientations = (0 ..< pdf.pageCount).map { + pdf.page(at: $0)!.bounds(for: .mediaBox) + } + #expect(pageOrientations.contains { $0.width > $0.height }) + #expect(pageOrientations.prefix { $0.height > $0.width }.count >= 7) + + let compactText = pdfText(pdf).filter { !$0.isWhitespace } + let expression = try NSRegularExpression( + pattern: "00000000-0000-0000-0000-[0-9]{12}", + ) + let range = NSRange(compactText.startIndex..., in: compactText) + let matches = expression.matches(in: compactText, range: range).compactMap { + Range($0.range, in: compactText).map { String(compactText[$0]) } + } + #expect(matches.count == 1000) + #expect(Set(matches).count == 1000) + #expect(compactText.contains("gpsVisit")) + #expect(compactText.contains("gpsSignificantChange")) + #expect(compactText.contains("37.774900")) + #expect(compactText.contains("-122.419400")) + #expect(compactText.contains("7m")) + } + + @Test func demoReportWatermarksEveryPageAndUsesDemoMetadataAndFilename() async throws { + let file = try await YearPDFRenderer().render( + document: YearPDFTestSupport.document(isDemo: true), + ) { _ in } + defer { try? FileManager.default.removeItem(at: file.storageDirectory) } + let pdf = try #require(PDFDocument(url: file.url)) + + #expect(file.suggestedFilename.hasPrefix("Where Demo Presence Report 2024 ")) + #expect( + (pdf.documentAttributes?[PDFDocumentAttribute.titleAttribute] as? String)?.hasPrefix( + "SAMPLE — DEMO DATA", + ) == true, + ) + for index in 0 ..< pdf.pageCount { + #expect(pdf.page(at: index)?.string?.contains("SAMPLE — DEMO DATA") == true) + } + } + + @Test func a4LongUnicodeContentRendersEveryPageWithoutBlankOutput() async throws { + let longNote = String( + repeating: "Réservation 東京 - supporting note with wrapped words. ", + count: 150, + ) + let file = try await YearPDFRenderer().render( + document: YearPDFTestSupport.document(pageSize: .a4, note: longNote), + ) { _ in } + defer { try? FileManager.default.removeItem(at: file.storageDirectory) } + let pdf = try #require(PDFDocument(url: file.url)) + + #expect(abs((pdf.page(at: 0)?.bounds(for: .mediaBox).width ?? 0) - 595.28) < 0.1) + #expect(pdfText(pdf).contains("Réservation 東京")) + for index in 0 ..< pdf.pageCount { + let page = try #require(pdf.page(at: index)) + #expect(!(page.string ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + let thumbnail = page.thumbnail(of: CGSize(width: 300, height: 400), for: .mediaBox) + #expect((thumbnail.pngData()?.count ?? 0) > 1000) + } + } + + @Test func emptyReportStillRendersEveryRequiredMainSection() async throws { + let base = YearPDFTestSupport.document() + let emptyAudit = YearAuditReport( + report: YearReport(year: 2024, days: [], totals: [:]), + samples: [], + manualDays: [], + evidence: [], + trackedRegions: base.audit.trackedRegions, + timeZone: base.audit.timeZone, + regionDataSources: base.audit.regionDataSources, + ) + let file = try await YearPDFRenderer().render( + document: replacingAudit(in: base, with: emptyAudit), + ) { _ in } + defer { try? FileManager.default.removeItem(at: file.storageDirectory) } + let pdf = try #require(PDFDocument(url: file.url)) + let text = pdfText(pdf) + + #expect(pdf.pageCount == 7) + #expect(text.contains("Coverage Summary")) + #expect(text.contains("Daily Presence Table")) + #expect(text.contains("Manual-Entry Audit")) + #expect(text.contains("Evidence Index")) + #expect(text.contains("Methodology and Limitations")) + } + + @Test func contiguousIdenticalManualRecordsConsolidateWithoutLosingSeparatedDates( + ) async throws { + let base = YearPDFTestSupport.document() + let audit = ManualEntryAudit( + recordedAt: YearPDFTestSupport.date("2024-04-01T10:00:00-07:00"), + note: "Shared audit note", + location: nil, + ) + let manualDays = [1, 2, 4].map { + DayPresence( + day: CalendarDay(year: 2024, month: 3, day: $0), + regions: [.california], + audit: audit, + ) + } + let groupedAudit = YearAuditReport( + report: base.audit.report, + samples: base.audit.samples, + manualDays: manualDays, + evidence: base.audit.evidence, + trackedRegions: base.audit.trackedRegions, + timeZone: base.audit.timeZone, + regionDataSources: base.audit.regionDataSources, + ) + let file = try await YearPDFRenderer().render( + document: replacingAudit(in: base, with: groupedAudit), + ) { _ in } + defer { try? FileManager.default.removeItem(at: file.storageDirectory) } + let pdf = try #require(PDFDocument(url: file.url)) + let text = pdfText(pdf).replacingOccurrences(of: "\n", with: " ") + + #expect(text.contains("2024-03-01 - 2024-03-02")) + #expect(text.contains("2024-03-04")) + #expect(text.components(separatedBy: "Shared audit note").count - 1 == 2) + } + + private func pdfText(_ pdf: PDFDocument) -> String { + (0 ..< pdf.pageCount) + .compactMap { pdf.page(at: $0)?.string } + .joined(separator: "\n") + } + + private func replacingAudit( + in document: YearPDFDocument, + with audit: YearAuditReport, + ) -> YearPDFDocument { + YearPDFDocument( + audit: audit, + generatedAt: document.generatedAt, + reportID: document.reportID, + preparedFor: document.preparedFor, + reference: document.reference, + pageSize: document.pageSize, + includeRawGPS: document.includeRawGPS, + isDemo: document.isDemo, + buildInfo: document.buildInfo, + ) + } +} + +private final class ProgressRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [YearPDFProgress] = [] + + var values: [YearPDFProgress] { + lock.withLock { storage } + } + + func append(_ value: YearPDFProgress) { + lock.withLock { storage.append(value) } + } +} diff --git a/Where/WhereUI/Tests/YearPDFTestSupport.swift b/Where/WhereUI/Tests/YearPDFTestSupport.swift new file mode 100644 index 00000000..34fd3785 --- /dev/null +++ b/Where/WhereUI/Tests/YearPDFTestSupport.swift @@ -0,0 +1,111 @@ +import Foundation +import RegionKit +@_spi(Testing) import WhereCore +@testable import WhereUI + +enum YearPDFTestSupport { + static let timeZone = TimeZone(identifier: "America/Los_Angeles")! + + static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + static let buildInfo = BuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "3.4", + "CFBundleVersion": "567", + ]) + + static func date(_ value: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) + ?? ISO8601DateFormatter().date(from: value)! + } + + static func audit( + year: Int = 2024, + gpsCount: Int = 2, + note: String = "Reviewed against itinerary.", + ) -> YearAuditReport { + let base = date("2024-02-29T12:00:00-08:00") + let samples = (0 ..< gpsCount).map { index -> YearAuditAttributedSample in + let id = UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", index + 1))! + let sample = LocationSample( + id: id, + timestamp: base.addingTimeInterval(TimeInterval(index)), + coordinate: Coordinate( + latitude: 37.7749 + Double(index) / 100_000, + longitude: -122.4194, + ), + horizontalAccuracy: 7.4, + source: index.isMultiple(of: 2) ? .gpsVisit : .gpsSignificantChange, + ) + return YearAuditAttributedSample(sample: sample, region: .california) + } + let manual = DayPresence( + day: CalendarDay(year: year, month: 3, day: 1), + regions: [.california, .newYork], + isAuthoritative: false, + audit: ManualEntryAudit( + recordedAt: date("2024-04-01T10:30:00-07:00"), + note: note, + location: CapturedLocation( + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: 11, + timestamp: date("2024-04-01T10:29:58-07:00"), + ), + ), + ) + let days = [ + DayPresence(day: CalendarDay(year: year, month: 2, day: 29), regions: [.california]), + DayPresence( + day: CalendarDay(year: year, month: 3, day: 1), + regions: [.california, .newYork], + ), + ] + return YearAuditReport( + report: YearReport( + year: year, + days: days, + totals: [.california: 2, .newYork: 1], + ), + samples: samples, + manualDays: [manual], + evidence: [ + Evidence( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, + kind: .boardingPass, + capturedAt: date("2024-02-29T11:00:00-08:00"), + region: .california, + note: "SFO to JFK - 予約番号 É123", + contentType: .pdf, + ), + ], + trackedRegions: [.california, .newYork, .canada, .europeanUnion], + timeZone: timeZone, + regionDataSources: RegionDataSource.all, + ) + } + + static func document( + pageSize: YearPDFPageSize = .letter, + includeRawGPS: Bool = false, + isDemo: Bool = false, + gpsCount: Int = 2, + note: String = "Reviewed against itinerary.", + ) -> YearPDFDocument { + YearPDFDocument( + audit: audit(gpsCount: gpsCount, note: note), + generatedAt: date("2026-07-31T15:04:05.123-07:00"), + reportID: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, + preparedFor: "Alex Example", + reference: "Tax file 2024", + pageSize: pageSize, + includeRawGPS: includeRawGPS, + isDemo: isDemo, + buildInfo: buildInfo, + ) + } +}