diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index 95e8c127..0e45f773 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -31,7 +31,7 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/` | `BillCommitteeStage` | A bill's active committee study stage, carrying the committee, study dates, upcoming meetings, and past meetings. | | `BillCommitteeMeeting` | A committee meeting tied to a bill study, including meeting number, date, optional evidence URL, and witness count. | | `BillVersion` | Backend-only bill publication/version row with source links for text, PDF, and XML artifacts. | -| `BillAmendment` | Backend-only House or committee amendment record associated with a bill and chamber stage. | +| `BillAmendment` | House or committee amendment record associated with a bill and chamber stage, with number, sponsor name, status, stage, verbatim amendment text, and source link. Surfaced on the iOS bill page via `LoadBillAmendments`. | | `PBOCosting` | Backend-only Parliamentary Budget Officer costing link associated with a bill. | | `ParliamentaryCommittee` | A House or Senate committee reference with acronym, name, chamber code, and authoritative source URL. | | `MemberBiography` | Parliament.ca biography details for an MP profile, including service periods, roles, education, professional background, source URL, and summary text where available. | @@ -99,6 +99,7 @@ to the issue that will build the missing artifact. | `BillRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillRepository.swift`; adapter: `ios/epac/Data/Adapters/LEGISinfoBillRepository.swift`. | List current-session bills. | | `BillRepository` | backend Go | outbound | Implemented: `backend/bills/internal/usecase/bills.go`; adapter: `backend/bills/internal/adapter/sqlite/repository.go`. | List bills and load bill-depth rows from the verified bills SQLite artifact. | | `BillCommitteeStageRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillCommitteeStageRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillCommitteeStageRepository.swift`. | Load the committee currently studying a bill, including study dates and meeting rows. | +| `BillAmendmentsRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillAmendmentsRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift`. | Load amendments tabled against a bill (number, mover, stage, status, verbatim text) from the backend bill-depth endpoint. | | `RecentLawQueryPort` | iOS Swift | outbound | Implemented by `ios/epac/Application/TrackRoyalAssent.swift`; adapter input is `BillRepository`. | Query current-session bills that received Royal Assent within the recent-law window. | | `MemberRepository` | backend Go | outbound | Implemented: `backend/members/internal/usecase/members.go`; adapter: `backend/members/internal/adapter/sqlite/repository.go`. | List members and load member-profile attendance rows from the verified members SQLite artifact. | | `MemberContentRepository` | backend Go | outbound | Implemented: `backend/member-speeches/internal/usecase/usecase.go` with adapter `backend/member-speeches/internal/adapter/artifact/artifact.go`; `backend/member-votes/main.go` has a local vote-feed interface implemented by `S3ArtifactMemberContentRepository`. | Load per-member append-only content feeds such as speeches and recorded votes. There is no iOS Swift protocol with this name today. | @@ -600,6 +601,29 @@ Current implementation: --- +### LoadBillAmendments + +``` +Actor: User (iOS app, bill detail) +Goal: See the amendments tabled against a bill — number, mover, stage, disposition (passed / defeated / withdrawn), and the full authoritative amendment text rendered verbatim. +Inputs: LEGISinfo bill ID. +Outputs: Optional [BillAmendment]; nil when the backend has no amendments record for the bill (hide panel), empty array when the bill is tracked but no amendments tabled yet (show empty-state row), non-empty array otherwise. +Entities / values: Bill, BillAmendment, BillAmendmentStatus. +Ports: iOS Swift: `BillAmendmentsRepository`. +Primary adapters: BackendBillAmendmentsRepository (GET /api/v1/bills/{id}), BillDetailView, BillAmendmentsPanel. +Current implementation: + ios/epac/Application/LoadBillAmendments.swift + ios/epac/Domain/Entities/BillAmendment.swift + ios/epac/Domain/Ports/BillAmendmentsRepository.swift + ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift + ios/epac/Views/Bills/BillAmendmentsPanel.swift + ios/epac/Views/Bills/BillDetailView.swift +``` + +> **Boundary rule:** Amendment text is rendered verbatim on the bill page — no paraphrasing, no summarization. iOS decodes only the backend's typed JSON; LEGISinfo and committee-minute parsing remain backend responsibilities. The bill page hides the panel when the use case returns `nil`, and renders an empty-state row when it returns an empty array. + +--- + ### TagPrivateMembersBill ``` diff --git a/ios/epac/Application/LoadBillAmendments.swift b/ios/epac/Application/LoadBillAmendments.swift new file mode 100644 index 00000000..6493de70 --- /dev/null +++ b/ios/epac/Application/LoadBillAmendments.swift @@ -0,0 +1,14 @@ +/// Loads the amendments tabled against one bill. +/// +/// Returns `nil` when the backend has no amendments record for the bill, so +/// the caller can hide the "Amendments" panel without inspecting wire-format +/// details. An empty array means the bill is tracked but no amendments have +/// been tabled yet — the panel shows an empty-state row in that case (a UI +/// policy, per the feature's boundary rule). +struct LoadBillAmendments: Sendable { + let repository: any BillAmendmentsRepository + + func execute(billID: String) async throws -> [BillAmendment]? { + try await repository.loadBillAmendments(billID: billID) + } +} diff --git a/ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift b/ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift new file mode 100644 index 00000000..eb327f6b --- /dev/null +++ b/ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Loads a bill's amendments list from the epac backend. +/// +/// Contract — `GET /api/v1/bills/{id}` (bill-depth endpoint): +/// - `200`: bill JSON whose `amendments` field carries the list (see +/// `BillDepthAmendmentsResponse`). The backend has already ingested +/// LEGISinfo and committee-minute amendments into the bill artifact, so the +/// iOS side only decodes and renders. +/// - `204` / `404`: backend has no record for that bill; the panel is hidden. +/// +/// The iOS layer never parses LEGISinfo or parl.ca wire formats; this typed +/// JSON shape is the boundary between backend and app. +struct BackendBillAmendmentsRepository: BillAmendmentsRepository { + fileprivate enum Constants { + static let requestTimeout: TimeInterval = 20 + static let successStatusLowerBound = 200 + static let successStatusUpperBound = 300 + static let noContentStatus = 204 + static let notFoundStatus = 404 + static let pathPrefix = "api/v1/bills" + + static var successStatusCodes: Range { + successStatusLowerBound.. [BillAmendment]? { + let path = "\(Constants.pathPrefix)/\(billID)" + guard let data = try await get(path: path) else { + return nil + } + let response = try decoder.decode(BillDepthAmendmentsResponse.self, from: data) + return response.bill.amendments.map(\.domain) + } + + private func get(path: String) async throws -> Data? { + let url = baseURL.appending(path: path) + var request = URLRequest(url: url, timeoutInterval: Constants.requestTimeout) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await network.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + if http.statusCode == Constants.noContentStatus || http.statusCode == Constants.notFoundStatus { + return nil + } + guard Constants.successStatusCodes.contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return data + } + + fileprivate static func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return dateFormatter.date(from: value) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +// MARK: - Backend JSON contract + +private struct BillDepthAmendmentsResponse: Decodable { + let bill: BillDTO + + struct BillDTO: Decodable { + let amendments: [AmendmentDTO] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + amendments = try container.decodeIfPresent([AmendmentDTO].self, forKey: .amendments) ?? [] + } + + enum CodingKeys: String, CodingKey { + case amendments + } + } +} + +private struct AmendmentDTO: Decodable { + let id: String? + let number: String? + let title: String? + let status: String? + let stage: String? + let sponsorName: String? + let proposedOn: String? + let text: String? + let sourceURL: URL? + + enum CodingKeys: String, CodingKey { + case id + case number + case title + case status + case stage + case sponsorName = "sponsor_name" + case proposedOn = "proposed_on" + case text + case sourceURL = "source_url" + } + + var domain: BillAmendment { + let rawStatus = status ?? "" + return BillAmendment( + id: id ?? number ?? UUID().uuidString, + number: number ?? "", + title: title?.isEmpty == false ? title : nil, + sponsorName: sponsorName ?? "", + proposedOn: BackendBillAmendmentsRepository.parseDate(proposedOn), + stage: stage ?? "", + status: BillAmendmentStatus.from(rawStatus), + statusLabel: rawStatus, + text: text ?? "", + sourceURL: sourceURL + ) + } +} diff --git a/ios/epac/Domain/Entities/BillAmendment.swift b/ios/epac/Domain/Entities/BillAmendment.swift new file mode 100644 index 00000000..93979b3b --- /dev/null +++ b/ios/epac/Domain/Entities/BillAmendment.swift @@ -0,0 +1,74 @@ +import Foundation + +/// One amendment tabled against a bill at committee, report, or third-reading +/// stage. Sourced from LEGISinfo and committee minutes via the epac backend's +/// bill-depth endpoint; the iOS layer never parses parl.ca markup directly. +/// +/// Civic-content rule: `text` is the authoritative amendment text as published +/// by Parliament. The bill page renders this verbatim — no paraphrasing, no +/// summarization — per the feature's boundary rule. +struct BillAmendment: Identifiable, Equatable, Sendable { + /// Stable identifier from the backend artifact. + let id: String + + /// Sponsor-prefixed amendment label as published (e.g. "LIB-1", "NDP-3"). + let number: String + + /// Short title or descriptor when the backend has one. Optional because the + /// upstream record is uneven — some amendments only carry a number. + let title: String? + + /// The mover's display name as recorded by Parliament. The backend stores + /// only a name string today, so this is not yet linkable to an MP profile. + let sponsorName: String + + /// When the amendment was proposed, if known. + let proposedOn: Date? + + /// Legislative stage the amendment was moved at, verbatim from the backend + /// (e.g. "Committee", "Report Stage", "Third Reading"). + let stage: String + + /// Disposition reported by Parliament. `unknown` covers records the backend + /// has not classified — render the raw `statusLabel` for those. + let status: BillAmendmentStatus + + /// Verbatim status label as returned by the backend, used both as the + /// fallback display when `status == .unknown` and for diagnostics. + let statusLabel: String + + /// Authoritative amendment text, rendered verbatim on tap. + let text: String + + /// Source link on parl.ca / LEGISinfo when the backend has one. + let sourceURL: URL? +} + +/// Disposition of a bill amendment as reported by Parliament. +/// +/// The backend ingests the raw status string; this enum normalizes the +/// recurring values so the UI can colour them consistently. Anything the +/// backend has not classified maps to `unknown`, and the raw label is shown +/// instead. +enum BillAmendmentStatus: String, Equatable, Sendable { + case passed + case defeated + case withdrawn + case unknown + + /// Map a raw backend status string onto a known case. Unknown values + /// collapse to `.unknown`, and callers should show `BillAmendment.statusLabel` + /// verbatim in that case. + static func from(_ rawValue: String) -> BillAmendmentStatus { + switch rawValue.lowercased() { + case "passed", "adopted", "agreed", "agreed_to", "agreed to", "carried": + return .passed + case "defeated", "rejected", "negatived": + return .defeated + case "withdrawn", "not_moved", "not moved": + return .withdrawn + default: + return .unknown + } + } +} diff --git a/ios/epac/Domain/Ports/BillAmendmentsRepository.swift b/ios/epac/Domain/Ports/BillAmendmentsRepository.swift new file mode 100644 index 00000000..c2f79f0d --- /dev/null +++ b/ios/epac/Domain/Ports/BillAmendmentsRepository.swift @@ -0,0 +1,9 @@ +/// Reads amendments tabled against a bill. +/// +/// `nil` means the backend has no amendments record for the bill at all (404 +/// or 204) — the panel hides itself. An empty array means "bill is tracked but +/// no amendments tabled yet" — the panel shows an empty-state row. Throws only +/// on transport or decoding failures. +protocol BillAmendmentsRepository: Sendable { + func loadBillAmendments(billID: String) async throws -> [BillAmendment]? +} diff --git a/ios/epac/Views/Bills/BillAmendmentsPanel.swift b/ios/epac/Views/Bills/BillAmendmentsPanel.swift new file mode 100644 index 00000000..5cc0302e --- /dev/null +++ b/ios/epac/Views/Bills/BillAmendmentsPanel.swift @@ -0,0 +1,232 @@ +import SwiftUI + +private enum BillAmendmentsPanelLayout { + static let rowSpacing = EpacSpacing.xxs + static let badgePaddingH: CGFloat = 6 + static let badgePaddingV: CGFloat = 3 + static let textPaddingTop: CGFloat = 4 + static let amendmentDisplayLimit = 50 +} + +/// The "Amendments" panel on the bill detail page. +/// +/// Renders one row per tabled amendment, with the amendment number, the +/// mover's name as recorded by Parliament, the stage at which it was moved, +/// and a coloured status badge (passed / defeated / withdrawn). Tapping a row +/// reveals the full authoritative amendment text verbatim — the panel does no +/// paraphrasing or summarization. +/// +/// The parent shows an empty-state row in the same section when the backend +/// returns an empty array (bill tracked, no amendments tabled yet); the +/// parent hides the entire panel when the backend has no amendments record +/// for the bill at all. +struct BillAmendmentsPanel: View { + let amendments: [BillAmendment] + + private var displayedAmendments: [BillAmendment] { + Array(amendments.prefix(BillAmendmentsPanelLayout.amendmentDisplayLimit)) + } + + var body: some View { + Section(NSLocalizedString("billAmendments.title", comment: "")) { + if amendments.isEmpty { + emptyRow + } else { + ForEach(displayedAmendments) { amendment in + BillAmendmentRow(amendment: amendment) + .accessibilityIdentifier("bill-amendments-row") + } + } + + Text(NSLocalizedString("billAmendments.source", comment: "")) + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("bill-amendments-source") + } + } + + private var emptyRow: some View { + Label { + Text(NSLocalizedString("billAmendments.empty", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "tray") + .foregroundStyle(.secondary) + } + .accessibilityIdentifier("bill-amendments-empty") + } +} + +/// One row inside the "Amendments" panel. Collapsed shows number, mover, +/// stage, and a status badge; tap expands to reveal the verbatim text and +/// source link when available. +private struct BillAmendmentRow: View { + let amendment: BillAmendment + + @State private var isExpanded = false + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + expandedContent + } label: { + collapsedLabel + } + .accessibilityAction(named: isExpanded + ? NSLocalizedString("billAmendments.collapse", comment: "") + : NSLocalizedString("billAmendments.expand", comment: "") + ) { + isExpanded.toggle() + } + } + + private var collapsedLabel: some View { + VStack(alignment: .leading, spacing: BillAmendmentsPanelLayout.rowSpacing) { + HStack(alignment: .firstTextBaseline, spacing: EpacSpacing.s) { + Text(amendment.number.isEmpty + ? NSLocalizedString("billAmendments.numberPlaceholder", comment: "") + : amendment.number) + .font(.subheadline.weight(.semibold).monospacedDigit()) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: EpacSpacing.s) + BillAmendmentStatusBadge(status: amendment.status, label: statusDisplayLabel) + } + + if !amendment.sponsorName.isEmpty { + Text(String( + format: NSLocalizedString("billAmendments.movedBy", comment: ""), + amendment.sponsorName + )) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let title = amendment.title { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: EpacSpacing.s) { + if !amendment.stage.isEmpty { + Text(amendment.stage) + .font(.caption2) + .foregroundStyle(.secondary) + } + if let proposedOn = amendment.proposedOn { + Text(proposedOn.billAmendmentDateText) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + @ViewBuilder + private var expandedContent: some View { + if amendment.text.isEmpty { + Text(NSLocalizedString("billAmendments.textUnavailable", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, BillAmendmentsPanelLayout.textPaddingTop) + } else { + Text(amendment.text) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .padding(.top, BillAmendmentsPanelLayout.textPaddingTop) + .accessibilityIdentifier("bill-amendments-text") + } + + if let sourceURL = amendment.sourceURL { + Link(NSLocalizedString("billAmendments.openSource", comment: ""), destination: sourceURL) + .font(.caption) + .foregroundStyle(Color.accentColor) + .accessibilityIdentifier("bill-amendments-source-link") + } + } + + private var statusDisplayLabel: String { + switch amendment.status { + case .passed: + return NSLocalizedString("billAmendments.status.passed", comment: "") + case .defeated: + return NSLocalizedString("billAmendments.status.defeated", comment: "") + case .withdrawn: + return NSLocalizedString("billAmendments.status.withdrawn", comment: "") + case .unknown: + return amendment.statusLabel.isEmpty + ? NSLocalizedString("billAmendments.status.unknown", comment: "") + : amendment.statusLabel + } + } + + private var accessibilityLabel: String { + var parts: [String] = [] + if !amendment.number.isEmpty { + parts.append(amendment.number) + } + if !amendment.sponsorName.isEmpty { + parts.append(String( + format: NSLocalizedString("billAmendments.movedBy", comment: ""), + amendment.sponsorName + )) + } + if !amendment.stage.isEmpty { + parts.append(amendment.stage) + } + parts.append(statusDisplayLabel) + if let proposedOn = amendment.proposedOn { + parts.append(proposedOn.billAmendmentDateText) + } + return parts.joined(separator: ", ") + } +} + +/// Coloured pill matching the conventions used elsewhere on the bill page +/// (e.g. `BillHeaderBadge`): semantic colour by status, white text, capsule. +private struct BillAmendmentStatusBadge: View { + let status: BillAmendmentStatus + let label: String + + var body: some View { + Text(label) + .font(.caption2.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.white) + .padding(.horizontal, BillAmendmentsPanelLayout.badgePaddingH) + .padding(.vertical, BillAmendmentsPanelLayout.badgePaddingV) + .background(colorFor(status), in: Capsule()) + .accessibilityHidden(true) + } + + private func colorFor(_ status: BillAmendmentStatus) -> Color { + switch status { + case .passed: + return .appPositive + case .defeated: + return .appDestructive + case .withdrawn: + return .appWarning + case .unknown: + return .appNeutral + } + } +} + +private extension Date { + var billAmendmentDateText: String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = .current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: self) + } +} diff --git a/ios/epac/Views/Bills/BillDetailView.swift b/ios/epac/Views/Bills/BillDetailView.swift index 89f175ac..4453be41 100644 --- a/ios/epac/Views/Bills/BillDetailView.swift +++ b/ios/epac/Views/Bills/BillDetailView.swift @@ -30,14 +30,17 @@ struct BillDetailView: View { private let loadBillLobbyingContext: LoadBillLobbyingContext private let loadBillCommitteeStage: LoadBillCommitteeStage + private let loadBillAmendments: LoadBillAmendments private let autoloadLobbyingContext: Bool private let autoloadCommitteeStage: Bool + private let autoloadAmendments: Bool @State private var billStore = BillFollowStore.shared @State private var matchingVotes: [RecordedVote] = [] @State private var matchingDebates: [SubjectOfBusiness] = [] @State private var lobbyingContext: BillLobbyingContext? @State private var committeeStage: BillCommitteeStage? + @State private var amendments: [BillAmendment]? @State private var shareItem: ActivityItem? @State private var myMP: ParliamentMember? @State private var sponsorMember: ParliamentMember? @@ -50,14 +53,20 @@ struct BillDetailView: View { loadBillCommitteeStage: LoadBillCommitteeStage = LoadBillCommitteeStage( repository: BackendBillCommitteeStageRepository() ), + loadBillAmendments: LoadBillAmendments = LoadBillAmendments( + repository: BackendBillAmendmentsRepository() + ), autoloadLobbyingContext: Bool = true, - autoloadCommitteeStage: Bool = true + autoloadCommitteeStage: Bool = true, + autoloadAmendments: Bool = true ) { self.bill = bill self.loadBillLobbyingContext = loadBillLobbyingContext self.loadBillCommitteeStage = loadBillCommitteeStage + self.loadBillAmendments = loadBillAmendments self.autoloadLobbyingContext = autoloadLobbyingContext self.autoloadCommitteeStage = autoloadCommitteeStage + self.autoloadAmendments = autoloadAmendments } var body: some View { @@ -78,6 +87,11 @@ struct BillDetailView: View { BillInCommitteePanel(stage: committeeStage) } + // MARK: Amendments tabled against this bill + if let amendments { + BillAmendmentsPanel(amendments: amendments) + } + // MARK: Pre-vote lobbying context if let lobbyingContext { BillLobbyingContextPanel(context: lobbyingContext) @@ -205,6 +219,7 @@ struct BillDetailView: View { .task { await loadCrossReferences() await loadCommitteeStage() + await loadAmendments() await loadLobbyingContext() BillFollowStore.shared.markAsRead(bill.number) } @@ -451,6 +466,17 @@ struct BillDetailView: View { committeeStage = nil } } + + @MainActor + private func loadAmendments() async { + guard autoloadAmendments else { return } + + do { + amendments = try await loadBillAmendments.execute(billID: bill.id) + } catch { + amendments = nil + } + } } private enum BillTimelineStageState: Equatable { diff --git a/ios/epac/en.lproj/Localizable.strings b/ios/epac/en.lproj/Localizable.strings index ddb3ce3c..3f59b4df 100644 --- a/ios/epac/en.lproj/Localizable.strings +++ b/ios/epac/en.lproj/Localizable.strings @@ -337,6 +337,21 @@ "billCommittee.witness.none" = "No witnesses recorded"; "billCommittee.witness.singular" = "1 witness"; "billCommittee.witness.plural" = "%d witnesses"; + +/* Bill amendments panel (EPAC-965) */ +"billAmendments.title" = "Amendments"; +"billAmendments.empty" = "No amendments tabled yet."; +"billAmendments.source" = "Source: LEGISinfo and committee minutes on parl.ca"; +"billAmendments.movedBy" = "Moved by %@"; +"billAmendments.numberPlaceholder" = "Amendment"; +"billAmendments.status.passed" = "Passed"; +"billAmendments.status.defeated" = "Defeated"; +"billAmendments.status.withdrawn" = "Withdrawn"; +"billAmendments.status.unknown" = "Status pending"; +"billAmendments.textUnavailable" = "Full amendment text not yet available."; +"billAmendments.openSource" = "Open on parl.ca"; +"billAmendments.expand" = "Show amendment text"; +"billAmendments.collapse" = "Hide amendment text"; "bills.chamber.house" = "House of Commons"; "bills.chamber.senate" = "Senate"; "bills.stage.houseFirst" = "House — First Reading"; diff --git a/ios/epac/fr.lproj/Localizable.strings b/ios/epac/fr.lproj/Localizable.strings index 6ce1a841..53b2078a 100644 --- a/ios/epac/fr.lproj/Localizable.strings +++ b/ios/epac/fr.lproj/Localizable.strings @@ -339,6 +339,21 @@ "billCommittee.witness.none" = "Aucun témoin consigné"; "billCommittee.witness.singular" = "1 témoin"; "billCommittee.witness.plural" = "%d témoins"; + +/* Bill amendments panel (EPAC-965) */ +"billAmendments.title" = "Amendements"; +"billAmendments.empty" = "Aucun amendement déposé pour l'instant."; +"billAmendments.source" = "Source : LEGISinfo et procès-verbaux des comités sur parl.ca"; +"billAmendments.movedBy" = "Proposé par %@"; +"billAmendments.numberPlaceholder" = "Amendement"; +"billAmendments.status.passed" = "Adopté"; +"billAmendments.status.defeated" = "Rejeté"; +"billAmendments.status.withdrawn" = "Retiré"; +"billAmendments.status.unknown" = "Statut en attente"; +"billAmendments.textUnavailable" = "Texte intégral de l'amendement pas encore disponible."; +"billAmendments.openSource" = "Ouvrir sur parl.ca"; +"billAmendments.expand" = "Afficher le texte de l'amendement"; +"billAmendments.collapse" = "Masquer le texte de l'amendement"; "bills.chamber.house" = "Chambre des communes"; "bills.chamber.senate" = "Sénat"; "bills.stage.houseFirst" = "Chambre — Première lecture"; diff --git a/ios/epacTests/Application/LoadBillAmendmentsTests.swift b/ios/epacTests/Application/LoadBillAmendmentsTests.swift new file mode 100644 index 00000000..d45da5ab --- /dev/null +++ b/ios/epacTests/Application/LoadBillAmendmentsTests.swift @@ -0,0 +1,85 @@ +@testable import epac +import Foundation +import Testing + +struct LoadBillAmendmentsTests { + @Test func executeReturnsAmendmentsFromRepository() async throws { + let amendments = Self.sampleAmendments() + let repository = StubBillAmendmentsRepository(amendments: amendments) + let useCase = LoadBillAmendments(repository: repository) + + let result = try await useCase.execute(billID: "C-8") + + #expect(repository.requestedBillIDs == ["C-8"]) + #expect(result == amendments) + } + + @Test func executeReturnsEmptyArrayWhenRepositoryHasNoAmendmentsForBill() async throws { + let repository = StubBillAmendmentsRepository(amendments: []) + let useCase = LoadBillAmendments(repository: repository) + + let result = try await useCase.execute(billID: "C-9") + + #expect(repository.requestedBillIDs == ["C-9"]) + #expect(result == []) + } + + @Test func executeReturnsNilWhenRepositoryHasNoAmendmentsRecord() async throws { + let repository = StubBillAmendmentsRepository(amendments: nil) + let useCase = LoadBillAmendments(repository: repository) + + let result = try await useCase.execute(billID: "C-10") + + #expect(repository.requestedBillIDs == ["C-10"]) + #expect(result == nil) + } + + @Test func executePropagatesRepositoryErrors() async { + let repository = StubBillAmendmentsRepository(error: StubBillAmendmentsRepositoryError.failed) + let useCase = LoadBillAmendments(repository: repository) + + await #expect(throws: StubBillAmendmentsRepositoryError.failed) { + _ = try await useCase.execute(billID: "C-11") + } + } + + private static func sampleAmendments() -> [BillAmendment] { + [ + BillAmendment( + id: "C-8-a-1", + number: "LIB-1", + title: "Clause 5: replace subsection (2)", + sponsorName: "Hon. Member A", + proposedOn: Date(timeIntervalSince1970: 1_780_704_000), + stage: "Committee", + status: .passed, + statusLabel: "adopted", + text: "That Bill C-8, in Clause 5, be amended by replacing line 10 on page 3 with the following: ...", + sourceURL: URL(string: "https://www.parl.ca/legisinfo/amendment/1") + ) + ] + } +} + +private enum StubBillAmendmentsRepositoryError: Error { + case failed +} + +private final class StubBillAmendmentsRepository: BillAmendmentsRepository, @unchecked Sendable { + private let amendments: [BillAmendment]? + private let error: Error? + private(set) var requestedBillIDs: [String] = [] + + init(amendments: [BillAmendment]? = nil, error: Error? = nil) { + self.amendments = amendments + self.error = error + } + + func loadBillAmendments(billID: String) async throws -> [BillAmendment]? { + requestedBillIDs.append(billID) + if let error { + throw error + } + return amendments + } +} diff --git a/ios/epacTests/BillAmendmentsRepositoryTests.swift b/ios/epacTests/BillAmendmentsRepositoryTests.swift new file mode 100644 index 00000000..ac04f467 --- /dev/null +++ b/ios/epacTests/BillAmendmentsRepositoryTests.swift @@ -0,0 +1,268 @@ +@testable import epac +import Foundation +import Testing + +@Suite(.serialized) +struct BillAmendmentsRepositoryTests { + @Test func billAmendmentsDecodesBackendResponse() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + var capturedRequest: URLRequest? + + BillAmendmentsMockURLProtocol.requestHandler = { request in + capturedRequest = request + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Self.billDepthJSON() + ) + } + + defer { harness.cleanup() } + defer { BillAmendmentsMockURLProtocol.requestHandler = nil } + + let repository = BackendBillAmendmentsRepository( + network: harness.service, + baseURL: baseURL + ) + + let loadedAmendments = try await repository.loadBillAmendments(billID: "C-8") + let amendments = try #require(loadedAmendments) + let request = try #require(capturedRequest) + let requestURL = try #require(request.url) + + #expect(requestURL.path == "/api/v1/bills/C-8") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(amendments.count == 3) + + let first = try #require(amendments.first) + #expect(first.id == "C-8-a-1") + #expect(first.number == "LIB-1") + #expect(first.title == "Clause 5 — replace subsection") + #expect(first.sponsorName == "Hon. Member A") + #expect(first.stage == "Committee") + #expect(first.status == .passed) + #expect(first.statusLabel == "adopted") + #expect(first.proposedOn == expectedUTCDate(year: 2026, month: 6, day: 4)) + #expect(first.text.contains("Bill C-8, in Clause 5")) + #expect(first.sourceURL?.absoluteString == "https://www.parl.ca/legisinfo/amendment/1") + + let second = try #require(amendments.dropFirst().first) + #expect(second.status == .defeated) + #expect(second.statusLabel == "negatived") + + let third = try #require(amendments.dropFirst(2).first) + #expect(third.status == .withdrawn) + #expect(third.statusLabel == "withdrawn") + #expect(third.title == nil) + } + + @Test func billAmendmentsReturnsEmptyArrayWhenBillHasNoAmendments() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + + BillAmendmentsMockURLProtocol.requestHandler = { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Data(#"{"bill": {"id": "C-9", "number": "C-9", "title": "Test"}}"#.utf8) + ) + } + + defer { harness.cleanup() } + defer { BillAmendmentsMockURLProtocol.requestHandler = nil } + + let repository = BackendBillAmendmentsRepository(network: harness.service, baseURL: baseURL) + let amendments = try await repository.loadBillAmendments(billID: "C-9") + + #expect(amendments == []) + } + + @Test func billAmendmentsReturnsNilOnNoContent() async throws { + let setup = try makeRepository(statusCode: 204) + defer { setup.harness.cleanup() } + defer { BillAmendmentsMockURLProtocol.requestHandler = nil } + + let amendments = try await setup.repository.loadBillAmendments(billID: "C-10") + + #expect(amendments == nil) + } + + @Test func billAmendmentsReturnsNilOnNotFound() async throws { + let setup = try makeRepository(statusCode: 404) + defer { setup.harness.cleanup() } + defer { BillAmendmentsMockURLProtocol.requestHandler = nil } + + let amendments = try await setup.repository.loadBillAmendments(billID: "C-11") + + #expect(amendments == nil) + } + + @Test func billAmendmentsThrowsOnServerError() async throws { + let setup = try makeRepository(statusCode: 500) + defer { setup.harness.cleanup() } + defer { BillAmendmentsMockURLProtocol.requestHandler = nil } + + await #expect(throws: URLError.self) { + _ = try await setup.repository.loadBillAmendments(billID: "C-12") + } + } + + private func makeRepository( + statusCode: Int + ) throws -> (repository: BackendBillAmendmentsRepository, harness: BillAmendmentsNetworkHarness) { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + + BillAmendmentsMockURLProtocol.requestHandler = { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )!, + Data() + ) + } + + return ( + repository: BackendBillAmendmentsRepository( + network: harness.service, + baseURL: baseURL + ), + harness: harness + ) + } + + private func makeHarness() throws -> BillAmendmentsNetworkHarness { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [BillAmendmentsMockURLProtocol.self] + let session = URLSession(configuration: configuration) + + let suiteName = "BillAmendmentsRepositoryTests.\(UUID().uuidString)" + let userDefaults = try #require(UserDefaults(suiteName: suiteName)) + userDefaults.removePersistentDomain(forName: suiteName) + + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("BillAmendmentsRepositoryTests-\(UUID().uuidString)", isDirectory: true) + let cacheStore = HTTPResponseCacheStore(userDefaults: userDefaults, cacheDirectory: cacheDirectory) + + return BillAmendmentsNetworkHarness( + service: NetworkService(session: session, cacheStore: cacheStore), + userDefaultsSuiteName: suiteName, + cacheDirectory: cacheDirectory + ) + } + + private func expectedUTCDate(year: Int, month: Int, day: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone + return calendar.date( + from: DateComponents( + timeZone: calendar.timeZone, + year: year, + month: month, + day: day + ) + ) ?? Date(timeIntervalSince1970: 0) + } + + private static func billDepthJSON() -> Data { + Data( + """ + { + "bill": { + "id": "C-8-45-1", + "number": "C-8", + "title": "An Act respecting example data", + "amendments": [ + { + "id": "C-8-a-1", + "number": "LIB-1", + "title": "Clause 5 — replace subsection", + "status": "adopted", + "stage": "Committee", + "sponsor_name": "Hon. Member A", + "proposed_on": "2026-06-04", + "text": "That Bill C-8, in Clause 5, be amended by replacing line 10 on page 3 with the following: ...", + "source_url": "https://www.parl.ca/legisinfo/amendment/1" + }, + { + "id": "C-8-a-2", + "number": "CPC-2", + "title": "Clause 7 — delete", + "status": "negatived", + "stage": "Committee", + "sponsor_name": "Hon. Member B", + "proposed_on": "2026-06-05", + "text": "That Bill C-8, Clause 7, be deleted." + }, + { + "id": "C-8-a-3", + "number": "NDP-3", + "status": "withdrawn", + "stage": "Report Stage", + "sponsor_name": "Hon. Member C", + "proposed_on": "2026-06-08", + "text": "That Bill C-8 be amended at Report Stage by ..." + } + ] + } + } + """.utf8 + ) + } +} + +private struct BillAmendmentsNetworkHarness { + let service: NetworkService + let userDefaultsSuiteName: String + let cacheDirectory: URL + + func cleanup() { + UserDefaults.standard.removePersistentDomain(forName: userDefaultsSuiteName) + try? FileManager.default.removeItem(at: cacheDirectory) + } +} + +private enum BillAmendmentsMockURLProtocolError: Error { + case missingHandler +} + +private final class BillAmendmentsMockURLProtocol: URLProtocol { + nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + do { + guard let handler = Self.requestHandler else { + throw BillAmendmentsMockURLProtocolError.missingHandler + } + + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/ios/epacTests/SnapshotTests.swift b/ios/epacTests/SnapshotTests.swift index 98a84c01..63e25311 100644 --- a/ios/epacTests/SnapshotTests.swift +++ b/ios/epacTests/SnapshotTests.swift @@ -268,6 +268,73 @@ final class SnapshotTests: XCTestCase { ) } + // MARK: - Bill amendments panel (EPAC-965) + + func testBillAmendmentsPanel_passed() { + snapshot( + NavigationStack { + List { + BillAmendmentsPanel(amendments: [Self.amendmentPassed]) + } + .listStyle(.insetGrouped) + } + .frame(width: 375, height: 360), + name: "BillAmendmentsPanel_passed" + ) + } + + func testBillAmendmentsPanel_defeated() { + snapshot( + NavigationStack { + List { + BillAmendmentsPanel(amendments: [Self.amendmentDefeated]) + } + .listStyle(.insetGrouped) + } + .frame(width: 375, height: 360), + name: "BillAmendmentsPanel_defeated" + ) + } + + func testBillAmendmentsPanel_withdrawn() { + snapshot( + NavigationStack { + List { + BillAmendmentsPanel(amendments: [Self.amendmentWithdrawn]) + } + .listStyle(.insetGrouped) + } + .frame(width: 375, height: 360), + name: "BillAmendmentsPanel_withdrawn" + ) + } + + func testBillAmendmentsPanel_multipleBySameMP() { + snapshot( + NavigationStack { + List { + BillAmendmentsPanel(amendments: Self.amendmentsMultipleBySameMP) + } + .listStyle(.insetGrouped) + } + .frame(width: 375, height: 540), + name: "BillAmendmentsPanel_multipleBySameMP" + ) + } + + func testBillAmendmentsPanel_empty() { + snapshot( + NavigationStack { + List { + BillAmendmentsPanel(amendments: []) + } + .listStyle(.insetGrouped) + } + .frame(width: 375, height: 280), + name: "BillAmendmentsPanel_empty" + ) + } + // MARK: - EmptyStateView func testEmptyStateView_noAction() { @@ -813,6 +880,84 @@ final class SnapshotTests: XCTestCase { ) } + private static let amendmentPassed = BillAmendment( + id: "C-8-a-1", + number: "LIB-1", + title: "Clause 5 — replace subsection (2)", + sponsorName: "Hon. Member A", + proposedOn: date("2026-06-04"), + stage: "Committee", + status: .passed, + statusLabel: "adopted", + text: "That Bill C-8, in Clause 5, be amended by replacing line 10 on page 3 with the following: the Minister shall publish quarterly progress reports.", + sourceURL: URL(string: "https://www.parl.ca/legisinfo/amendment/1") + ) + + private static let amendmentDefeated = BillAmendment( + id: "C-8-a-2", + number: "CPC-2", + title: "Clause 7 — delete", + sponsorName: "Hon. Member B", + proposedOn: date("2026-06-05"), + stage: "Committee", + status: .defeated, + statusLabel: "negatived", + text: "That Bill C-8, Clause 7, be deleted.", + sourceURL: URL(string: "https://www.parl.ca/legisinfo/amendment/2") + ) + + private static let amendmentWithdrawn = BillAmendment( + id: "C-8-a-3", + number: "NDP-3", + title: nil, + sponsorName: "Hon. Member C", + proposedOn: date("2026-06-08"), + stage: "Report Stage", + status: .withdrawn, + statusLabel: "withdrawn", + text: "That Bill C-8 be amended at Report Stage by adding the following after line 12 on page 5: ...", + sourceURL: nil + ) + + private static let amendmentsMultipleBySameMP: [BillAmendment] = [ + BillAmendment( + id: "C-8-a-4", + number: "LIB-4", + title: "Clause 3 — add subsection (3.1)", + sponsorName: "Hon. Member A", + proposedOn: date("2026-06-04"), + stage: "Committee", + status: .passed, + statusLabel: "adopted", + text: "That Bill C-8, in Clause 3, be amended by adding the following after line 22 on page 2: (3.1) The Minister shall report annually to Parliament.", + sourceURL: URL(string: "https://www.parl.ca/legisinfo/amendment/4") + ), + BillAmendment( + id: "C-8-a-5", + number: "LIB-5", + title: "Clause 9 — clarify scope", + sponsorName: "Hon. Member A", + proposedOn: date("2026-06-05"), + stage: "Committee", + status: .defeated, + statusLabel: "negatived", + text: "That Bill C-8, in Clause 9, be amended by replacing line 4 on page 7 with the following: ...", + sourceURL: URL(string: "https://www.parl.ca/legisinfo/amendment/5") + ), + BillAmendment( + id: "C-8-a-6", + number: "LIB-6", + title: nil, + sponsorName: "Hon. Member A", + proposedOn: date("2026-06-09"), + stage: "Report Stage", + status: .withdrawn, + statusLabel: "withdrawn", + text: "That Bill C-8 be amended at Report Stage by deleting Clause 14.", + sourceURL: nil + ) + ] + private static func date(_ rawValue: String) -> Date { dateFormatter.date(from: rawValue) ?? Date(timeIntervalSince1970: 0) } diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_a11y.png new file mode 100644 index 00000000..8b12eda6 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_dark.png new file mode 100644 index 00000000..d001d741 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_light.png new file mode 100644 index 00000000..18029982 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_defeated.BillAmendmentsPanel_defeated_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_a11y.png new file mode 100644 index 00000000..574842af Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_dark.png new file mode 100644 index 00000000..269ed05c Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_light.png new file mode 100644 index 00000000..80248e7d Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_empty.BillAmendmentsPanel_empty_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_a11y.png new file mode 100644 index 00000000..7aa3951e Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_dark.png new file mode 100644 index 00000000..c8cd9002 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_light.png new file mode 100644 index 00000000..a8c982f5 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_multipleBySameMP.BillAmendmentsPanel_multipleBySameMP_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_a11y.png new file mode 100644 index 00000000..1870763c Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_dark.png new file mode 100644 index 00000000..d5473055 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_light.png new file mode 100644 index 00000000..2b891db5 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_passed.BillAmendmentsPanel_passed_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_a11y.png new file mode 100644 index 00000000..7829e9bb Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_dark.png new file mode 100644 index 00000000..347c0e72 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_light.png new file mode 100644 index 00000000..1760a87b Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillAmendmentsPanel_withdrawn.BillAmendmentsPanel_withdrawn_light.png differ