From adf9c166e34cebf61134e38b09ca84802cc00083 Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Fri, 7 Aug 2026 11:31:28 -0500 Subject: [PATCH 1/4] Adds datefieldpicker to new cipher types license and passport --- .../Application/Views/DateFieldPicker.swift | 4 +- .../AddEditDriversLicenseItemAction.swift | 11 ++ .../AddEditDriversLicenseItemView.swift | 27 ++-- .../AddEditItem/AddEditItemProcessor.swift | 12 ++ .../AddEditItemProcessorTests.swift | 60 +++++++++ .../AddEditItemView+ViewInspectorTests.swift | 120 ++++++++++++------ .../AddEditPassportItemAction.swift | 11 ++ .../AddEditPassportItemView.swift | 27 ++-- .../InspectableView.swift | 42 ++++++ 9 files changed, 252 insertions(+), 62 deletions(-) diff --git a/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift b/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift index 021f671d5f..15af88c6a4 100644 --- a/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift +++ b/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift @@ -146,8 +146,8 @@ public struct DateFieldPicker: View { if let title { Text(title) .styleGuide( - .subheadline, - weight: .semibold, + .headline, + weight: .regular, includeLinePadding: false, includeLineSpacing: false, ) diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemAction.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemAction.swift index e723e0169e..6f0584f184 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemAction.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemAction.swift @@ -1,11 +1,22 @@ +import Foundation + // MARK: AddEditDriversLicenseItemAction /// An enum of actions for adding or editing a driver's license Item in its add/edit state. /// enum AddEditDriversLicenseItemAction: Equatable, Sendable { + /// The date of birth changed. + case dateOfBirthChanged(Date?) + + /// The expiration date changed. + case expirationDateChanged(Date?) + /// The first name on the license changed. case firstNameChanged(String) + /// The issue date changed. + case issueDateChanged(Date?) + /// The issuing authority changed. case issuingAuthorityChanged(String) diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift index d284f05e3c..b19d70d65a 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift @@ -60,12 +60,13 @@ struct AddEditDriversLicenseItemView: View { ), ) - // TODO: PM-38360 - replace with DateFieldPicker - BitwardenTextField( + DateFieldPicker( title: Localizations.dateOfBirth, - text: .constant(store.state.dateOfBirthDisplay), accessibilityIdentifier: "DriversLicenseDateOfBirthEntry", - isTextFieldDisabled: true, + date: store.binding( + get: \.dateOfBirth, + send: AddEditDriversLicenseItemAction.dateOfBirthChanged, + ), ) BitwardenTextField( @@ -95,20 +96,22 @@ struct AddEditDriversLicenseItemView: View { accessibilityIdentifier: "DriversLicenseIssuingAuthorityEntry", ) - // TODO: PM-38360 - replace with DateFieldPicker - BitwardenTextField( + DateFieldPicker( title: Localizations.issueDate, - text: .constant(store.state.issueDateDisplay), accessibilityIdentifier: "DriversLicenseIssueDateEntry", - isTextFieldDisabled: true, + date: store.binding( + get: \.issueDate, + send: AddEditDriversLicenseItemAction.issueDateChanged, + ), ) - // TODO: PM-38360 - replace with DateFieldPicker - BitwardenTextField( + DateFieldPicker( title: Localizations.expirationDate, - text: .constant(store.state.expirationDateDisplay), accessibilityIdentifier: "DriversLicenseExpirationDateEntry", - isTextFieldDisabled: true, + date: store.binding( + get: \.expirationDate, + send: AddEditDriversLicenseItemAction.expirationDateChanged, + ), ) BitwardenTextField( diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift index 22794f587d..20383cc337 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift @@ -611,8 +611,14 @@ final class AddEditItemProcessor: StateProcessor InspectableView { + try find(DateFieldPickerType.self, containing: title, locale: locale) + } + /// Attempts to locate an floating action button with the provided accessibility identifier. /// /// - Parameter accessibilityIdentifier: The accessibility identifier to use while searching for @@ -389,6 +415,22 @@ public extension InspectableView where View == BitwardenTextFieldType { } } +public extension InspectableView where View == DateFieldPickerType { + /// Locates the raw binding on this field's date value. Can be used to simulate selecting a date. + /// + func inputBinding() throws -> Binding { + let mirror = Mirror(reflecting: self) + if let binding = mirror.descendant("content", "view", "_date") as? Binding { + return binding + } else { + throw InspectionError.attributeNotFound( + label: "_date", + type: String(describing: DateFieldPickerType.self), + ) + } + } +} + public extension InspectableView where View == BitwardenMultilineTextFieldType { /// Locates the raw binding on this textfield's text value. Can be used to simulate updating the text field. /// From ea8bc25fba64630b53360966bb9a7c7a08c0ec0b Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Fri, 7 Aug 2026 13:52:39 -0500 Subject: [PATCH 2/4] Fix DateFieldPicker accessibility ID collisions and add date-of-birth range validation --- .../DateFieldPicker+ViewInspectorTests.swift | 59 +++++++++++++++++-- .../Application/Views/DateFieldPicker.swift | 40 ++++++++++--- .../AddEditDriversLicenseItemView.swift | 1 + .../AddEditPassportItemView.swift | 1 + 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker+ViewInspectorTests.swift b/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker+ViewInspectorTests.swift index 2158271420..00abc8bfd4 100644 --- a/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker+ViewInspectorTests.swift +++ b/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker+ViewInspectorTests.swift @@ -61,12 +61,12 @@ class DateFieldPickerTests: BitwardenTestCase { /// The collapsed header is a button so a single tap expands the picker. func test_headerButton_exists() throws { - XCTAssertNoThrow(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldHeaderButton")) + XCTAssertNoThrow(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldPickerHeaderButton")) } /// The header button carries an accessibility hint telling VoiceOver users it selects a date. func test_headerButton_hasSelectDateHint() throws { - let header = try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldHeaderButton") + let header = try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldPickerHeaderButton") XCTAssertEqual(try header.accessibilityHint().string(), Localizations.selectDate) } @@ -81,14 +81,14 @@ class DateFieldPickerTests: BitwardenTestCase { /// When a date is selected, a clear control is shown and tapping it resets the value to `nil`. func test_clearButton_clearsDate() throws { date = defaultDate - let clearButton = try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldClearButton") + let clearButton = try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldPickerClearButton") try clearButton.button().tap() XCTAssertNil(date) } /// No clear control is shown when the field is empty. func test_clearButton_hiddenWhenEmpty() throws { - XCTAssertThrowsError(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldClearButton")) + XCTAssertThrowsError(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldPickerClearButton")) } /// A provided footer is rendered below the field. @@ -112,4 +112,55 @@ class DateFieldPickerTests: BitwardenTestCase { ) XCTAssertNoThrow(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateOfBirthField")) } + + /// The header and clear button identifiers derive from a custom accessibility identifier, so + /// multiple pickers on the same screen (each given a distinct identifier) don't share child + /// element identifiers. + func test_accessibilityIdentifier_custom_derivesChildIdentifiers() throws { + date = defaultDate + subject = DateFieldPicker( + title: "Date of birth", + accessibilityIdentifier: "DateOfBirthField", + date: bindingDate, + defaultDate: defaultDate, + ) + XCTAssertNoThrow( + try subject.inspect().find(viewWithAccessibilityIdentifier: "DateOfBirthFieldHeaderButton"), + ) + XCTAssertNoThrow( + try subject.inspect().find(viewWithAccessibilityIdentifier: "DateOfBirthFieldClearButton"), + ) + } + + /// `selectedLocalDay()` (which feeds the `DatePicker`'s displayed selection) converts the stored + /// UTC-anchored date into the local calendar day domain the `DatePicker` operates in. + func test_selectedLocalDay_convertsStoredDateToLocalDay() { + let stored = Date(year: 2024, month: 2, day: 29) + date = stored + XCTAssertEqual(subject.selectedLocalDay(), stored.asLocalCalendarDay()) + } + + /// `selectedLocalDay()` falls back to `defaultDate` when no date is set yet. + func test_selectedLocalDay_fallsBackToDefaultDateWhenUnset() { + date = nil + XCTAssertEqual(subject.selectedLocalDay(), defaultDate.asLocalCalendarDay()) + } + + /// `commitSelectedLocalDay(_:)` (called when the user picks a day on the `DatePicker`) converts + /// the picked local calendar day back into the UTC-anchored form used for storage — the exact + /// composition `selection()` wires into the live `DatePicker`, verified here without needing to + /// render or expand the calendar. + func test_commitSelectedLocalDay_commitsUTCAnchoredDate() { + let pickedLocalDay = Date(year: 2024, month: 2, day: 29) + subject.commitSelectedLocalDay(pickedLocalDay) + XCTAssertEqual(date, pickedLocalDay.asUTCCalendarDay()) + } + + /// Selecting the day that's already displayed is idempotent: it doesn't drift the stored date by + /// re-converting an already-converted value. + func test_commitSelectedLocalDay_isIdempotentForTheCurrentlyDisplayedDay() { + date = Date(year: 2024, month: 2, day: 29) + subject.commitSelectedLocalDay(subject.selectedLocalDay()) + XCTAssertEqual(date, Date(year: 2024, month: 2, day: 29)) + } } diff --git a/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift b/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift index 15af88c6a4..c571c4efb2 100644 --- a/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift +++ b/BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift @@ -29,6 +29,11 @@ public struct DateFieldPicker: View { /// The (optional) range of selectable dates. let range: ClosedRange? + /// The identifier applied to the field, falling back to a generic default when the caller doesn't + /// supply one. Child elements (the header button, the clear button) derive their own identifiers + /// from this so multiple pickers on the same screen don't share child accessibility identifiers. + private var resolvedAccessibilityIdentifier: String { accessibilityIdentifier ?? "DateFieldPicker" } + /// The (optional) title of the field. let title: String? @@ -75,7 +80,7 @@ public struct DateFieldPicker: View { : SharedAsset.Colors.backgroundSecondaryDisabled.swiftUIColor, ) .clipShape(RoundedRectangle(cornerRadius: 8)) - .accessibilityIdentifier(accessibilityIdentifier ?? "DateFieldPicker") + .accessibilityIdentifier(resolvedAccessibilityIdentifier) } // MARK: Initialization @@ -179,14 +184,14 @@ public struct DateFieldPicker: View { labelContent() } .buttonStyle(.plain) - .accessibilityIdentifier("DateFieldHeaderButton") + .accessibilityIdentifier("\(resolvedAccessibilityIdentifier)HeaderButton") .accessibilityHint(Localizations.selectDate) if date != nil { AccessoryButton( asset: SharedAsset.Icons.circleX24, accessibilityLabel: title.map { Localizations.clearFieldName($0) } ?? Localizations.clear, - accessibilityIdentifier: "DateFieldClearButton", + accessibilityIdentifier: "\(resolvedAccessibilityIdentifier)ClearButton", ) { clearDate() } @@ -217,15 +222,32 @@ public struct DateFieldPicker: View { /// the day the user sees selected, and the day they pick, always match the day that gets stored. private func selection() -> Binding { Binding( - get: { (date ?? defaultDate).asLocalCalendarDay() }, - set: { newValue in - date = newValue.asUTCCalendarDay() - guard !voiceOverEnabled else { return } - withAnimation { isExpanded = false } - }, + get: { selectedLocalDay() }, + set: { newValue in commitSelectedLocalDay(newValue) }, ) } + /// The calendar day the `DatePicker` should currently show as selected: the stored date (or + /// `defaultDate` when unset), converted from its UTC-anchored storage form into the local + /// calendar day the `DatePicker` operates in. + /// + /// Not `private` so it can be exercised directly in tests without hosting the view: `isExpanded` + /// is `@State`, so the `DatePicker` this feeds is otherwise only reachable by first expanding the + /// calendar, which requires state mutations to survive a re-inspection — unavailable without + /// `ViewHosting`, which nothing else in this codebase uses. + func selectedLocalDay() -> Date { + (date ?? defaultDate).asLocalCalendarDay() + } + + /// Commits a calendar day the user picked (in the `DatePicker`'s local-day domain) back into + /// `date`, converting it to the UTC-anchored form used for storage, and collapses the calendar + /// unless VoiceOver is active. See `selectedLocalDay()` for why this isn't `private`. + func commitSelectedLocalDay(_ localDay: Date) { + date = localDay.asUTCCalendarDay() + guard !voiceOverEnabled else { return } + withAnimation { isExpanded = false } + } + /// Clears the selected date. private func clearDate() { withAnimation { diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift index b19d70d65a..b12eddc3b3 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift @@ -67,6 +67,7 @@ struct AddEditDriversLicenseItemView: View { get: \.dateOfBirth, send: AddEditDriversLicenseItemAction.dateOfBirthChanged, ), + in: Date.distantPast ... Date().asUTCCalendarDay(), ) BitwardenTextField( diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift index 20b5230faa..0b5c2d7626 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift @@ -44,6 +44,7 @@ struct AddEditPassportItemView: View { get: \.dateOfBirth, send: AddEditPassportItemAction.dateOfBirthChanged, ), + in: Date.distantPast ... Date().asUTCCalendarDay(), ) BitwardenTextField( From a06ff9004886e566ad2484eb7bc64225ea546457 Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Fri, 7 Aug 2026 15:42:24 -0500 Subject: [PATCH 3/4] Fix TestHarness DateFieldPicker showcase showing the day before the selected date The Form Values section formatted the UTC-anchored stored date with an implicit local time zone, rolling the displayed day back by one on time zones behind UTC. Format via the shared longCalendarDateDisplay helper instead, matching how the real app displays these dates. --- .../DriversLicenseItemStateTests.swift | 16 ++++++++ .../PassportItemStateTests.swift | 16 ++++++++ .../DateFieldPickerShowcaseState.swift | 11 +++++ .../DateFieldPickerShowcaseStateTests.swift | 40 +++++++++++++++++++ .../DateFieldPickerShowcaseView.swift | 4 +- 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/DriversLicenseItemStateTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/DriversLicenseItemStateTests.swift index ac7a599787..1e72f6446a 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/DriversLicenseItemStateTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/DriversLicenseItemStateTests.swift @@ -99,4 +99,20 @@ struct DriversLicenseItemStateTests { #expect(subject.issueDateDisplay == subject.issueDate?.longCalendarDateDisplay) #expect(subject.issueDateDisplay.contains("August")) } + + /// `dateOfBirthDisplay` shows the same calendar day the user picked in `DateFieldPicker`, even + /// in a time zone behind UTC, where the UTC-anchored stored value falls on the previous day. + @Test + func dateOfBirthDisplay_matchesPickedDayInTimeZoneBehindUTC() { + let losAngeles = TimeZone(identifier: "America/Los_Angeles")! + let pickedLocalDay = Date(year: 2026, month: 8, day: 10, timeZone: losAngeles) + + var subject = DriversLicenseItemState() + subject.dateOfBirth = pickedLocalDay.asUTCCalendarDay(from: losAngeles) + + #expect(subject.dateOfBirthDisplay.contains("August")) + #expect(subject.dateOfBirthDisplay.contains("10")) + #expect(subject.dateOfBirthDisplay.contains("2026")) + #expect(!subject.dateOfBirthDisplay.contains(" 9,")) + } } diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/PassportItemStateTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/PassportItemStateTests.swift index 793afff2e8..19c4d74439 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/PassportItemStateTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/PassportItemStateTests.swift @@ -105,4 +105,20 @@ struct PassportItemStateTests { #expect(subject.expirationDateDisplay == subject.expirationDate?.longCalendarDateDisplay) #expect(subject.expirationDateDisplay.contains("August")) } + + /// `dateOfBirthDisplay` shows the same calendar day the user picked in `DateFieldPicker`, even + /// in a time zone behind UTC, where the UTC-anchored stored value falls on the previous day. + @Test + func dateOfBirthDisplay_matchesPickedDayInTimeZoneBehindUTC() { + let losAngeles = TimeZone(identifier: "America/Los_Angeles")! + let pickedLocalDay = Date(year: 2025, month: 4, day: 20, timeZone: losAngeles) + + var subject = PassportItemState() + subject.dateOfBirth = pickedLocalDay.asUTCCalendarDay(from: losAngeles) + + #expect(subject.dateOfBirthDisplay.contains("April")) + #expect(subject.dateOfBirthDisplay.contains("20")) + #expect(subject.dateOfBirthDisplay.contains("2025")) + #expect(!subject.dateOfBirthDisplay.contains("19")) + } } diff --git a/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseState.swift b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseState.swift index 1d175b2994..215983403f 100644 --- a/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseState.swift +++ b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseState.swift @@ -1,3 +1,4 @@ +import BitwardenKit import Foundation /// The state for the date field picker showcase screen. @@ -11,3 +12,13 @@ struct DateFieldPickerShowcaseState: Equatable { /// The currently selected date, or `nil` if no date has been selected. var selectedDate: Date? } + +extension DateFieldPickerShowcaseState { + /// The selected date formatted as a long localized calendar date (e.g. "August 10, 2026"); + /// empty when unset. Pinned to UTC so a UTC-anchored stored date reads back as the same + /// calendar day regardless of device time zone. + var selectedDateDisplay: String { + guard let selectedDate else { return "" } + return selectedDate.longCalendarDateDisplay + } +} diff --git a/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift new file mode 100644 index 0000000000..5512d30a38 --- /dev/null +++ b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift @@ -0,0 +1,40 @@ +import BitwardenKit +import XCTest + +@testable import TestHarnessShared + +// MARK: - DateFieldPickerShowcaseStateTests + +/// Tests for `DateFieldPickerShowcaseState`. +/// +class DateFieldPickerShowcaseStateTests: XCTestCase { + // MARK: Tests + + /// `selectedDateDisplay` is an empty string when no date has been selected. + func test_selectedDateDisplay_isEmptyWhenUnset() { + let subject = DateFieldPickerShowcaseState() + XCTAssertEqual(subject.selectedDateDisplay, "") + } + + /// `selectedDateDisplay` shows the same calendar day that was selected, even when the stored + /// UTC-anchored date falls on the previous day in the device's local time zone. This guards + /// against a regression where the showcase read the stored date back with an implicit local + /// time zone and displayed the day before the one the user picked. + func test_selectedDateDisplay_matchesSelectedDayRegardlessOfDeviceTimeZone() { + let losAngeles = TimeZone(identifier: "America/Los_Angeles")! + let selectedLocalDay = Date(year: 2026, month: 8, day: 7, timeZone: losAngeles) + let storedDate = selectedLocalDay.asUTCCalendarDay(from: losAngeles) + + var subject = DateFieldPickerShowcaseState() + subject.selectedDate = storedDate + + XCTAssertEqual(subject.selectedDateDisplay, storedDate.longCalendarDateDisplay) + XCTAssertTrue(subject.selectedDateDisplay.contains("August")) + XCTAssertTrue(subject.selectedDateDisplay.contains("7")) + XCTAssertTrue(subject.selectedDateDisplay.contains("2026")) + + var behindUTCStyle = Date.FormatStyle(date: .long, time: .omitted) + behindUTCStyle.timeZone = losAngeles + XCTAssertNotEqual(subject.selectedDateDisplay, storedDate.formatted(behindUTCStyle)) + } +} diff --git a/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseView.swift b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseView.swift index 09668cfc19..7b198de08f 100644 --- a/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseView.swift +++ b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseView.swift @@ -40,8 +40,8 @@ struct DateFieldPickerShowcaseView: View { } Section { - if let selectedDate = store.state.selectedDate { - Text(Localizations.selectedDateValue(selectedDate.formatted(date: .long, time: .omitted))) + if store.state.selectedDate != nil { + Text(Localizations.selectedDateValue(store.state.selectedDateDisplay)) .styleGuide(.body) } else { Text(Localizations.noDateSelected) From 06a28af40dbfc7616547094300dcd154ee156225 Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Mon, 10 Aug 2026 09:29:28 -0500 Subject: [PATCH 4/4] Cap issue date at today in Passport and Driver's License forms --- .../AddEditDriversLicenseItemView.swift | 1 + .../AddEditPassportItemView.swift | 1 + .../DateFieldPickerShowcaseStateTests.swift | 23 +++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift index b12eddc3b3..b4ea5219a0 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditDriversLicenseItem/AddEditDriversLicenseItemView.swift @@ -104,6 +104,7 @@ struct AddEditDriversLicenseItemView: View { get: \.issueDate, send: AddEditDriversLicenseItemAction.issueDateChanged, ), + in: Date.distantPast ... Date().asUTCCalendarDay(), ) DateFieldPicker( diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift index 0b5c2d7626..20967a2c87 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditPassportItem/AddEditPassportItemView.swift @@ -136,6 +136,7 @@ struct AddEditPassportItemView: View { get: \.issueDate, send: AddEditPassportItemAction.issueDateChanged, ), + in: Date.distantPast ... Date().asUTCCalendarDay(), ) DateFieldPicker( diff --git a/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift index 5512d30a38..184c65655f 100644 --- a/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift +++ b/TestHarnessShared/UI/Platform/DateFieldPicker/DateFieldPickerShowcaseStateTests.swift @@ -1,5 +1,6 @@ import BitwardenKit -import XCTest +import Foundation +import Testing @testable import TestHarnessShared @@ -7,20 +8,22 @@ import XCTest /// Tests for `DateFieldPickerShowcaseState`. /// -class DateFieldPickerShowcaseStateTests: XCTestCase { +struct DateFieldPickerShowcaseStateTests { // MARK: Tests /// `selectedDateDisplay` is an empty string when no date has been selected. - func test_selectedDateDisplay_isEmptyWhenUnset() { + @Test + func selectedDateDisplay_isEmptyWhenUnset() { let subject = DateFieldPickerShowcaseState() - XCTAssertEqual(subject.selectedDateDisplay, "") + #expect(subject.selectedDateDisplay.isEmpty) } /// `selectedDateDisplay` shows the same calendar day that was selected, even when the stored /// UTC-anchored date falls on the previous day in the device's local time zone. This guards /// against a regression where the showcase read the stored date back with an implicit local /// time zone and displayed the day before the one the user picked. - func test_selectedDateDisplay_matchesSelectedDayRegardlessOfDeviceTimeZone() { + @Test + func selectedDateDisplay_matchesSelectedDayRegardlessOfDeviceTimeZone() { let losAngeles = TimeZone(identifier: "America/Los_Angeles")! let selectedLocalDay = Date(year: 2026, month: 8, day: 7, timeZone: losAngeles) let storedDate = selectedLocalDay.asUTCCalendarDay(from: losAngeles) @@ -28,13 +31,13 @@ class DateFieldPickerShowcaseStateTests: XCTestCase { var subject = DateFieldPickerShowcaseState() subject.selectedDate = storedDate - XCTAssertEqual(subject.selectedDateDisplay, storedDate.longCalendarDateDisplay) - XCTAssertTrue(subject.selectedDateDisplay.contains("August")) - XCTAssertTrue(subject.selectedDateDisplay.contains("7")) - XCTAssertTrue(subject.selectedDateDisplay.contains("2026")) + #expect(subject.selectedDateDisplay == storedDate.longCalendarDateDisplay) + #expect(subject.selectedDateDisplay.contains("August")) + #expect(subject.selectedDateDisplay.contains("7")) + #expect(subject.selectedDateDisplay.contains("2026")) var behindUTCStyle = Date.FormatStyle(date: .long, time: .omitted) behindUTCStyle.timeZone = losAngeles - XCTAssertNotEqual(subject.selectedDateDisplay, storedDate.formatted(behindUTCStyle)) + #expect(subject.selectedDateDisplay != storedDate.formatted(behindUTCStyle)) } }