Skip to content

Commit 25abb6f

Browse files
[PM-21739] Replace add send segmented control with a type selection menu (#1603)
1 parent a41a64d commit 25abb6f

38 files changed

+315
-267
lines changed

BitwardenShared/UI/Platform/Application/Extensions/View.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ extension View {
9797
///
9898
func addItemFloatingActionButton(
9999
hidden: Bool = false,
100-
action: @escaping () -> Void
100+
action: @escaping () async -> Void
101101
) -> some View {
102102
floatingActionButton(
103103
hidden: hidden,
@@ -108,6 +108,30 @@ extension View {
108108
.accessibilityIdentifier("AddItemFloatingActionButton")
109109
}
110110

111+
/// Returns a floating action menu positioned at the bottom-right corner of the screen for
112+
/// adding a send item.
113+
///
114+
/// - Parameters:
115+
/// - hidden: Whether the menu button should be hidden.
116+
/// - action: The action to perform when a send type is tapped in the menu.
117+
/// - Returns: A `FloatingActionMenu` configured for adding a send item.
118+
///
119+
func addSendItemFloatingActionMenu(
120+
hidden: Bool = false,
121+
action: @escaping (SendType) async -> Void
122+
) -> some View {
123+
FloatingActionMenu(image: Asset.Images.plus32.swiftUIImage) {
124+
ForEach(SendType.allCases) { type in
125+
AsyncButton(type.localizedName) {
126+
await action(type)
127+
}
128+
}
129+
}
130+
.accessibilityLabel(Localizations.add)
131+
.accessibilityIdentifier("AddItemFloatingActionButton")
132+
.padding([.trailing, .bottom], 16)
133+
}
134+
111135
/// Returns a floating action menu positioned at the bottom-right corner of the screen.
112136
///
113137
/// - Parameters:
@@ -174,7 +198,7 @@ extension View {
174198
func floatingActionButton(
175199
hidden: Bool = false,
176200
image: Image,
177-
action: @escaping () -> Void
201+
action: @escaping () async -> Void
178202
) -> some View {
179203
FloatingActionButton(
180204
image: image,

BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,3 +1188,7 @@
11881188
"FlightRecorderOn" = "Flight recorder on";
11891189
"FlightRecorderWillBeActiveUntilDescriptionLong" = "Flight recorder will be active until %1$@ at %2$@. Return to settings to deactivate now.";
11901190
"GoToSettings" = "Go to settings";
1191+
"NewFileSend" = "New file Send";
1192+
"NewTextSend" = "New text Send";
1193+
"EditFileSend" = "Edit file Send";
1194+
"EditTextSend" = "Edit text Send";

BitwardenShared/UI/Platform/Application/Views/FloatingActionButton.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ struct FloatingActionButton: View {
1515
let image: Image
1616

1717
/// A closure that defines the action to be performed when the button is tapped.
18-
let action: () -> Void
18+
let action: () async -> Void
1919

2020
// MARK: View
2121

2222
var body: some View {
23-
Button(action: action) {
23+
AsyncButton(action: action) {
2424
image.imageStyle(.floatingActionButton)
2525
}
2626
.buttonStyle(CircleButtonStyle(diameter: 50))

BitwardenShared/UI/Platform/Settings/Settings/Vault/Folders/FoldersViewTests.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ class FoldersViewTests: BitwardenTestCase {
3131

3232
/// Tapping the new folder floating action button dispatches the `.add` action.`
3333
@MainActor
34-
func test_addItemFloatingActionButton_tap() throws {
35-
let fab = try subject.inspect().find(viewWithAccessibilityIdentifier: "AddItemFloatingActionButton")
36-
try fab.button().tap()
34+
func test_addItemFloatingActionButton_tap() async throws {
35+
let fab = try subject.inspect().find(
36+
floatingActionButtonWithAccessibilityIdentifier: "AddItemFloatingActionButton"
37+
)
38+
try await fab.tap()
3739
XCTAssertEqual(processor.dispatchedActions.last, .add)
3840
}
3941

BitwardenShared/UI/Tools/Send/Send/SendCoordinator.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,6 @@ final class SendCoordinator: Coordinator, HasStackNavigator {
130130
///
131131
private func showItem(route: SendItemRoute, delegate: SendItemDelegate) {
132132
let navigationController = module.makeNavigationController()
133-
if case .add = route {
134-
navigationController.removeHairlineDivider()
135-
}
136133
let coordinator = module.makeSendItemCoordinator(
137134
delegate: delegate,
138135
stackNavigator: navigationController

BitwardenShared/UI/Tools/Send/Send/SendList/SendListAction.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
/// Actions that can be processed by a `SendListProcessor`.
44
///
55
enum SendListAction: Equatable, Sendable {
6-
/// The add item button was pressed.
7-
case addItemPressed
8-
96
/// Clears the info URL after the web app has been opened.
107
case clearInfoUrl
118

BitwardenShared/UI/Tools/Send/Send/SendList/SendListEffect.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// MARK: - SendListEffect
22

33
/// Effects that can be processed by a `SendListProcessor`.
4-
enum SendListEffect {
4+
enum SendListEffect: Equatable {
5+
/// The add item button was pressed.
6+
case addItemPressed(SendType)
7+
58
/// Any initial data for the view should be loaded.
69
case loadData
710

BitwardenShared/UI/Tools/Send/Send/SendList/SendListProcessor.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ final class SendListProcessor: StateProcessor<SendListState, SendListAction, Sen
4646

4747
override func perform(_ effect: SendListEffect) async {
4848
switch effect {
49+
case let .addItemPressed(sendType):
50+
await addNewSend(sendType: sendType)
4951
case .loadData:
5052
await loadData()
5153
case let .search(text):
@@ -79,8 +81,6 @@ final class SendListProcessor: StateProcessor<SendListState, SendListAction, Sen
7981

8082
override func receive(_ action: SendListAction) {
8183
switch action {
82-
case .addItemPressed:
83-
coordinator.navigate(to: .addItem(type: state.type), context: self)
8484
case .clearInfoUrl:
8585
state.infoUrl = nil
8686
case .infoButtonPressed:
@@ -114,6 +114,30 @@ final class SendListProcessor: StateProcessor<SendListState, SendListAction, Sen
114114

115115
// MARK: Private Methods
116116

117+
/// Navigates to the add new send view. If the user is trying to add a new send type which
118+
/// requires premium and they don't have premium this will instead show an error alert to the
119+
/// user.
120+
///
121+
/// - Parameter sendType: The type of send the user is trying to add.
122+
///
123+
private func addNewSend(sendType: SendType) async {
124+
if sendType.requiresPremium {
125+
let hasPremium: Bool
126+
do {
127+
hasPremium = try await services.sendRepository.doesActiveAccountHavePremium()
128+
} catch {
129+
hasPremium = false
130+
services.errorReporter.log(error: error)
131+
}
132+
133+
guard hasPremium else {
134+
coordinator.showAlert(.defaultAlert(title: Localizations.sendFilePremiumRequired))
135+
return
136+
}
137+
}
138+
coordinator.navigate(to: .addItem(type: sendType), context: self)
139+
}
140+
117141
/// Refreshes the user's vault, including sends.
118142
///
119143
private func refresh() async {

BitwardenShared/UI/Tools/Send/Send/SendList/SendListProcessorTests.swift

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,55 @@ class SendListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
5656

5757
// MARK: Tests
5858

59+
/// `perform(_:)` with `.addItemPressed` navigates to the `.addItem` route.
60+
@MainActor
61+
func test_perform_addItemPressed_fileType() async {
62+
subject.state.type = .file
63+
await subject.perform(.addItemPressed(.file))
64+
65+
XCTAssertEqual(coordinator.routes.last, .addItem(type: .file))
66+
}
67+
68+
/// `perform(_:)` with `.addItemPressed` shows an alert if attempting to add a file send and
69+
/// there's an error determining if the user has premium.
70+
@MainActor
71+
func test_perform_addItemPressed_fileType_error() async throws {
72+
sendRepository.doesActivateAccountHavePremiumResult = .failure(BitwardenTestError.example)
73+
subject.state.type = .file
74+
await subject.perform(.addItemPressed(.file))
75+
76+
XCTAssertEqual(
77+
coordinator.alertShown,
78+
[.defaultAlert(title: Localizations.sendFilePremiumRequired)]
79+
)
80+
XCTAssertTrue(coordinator.routes.isEmpty)
81+
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
82+
}
83+
84+
/// `perform(_:)` with `.addItemPressed` shows an alert if attempting to add a file send and
85+
/// the user doesn't have premium.
86+
@MainActor
87+
func test_perform_addItemPressed_fileType_withoutPremium() async throws {
88+
sendRepository.doesActivateAccountHavePremiumResult = .success(false)
89+
subject.state.type = .file
90+
await subject.perform(.addItemPressed(.file))
91+
92+
XCTAssertEqual(
93+
coordinator.alertShown,
94+
[.defaultAlert(title: Localizations.sendFilePremiumRequired)]
95+
)
96+
XCTAssertTrue(coordinator.routes.isEmpty)
97+
}
98+
99+
/// `perform(_:)` with `.addItemPressed` navigates to the `.addItem` route.
100+
@MainActor
101+
func test_perform_addItemPressed_textType() async {
102+
subject.state.type = .text
103+
await subject.perform(.addItemPressed(.text))
104+
105+
XCTAssertEqual(coordinator.routes.last, .addItem(type: .text))
106+
}
107+
59108
/// `perform(_:)` with `loadData` loads the policy data for the view.
60109
@MainActor
61110
func test_perform_loadData_policies() async {
@@ -413,33 +462,6 @@ class SendListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
413462
XCTAssertTrue(sections.isEmpty)
414463
}
415464

416-
/// `receive(_:)` with `.addItemPressed` navigates to the `.addItem` route.
417-
@MainActor
418-
func test_receive_addItemPressed_nilType() {
419-
subject.state.type = nil
420-
subject.receive(.addItemPressed)
421-
422-
XCTAssertEqual(coordinator.routes.last, .addItem(type: nil))
423-
}
424-
425-
/// `receive(_:)` with `.addItemPressed` navigates to the `.addItem` route.
426-
@MainActor
427-
func test_receive_addItemPressed_fileType() {
428-
subject.state.type = .file
429-
subject.receive(.addItemPressed)
430-
431-
XCTAssertEqual(coordinator.routes.last, .addItem(type: .file))
432-
}
433-
434-
/// `receive(_:)` with `.addItemPressed` navigates to the `.addItem` route.
435-
@MainActor
436-
func test_receive_addItemPressed_textType() {
437-
subject.state.type = .text
438-
subject.receive(.addItemPressed)
439-
440-
XCTAssertEqual(coordinator.routes.last, .addItem(type: .text))
441-
}
442-
443465
/// `receive(_:)` with `.clearInfoUrl` clears the info url.
444466
@MainActor
445467
func test_receive_clearInfoUrl() {

BitwardenShared/UI/Tools/Send/Send/SendList/SendListView.swift

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,14 @@ private struct MainSendListView: View {
3434
content
3535
.hidden(isSearching)
3636
.overlay(alignment: .bottomTrailing) {
37-
addItemFloatingActionButton {
38-
store.send(.addItemPressed)
37+
if let sendType = store.state.type {
38+
addItemFloatingActionButton {
39+
await store.perform(.addItemPressed(sendType))
40+
}
41+
} else {
42+
addSendItemFloatingActionMenu { sendType in
43+
await store.perform(.addItemPressed(sendType))
44+
}
3945
}
4046
}
4147

@@ -81,16 +87,25 @@ private struct MainSendListView: View {
8187
)
8288
.padding(.horizontal, 16)
8389

84-
Button {
85-
store.send(.addItemPressed)
86-
} label: {
87-
HStack {
88-
Image(decorative: Asset.Images.plus16)
89-
.resizable()
90-
.frame(width: 16, height: 16)
91-
Text(Localizations.newSend)
90+
Group {
91+
let newSendLabel = Label(Localizations.newSend, image: Asset.Images.plus16.swiftUIImage)
92+
if let sendType = store.state.type {
93+
AsyncButton {
94+
await store.perform(.addItemPressed(sendType))
95+
} label: {
96+
newSendLabel
97+
}
98+
} else {
99+
Menu {
100+
ForEach(SendType.allCases.reversed()) { sendType in
101+
AsyncButton(sendType.localizedName) {
102+
await store.perform(.addItemPressed(sendType))
103+
}
104+
}
105+
} label: {
106+
newSendLabel
107+
}
92108
}
93-
.padding(.horizontal, 24)
94109
}
95110
.buttonStyle(.primary(shouldFillWidth: false))
96111
// Disable from VoiceOver in favor of the FAB which provides the same functionality.

BitwardenShared/UI/Tools/Send/Send/SendList/SendListViewTests.swift

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,62 @@ class SendListViewTests: BitwardenTestCase {
2929

3030
// MARK: Tests
3131

32-
/// Tapping the add item floating action button dispatches the `.addItemPressed` action.`
32+
/// Tapping the add item floating action button in the file type list performs the `.addItemPressed` effect.
3333
@MainActor
34-
func test_additemFloatingActionButton_tap() throws {
35-
let fab = try subject.inspect().find(viewWithAccessibilityIdentifier: "AddItemFloatingActionButton")
36-
try fab.button().tap()
37-
XCTAssertEqual(processor.dispatchedActions.last, .addItemPressed)
34+
func test_addItemFloatingActionButton_sendTypeFile_tap() async throws {
35+
processor.state.type = .file
36+
let fab = try subject.inspect().find(
37+
floatingActionButtonWithAccessibilityIdentifier: "AddItemFloatingActionButton"
38+
)
39+
try await fab.tap()
40+
XCTAssertEqual(processor.effects.last, .addItemPressed(.file))
41+
}
42+
43+
/// Tapping the add item floating action button in the text type list performs the `.addItemPressed` effect.
44+
@MainActor
45+
func test_addItemFloatingActionButton_sendTypeText_tap() async throws {
46+
processor.state.type = .text
47+
let fab = try subject.inspect().find(
48+
floatingActionButtonWithAccessibilityIdentifier: "AddItemFloatingActionButton"
49+
)
50+
try await fab.tap()
51+
XCTAssertEqual(processor.effects.last, .addItemPressed(.text))
52+
}
53+
54+
/// Tapping the add item floating action menu and selecting the file type performs the `.addItemPressed` effect.
55+
@MainActor
56+
func test_addItemFloatingActionMenu_file_tap() async throws {
57+
let fabMenuButton = try subject.inspect().find(asyncButton: Localizations.file)
58+
try await fabMenuButton.tap()
59+
XCTAssertEqual(processor.effects.last, .addItemPressed(.file))
3860
}
3961

40-
/// Tapping the add a send button dispatches the `.addItemPressed` action.
62+
/// Tapping the add item floating action menu and selecting the text type performs the `.addItemPressed` effect.
4163
@MainActor
42-
func test_addSendButton_tap() throws {
64+
func test_addItemFloatingActionMenu_text_tap() async throws {
65+
let fabMenuButton = try subject.inspect().find(asyncButton: Localizations.text)
66+
try await fabMenuButton.tap()
67+
XCTAssertEqual(processor.effects.last, .addItemPressed(.text))
68+
}
69+
70+
/// Tapping the add a send button in the empty state performs the `.addItemPressed` effect.
71+
@MainActor
72+
func test_emptyState_addSendButton_sendTypeFile_tap() async throws {
4373
processor.state = .empty
44-
let button = try subject.inspect().find(button: Localizations.newSend)
45-
try button.tap()
46-
XCTAssertEqual(processor.dispatchedActions.last, .addItemPressed)
74+
processor.state.type = .file
75+
let button = try subject.inspect().find(asyncButton: Localizations.newSend)
76+
try await button.tap()
77+
XCTAssertEqual(processor.effects.last, .addItemPressed(.file))
78+
}
79+
80+
/// Tapping the add a send button in the empty state performs the `.addItemPressed` effect.
81+
@MainActor
82+
func test_emptyState_addSendButton_sendTypeText_tap() async throws {
83+
processor.state = .empty
84+
processor.state.type = .text
85+
let button = try subject.inspect().find(asyncButton: Localizations.newSend)
86+
try await button.tap()
87+
XCTAssertEqual(processor.effects.last, .addItemPressed(.text))
4788
}
4889

4990
/// Tapping the info button dispatches the `.infoButtonPressed` action.

BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemAction.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,4 @@ enum AddEditSendItemAction: Equatable {
4646

4747
/// The toast was shown or hidden.
4848
case toastShown(Toast?)
49-
50-
/// The type picker was changed.
51-
case typeChanged(SendType)
5249
}

0 commit comments

Comments
 (0)