Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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.
Expand All @@ -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))
}
}
44 changes: 33 additions & 11 deletions BitwardenKit/UI/Platform/Application/Views/DateFieldPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public struct DateFieldPicker: View {
/// The (optional) range of selectable dates.
let range: ClosedRange<Date>?

/// 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?

Expand Down Expand Up @@ -75,7 +80,7 @@ public struct DateFieldPicker: View {
: SharedAsset.Colors.backgroundSecondaryDisabled.swiftUIColor,
)
.clipShape(RoundedRectangle(cornerRadius: 8))
.accessibilityIdentifier(accessibilityIdentifier ?? "DateFieldPicker")
.accessibilityIdentifier(resolvedAccessibilityIdentifier)
}

// MARK: Initialization
Expand Down Expand Up @@ -146,8 +151,8 @@ public struct DateFieldPicker: View {
if let title {
Text(title)
.styleGuide(
.subheadline,
weight: .semibold,
.headline,
weight: .regular,
includeLinePadding: false,
includeLineSpacing: false,
)
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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<Date> {
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ 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,
),
in: Date.distantPast ... Date().asUTCCalendarDay(),
)

BitwardenTextField(
Expand Down Expand Up @@ -95,20 +97,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,
),
)
Comment thread
morganzellers-bw marked this conversation as resolved.

// 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -611,8 +611,14 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
for action: AddEditDriversLicenseItemAction,
) {
switch action {
case let .dateOfBirthChanged(dateOfBirth):
state.driversLicenseItemState.dateOfBirth = dateOfBirth
case let .expirationDateChanged(expirationDate):
state.driversLicenseItemState.expirationDate = expirationDate
case let .firstNameChanged(firstName):
state.driversLicenseItemState.firstName = firstName
case let .issueDateChanged(issueDate):
state.driversLicenseItemState.issueDate = issueDate
case let .issuingAuthorityChanged(issuingAuthority):
state.driversLicenseItemState.issuingAuthority = issuingAuthority
case let .issuingCountryChanged(issuingCountry):
Expand Down Expand Up @@ -691,8 +697,14 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
switch action {
case let .birthPlaceChanged(birthPlace):
state.passportItemState.birthPlace = birthPlace
case let .dateOfBirthChanged(dateOfBirth):
state.passportItemState.dateOfBirth = dateOfBirth
case let .expirationDateChanged(expirationDate):
state.passportItemState.expirationDate = expirationDate
case let .givenNameChanged(givenName):
state.passportItemState.givenName = givenName
case let .issueDateChanged(issueDate):
state.passportItemState.issueDate = issueDate
case let .issuingAuthorityChanged(issuingAuthority):
state.passportItemState.issuingAuthority = issuingAuthority
case let .issuingCountryChanged(issuingCountry):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2222,6 +2222,36 @@ class AddEditItemProcessorTests: BitwardenTestCase {
XCTAssertFalse(subject.state.driversLicenseItemState.isLicenseNumberVisible)
}

/// `receive(_:)` with `.driversLicenseFieldChanged(.dateOfBirthChanged)` updates the state correctly.
@MainActor
func test_receive_driversLicenseFieldChanged_dateOfBirthChanged() {
subject.receive(.driversLicenseFieldChanged(.dateOfBirthChanged(Date(year: 1989, month: 8, day: 1))))
XCTAssertEqual(subject.state.driversLicenseItemState.dateOfBirth, Date(year: 1989, month: 8, day: 1))

subject.receive(.driversLicenseFieldChanged(.dateOfBirthChanged(nil)))
XCTAssertNil(subject.state.driversLicenseItemState.dateOfBirth)
}

/// `receive(_:)` with `.driversLicenseFieldChanged(.issueDateChanged)` updates the state correctly.
@MainActor
func test_receive_driversLicenseFieldChanged_issueDateChanged() {
subject.receive(.driversLicenseFieldChanged(.issueDateChanged(Date(year: 2019, month: 8, day: 1))))
XCTAssertEqual(subject.state.driversLicenseItemState.issueDate, Date(year: 2019, month: 8, day: 1))

subject.receive(.driversLicenseFieldChanged(.issueDateChanged(nil)))
XCTAssertNil(subject.state.driversLicenseItemState.issueDate)
}

/// `receive(_:)` with `.driversLicenseFieldChanged(.expirationDateChanged)` updates the state correctly.
@MainActor
func test_receive_driversLicenseFieldChanged_expirationDateChanged() {
subject.receive(.driversLicenseFieldChanged(.expirationDateChanged(Date(year: 2029, month: 8, day: 1))))
XCTAssertEqual(subject.state.driversLicenseItemState.expirationDate, Date(year: 2029, month: 8, day: 1))

subject.receive(.driversLicenseFieldChanged(.expirationDateChanged(nil)))
XCTAssertNil(subject.state.driversLicenseItemState.expirationDate)
}

/// `receive(_:)` with `.identityFieldChanged(.titleChanged)` with a value updates the state correctly.
@MainActor
func test_receive_identity_titleChange_withValidValue() {
Expand Down Expand Up @@ -3337,6 +3367,36 @@ class AddEditItemProcessorTests: BitwardenTestCase {
XCTAssertFalse(subject.state.passportItemState.isPassportNumberVisible)
}

/// `receive(_:)` with `.passportFieldChanged(.dateOfBirthChanged)` updates the state correctly.
@MainActor
func test_receive_passportFieldChanged_dateOfBirthChanged() {
subject.receive(.passportFieldChanged(.dateOfBirthChanged(Date(year: 2025, month: 4, day: 20))))
XCTAssertEqual(subject.state.passportItemState.dateOfBirth, Date(year: 2025, month: 4, day: 20))

subject.receive(.passportFieldChanged(.dateOfBirthChanged(nil)))
XCTAssertNil(subject.state.passportItemState.dateOfBirth)
}

/// `receive(_:)` with `.passportFieldChanged(.issueDateChanged)` updates the state correctly.
@MainActor
func test_receive_passportFieldChanged_issueDateChanged() {
subject.receive(.passportFieldChanged(.issueDateChanged(Date(year: 2021, month: 8, day: 10))))
XCTAssertEqual(subject.state.passportItemState.issueDate, Date(year: 2021, month: 8, day: 10))

subject.receive(.passportFieldChanged(.issueDateChanged(nil)))
XCTAssertNil(subject.state.passportItemState.issueDate)
}

/// `receive(_:)` with `.passportFieldChanged(.expirationDateChanged)` updates the state correctly.
@MainActor
func test_receive_passportFieldChanged_expirationDateChanged() {
subject.receive(.passportFieldChanged(.expirationDateChanged(Date(year: 2026, month: 8, day: 10))))
XCTAssertEqual(subject.state.passportItemState.expirationDate, Date(year: 2026, month: 8, day: 10))

subject.receive(.passportFieldChanged(.expirationDateChanged(nil)))
XCTAssertNil(subject.state.passportItemState.expirationDate)
}

/// `getter:rehydrationState` returns the proper state with the cipher id.
@MainActor
func test_rehydrationState() {
Expand Down
Loading
Loading