Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.

Commit 4603706

Browse files
Alessandro/onboarding choose app icon (#3330)
Task/Issue URL:mhttps://app.asana.com/0/1206329551987282/1208084960726996/f **Description**: Add AppIcon screen selection to the onboarding.
1 parent ddbd2d5 commit 4603706

File tree

6 files changed

+386
-9
lines changed

6 files changed

+386
-9
lines changed

DuckDuckGo.xcodeproj/project.pbxproj

+24
Original file line numberDiff line numberDiff line change
@@ -696,11 +696,14 @@
696696
9F69331D2C5A191400CD6A5D /* MockTutorialSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */; };
697697
9F69331F2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F69331E2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift */; };
698698
9F6933212C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6933202C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift */; };
699+
9F7CFF762C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7CFF752C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift */; };
699700
9F7CFF782C86E3E10012833E /* OnboardingManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7CFF772C86E3E10012833E /* OnboardingManagerTests.swift */; };
701+
9F7CFF7D2C89B69A0012833E /* AppIconPickerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7CFF7C2C89B69A0012833E /* AppIconPickerViewModelTests.swift */; };
700702
9F8007262C5261AF003EDAF4 /* MockPrivacyDataReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8007252C5261AF003EDAF4 /* MockPrivacyDataReporter.swift */; };
701703
9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 9F8FE9482BAE50E50071E372 /* Lottie */; };
702704
9F9A922E2C86A56B001D036D /* OnboardingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9A922D2C86A56B001D036D /* OnboardingManager.swift */; };
703705
9F9A92312C86AAE9001D036D /* OnboardingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9A92302C86AAE9001D036D /* OnboardingDebugView.swift */; };
706+
9F9A92342C86B42B001D036D /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9A92332C86B42B001D036D /* AppIconPicker.swift */; };
704707
9F9EE4CE2C377D4900D4118E /* OnboardingFirePixelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9EE4CC2C377D3F00D4118E /* OnboardingFirePixelMock.swift */; };
705708
9F9EE4D42C37BB1300D4118E /* OnboardingView+Landing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9EE4D32C37BB1300D4118E /* OnboardingView+Landing.swift */; };
706709
9FA5E44B2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5E44A2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift */; };
@@ -713,6 +716,7 @@
713716
9FCFCD802C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */; };
714717
9FCFCD812C6B020D006EB7A0 /* LaunchOptionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */; };
715718
9FCFCD852C75C91A006EB7A0 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */; };
719+
9FDEC7BC2C91204900C7A692 /* AppIconPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDEC7BB2C91204900C7A692 /* AppIconPickerViewModel.swift */; };
716720
9FE05CEE2C36424E00D9046B /* OnboardingPixelReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE05CED2C36424E00D9046B /* OnboardingPixelReporter.swift */; };
717721
9FE05CF12C36468A00D9046B /* OnboardingPixelReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE05CEF2C3642F900D9046B /* OnboardingPixelReporterTests.swift */; };
718722
9FE08BD32C2A5B88001D5EBC /* OnboardingTextStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE08BD22C2A5B88001D5EBC /* OnboardingTextStyles.swift */; };
@@ -2476,10 +2480,13 @@
24762480
9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTutorialSettings.swift; sourceTree = "<group>"; };
24772481
9F69331E2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppearViewModifier.swift; sourceTree = "<group>"; };
24782482
9F6933202C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHostingControllerMock.swift; sourceTree = "<group>"; };
2483+
9F7CFF752C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingView+AppIconPickerContent.swift"; sourceTree = "<group>"; };
24792484
9F7CFF772C86E3E10012833E /* OnboardingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManagerTests.swift; sourceTree = "<group>"; };
2485+
9F7CFF7C2C89B69A0012833E /* AppIconPickerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPickerViewModelTests.swift; sourceTree = "<group>"; };
24802486
9F8007252C5261AF003EDAF4 /* MockPrivacyDataReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPrivacyDataReporter.swift; sourceTree = "<group>"; };
24812487
9F9A922D2C86A56B001D036D /* OnboardingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManager.swift; sourceTree = "<group>"; };
24822488
9F9A92302C86AAE9001D036D /* OnboardingDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingDebugView.swift; sourceTree = "<group>"; };
2489+
9F9A92332C86B42B001D036D /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = "<group>"; };
24832490
9F9EE4CC2C377D3F00D4118E /* OnboardingFirePixelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFirePixelMock.swift; sourceTree = "<group>"; };
24842491
9F9EE4D32C37BB1300D4118E /* OnboardingView+Landing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingView+Landing.swift"; sourceTree = "<group>"; };
24852492
9FA5E44A2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerViewFactory.swift; sourceTree = "<group>"; };
@@ -2491,6 +2498,7 @@
24912498
9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LaunchOptionsHandler.swift; path = ../DuckDuckGo/LaunchOptionsHandler.swift; sourceTree = "<group>"; };
24922499
9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchOptionsHandlerTests.swift; sourceTree = "<group>"; };
24932500
9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = "<group>"; };
2501+
9FDEC7BB2C91204900C7A692 /* AppIconPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPickerViewModel.swift; sourceTree = "<group>"; };
24942502
9FE05CED2C36424E00D9046B /* OnboardingPixelReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPixelReporter.swift; sourceTree = "<group>"; };
24952503
9FE05CEF2C3642F900D9046B /* OnboardingPixelReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPixelReporterTests.swift; sourceTree = "<group>"; };
24962504
9FE08BD22C2A5B88001D5EBC /* OnboardingTextStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTextStyles.swift; sourceTree = "<group>"; };
@@ -4660,6 +4668,7 @@
46604668
9F9EE4D32C37BB1300D4118E /* OnboardingView+Landing.swift */,
46614669
9FB027112C2526DD009EA190 /* OnboardingView+IntroDialogContent.swift */,
46624670
9FB027132C252E0C009EA190 /* OnboardingView+BrowsersComparisonContent.swift */,
4671+
9F7CFF752C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift */,
46634672
);
46644673
path = OnboardingIntro;
46654674
sourceTree = "<group>";
@@ -4678,6 +4687,7 @@
46784687
9F4CC51E2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift */,
46794688
9F69331A2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift */,
46804689
9F7CFF772C86E3E10012833E /* OnboardingManagerTests.swift */,
4690+
9F7CFF7C2C89B69A0012833E /* AppIconPickerViewModelTests.swift */,
46814691
);
46824692
name = Onboarding;
46834693
sourceTree = "<group>";
@@ -4715,6 +4725,15 @@
47154725
name = OnboardingDebugView;
47164726
sourceTree = "<group>";
47174727
};
4728+
9F9A92322C86B419001D036D /* AppIconPicker */ = {
4729+
isa = PBXGroup;
4730+
children = (
4731+
9F9A92332C86B42B001D036D /* AppIconPicker.swift */,
4732+
9FDEC7BB2C91204900C7A692 /* AppIconPickerViewModel.swift */,
4733+
);
4734+
path = AppIconPicker;
4735+
sourceTree = "<group>";
4736+
};
47184737
9F9EE4CB2C377D2400D4118E /* Mocks */ = {
47194738
isa = PBXGroup;
47204739
children = (
@@ -4763,6 +4782,7 @@
47634782
9FF7E9802C22A19800902BE5 /* OnboardingExperiment */ = {
47644783
isa = PBXGroup;
47654784
children = (
4785+
9F9A92322C86B419001D036D /* AppIconPicker */,
47664786
9F9A922C2C86A560001D036D /* Manager */,
47674787
9FE05CEC2C36423C00D9046B /* Pixels */,
47684788
56D060202C356B0B003BAEB5 /* ContextualDaxDialogs */,
@@ -7339,6 +7359,7 @@
73397359
37FCAABC2992F592000E420A /* MultilineScrollableTextFix.swift in Sources */,
73407360
85DFEDED24C7CCA500973FE7 /* AppWidthObserver.swift in Sources */,
73417361
4B6484F327FD1E350050A7A1 /* MenuControllerView.swift in Sources */,
7362+
9F9A92342C86B42B001D036D /* AppIconPicker.swift in Sources */,
73427363
CB825C962C071C9300BCC586 /* AlertViewPresenter.swift in Sources */,
73437364
1EE7C299294227EC0026C8CB /* AutoconsentSettingsViewController.swift in Sources */,
73447365
1E8AD1D527C2E22900ABA377 /* DownloadsListSectionViewModel.swift in Sources */,
@@ -7449,6 +7470,7 @@
74497470
D6E83C602B22B3C9006C8AFB /* SettingsState.swift in Sources */,
74507471
D6E83C482B20C812006C8AFB /* SettingsHostingController.swift in Sources */,
74517472
F46FEC5727987A5F0061D9DF /* KeychainItemsDebugViewController.swift in Sources */,
7473+
9F7CFF762C86BB8F0012833E /* OnboardingView+AppIconPickerContent.swift in Sources */,
74527474
D68A21442B7EC08500BB372E /* SubscriptionExternalLinkView.swift in Sources */,
74537475
BDD3B3552B8EF8DB005857A8 /* NetworkProtectionUNNotificationPresenter.swift in Sources */,
74547476
BD862E0B2B30F9300073E2EE /* VPNFeedbackFormView.swift in Sources */,
@@ -7549,6 +7571,7 @@
75497571
1DEAADFF2BA7832F00E25A97 /* EmailProtectionView.swift in Sources */,
75507572
988F3DD3237DE8D900AEE34C /* ForgetDataAlert.swift in Sources */,
75517573
D6FEB8B12B7498A300C3615F /* HeadlessWebView.swift in Sources */,
7574+
9FDEC7BC2C91204900C7A692 /* AppIconPickerViewModel.swift in Sources */,
75527575
F1FDC9352BF51E41006B1435 /* VPNSettings+Environment.swift in Sources */,
75537576
850ABD012AC3961100A733DF /* MainViewController+Segues.swift in Sources */,
75547577
6FB2A6802C2EA950004D20C8 /* FavoritesDefaultModel.swift in Sources */,
@@ -7831,6 +7854,7 @@
78317854
983BD6B52B34760600AAC78E /* MockPrivacyConfiguration.swift in Sources */,
78327855
1E8146AD28C8ABF000D1AF63 /* TrackerAnimationLogicTests.swift in Sources */,
78337856
C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */,
7857+
9F7CFF7D2C89B69A0012833E /* AppIconPickerViewModelTests.swift in Sources */,
78347858
B6AD9E3A28D456820019CDE9 /* PrivacyConfigurationManagerMock.swift in Sources */,
78357859
F189AED71F18F6DE001EBAE1 /* TabTests.swift in Sources */,
78367860
6FABAA692C6116FD003762EC /* NewTabPageShortcutsSettingsStorageTests.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//
2+
// AppIconPicker.swift
3+
// DuckDuckGo
4+
//
5+
// Copyright © 2024 DuckDuckGo. All rights reserved.
6+
//
7+
// Licensed under the Apache License, Version 2.0 (the "License");
8+
// you may not use this file except in compliance with the License.
9+
// You may obtain a copy of the License at
10+
//
11+
// http://www.apache.org/licenses/LICENSE-2.0
12+
//
13+
// Unless required by applicable law or agreed to in writing, software
14+
// distributed under the License is distributed on an "AS IS" BASIS,
15+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
// See the License for the specific language governing permissions and
17+
// limitations under the License.
18+
//
19+
20+
import SwiftUI
21+
import DuckUI
22+
23+
private enum Metrics {
24+
static let cornerRadius: CGFloat = 13.0
25+
static let iconSize: CGFloat = 56.0
26+
static let spacing: CGFloat = 16.0
27+
static let strokeFrameSize: CGFloat = 60
28+
static let strokeWidth: CGFloat = 3
29+
static let strokeInset: CGFloat = 1.5
30+
}
31+
32+
struct AppIconPicker: View {
33+
@Environment(\.colorScheme) private var color
34+
35+
@StateObject private var viewModel = AppIconPickerViewModel()
36+
37+
let layout = [GridItem(.adaptive(minimum: Metrics.iconSize, maximum: Metrics.iconSize), spacing: Metrics.spacing)]
38+
39+
var body: some View {
40+
LazyVGrid(columns: layout, spacing: Metrics.spacing) {
41+
ForEach(viewModel.items, id: \.icon) { item in
42+
Image(uiImage: item.icon.mediumImage ?? UIImage())
43+
.resizable()
44+
.frame(width: Metrics.iconSize, height: Metrics.iconSize)
45+
.cornerRadius(Metrics.cornerRadius)
46+
.overlay {
47+
strokeOverlay(isSelected: item.isSelected)
48+
}
49+
.onTapGesture {
50+
viewModel.changeApp(icon: item.icon)
51+
}
52+
}
53+
}
54+
}
55+
56+
@ViewBuilder
57+
private func strokeOverlay(isSelected: Bool) -> some View {
58+
if isSelected {
59+
RoundedRectangle(cornerRadius: Metrics.cornerRadius)
60+
.foregroundColor(.clear)
61+
.frame(width: Metrics.strokeFrameSize, height: Metrics.strokeFrameSize)
62+
.overlay(
63+
RoundedRectangle(cornerRadius: Metrics.cornerRadius)
64+
.inset(by: -Metrics.strokeInset)
65+
.stroke(.blue, lineWidth: Metrics.strokeWidth)
66+
)
67+
}
68+
}
69+
}
70+
71+
#Preview {
72+
AppIconPicker()
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//
2+
// AppIconPickerViewModel.swift
3+
// DuckDuckGo
4+
//
5+
// Copyright © 2024 DuckDuckGo. All rights reserved.
6+
//
7+
// Licensed under the Apache License, Version 2.0 (the "License");
8+
// you may not use this file except in compliance with the License.
9+
// You may obtain a copy of the License at
10+
//
11+
// http://www.apache.org/licenses/LICENSE-2.0
12+
//
13+
// Unless required by applicable law or agreed to in writing, software
14+
// distributed under the License is distributed on an "AS IS" BASIS,
15+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
// See the License for the specific language governing permissions and
17+
// limitations under the License.
18+
//
19+
20+
import Foundation
21+
22+
@MainActor
23+
final class AppIconPickerViewModel: ObservableObject {
24+
25+
struct DisplayModel {
26+
let icon: AppIcon
27+
let isSelected: Bool
28+
}
29+
30+
@Published private(set) var items: [DisplayModel] = []
31+
32+
private let appIconManager: AppIconManaging
33+
34+
init(appIconManager: AppIconManaging = AppIconManager.shared) {
35+
self.appIconManager = appIconManager
36+
items = makeDisplayModels()
37+
}
38+
39+
func changeApp(icon: AppIcon) {
40+
appIconManager.changeAppIcon(icon) { [weak self] error in
41+
guard let self, error == nil else { return }
42+
items = makeDisplayModels()
43+
}
44+
}
45+
46+
private func makeDisplayModels() -> [DisplayModel] {
47+
AppIcon.allCases.map { appIcon in
48+
DisplayModel(icon: appIcon, isSelected: appIconManager.appIcon == appIcon)
49+
}
50+
}
51+
}
52+
53+
protocol AppIconManaging {
54+
var appIcon: AppIcon { get }
55+
func changeAppIcon(_ appIcon: AppIcon, completionHandler: ((Error?) -> Void)?)
56+
}
57+
58+
extension AppIconManaging {
59+
func changeAppIcon(_ appIcon: AppIcon) {
60+
changeAppIcon(appIcon, completionHandler: nil)
61+
}
62+
}
63+
64+
extension AppIconManager: AppIconManaging {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// OnboardingView+AppIconPickerContent.swift
3+
// DuckDuckGo
4+
//
5+
// Copyright © 2024 DuckDuckGo. All rights reserved.
6+
//
7+
// Licensed under the Apache License, Version 2.0 (the "License");
8+
// you may not use this file except in compliance with the License.
9+
// You may obtain a copy of the License at
10+
//
11+
// http://www.apache.org/licenses/LICENSE-2.0
12+
//
13+
// Unless required by applicable law or agreed to in writing, software
14+
// distributed under the License is distributed on an "AS IS" BASIS,
15+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
// See the License for the specific language governing permissions and
17+
// limitations under the License.
18+
//
19+
20+
import SwiftUI
21+
import DuckUI
22+
import Onboarding
23+
24+
extension OnboardingView {
25+
26+
struct AppIconPickerContentState {
27+
var animateTitle = true
28+
var animateMessage = false
29+
var showContent = false
30+
}
31+
32+
struct AppIconPickerContent: View {
33+
34+
private var animateTitle: Binding<Bool>
35+
private var animateMessage: Binding<Bool>
36+
private var showContent: Binding<Bool>
37+
private let action: () -> Void
38+
39+
init(
40+
animateTitle: Binding<Bool> = .constant(true),
41+
animateMessage: Binding<Bool> = .constant(true),
42+
showContent: Binding<Bool> = .constant(false),
43+
action: @escaping () -> Void
44+
) {
45+
self.animateTitle = animateTitle
46+
self.animateMessage = animateMessage
47+
self.showContent = showContent
48+
self.action = action
49+
}
50+
51+
var body: some View {
52+
VStack(spacing: 16.0) {
53+
AnimatableTypingText(UserText.HighlightsOnboardingExperiment.AppIconSelection.title, startAnimating: animateTitle) {
54+
animateMessage.wrappedValue = true
55+
}
56+
.foregroundColor(.primary)
57+
.font(Metrics.titleFont)
58+
59+
AnimatableTypingText(UserText.HighlightsOnboardingExperiment.AppIconSelection.message, startAnimating: animateMessage) {
60+
withAnimation {
61+
showContent.wrappedValue = true
62+
}
63+
}
64+
.foregroundColor(.primary)
65+
.font(Metrics.messageFont)
66+
67+
VStack(spacing: 24) {
68+
AppIconPicker()
69+
.offset(x: Metrics.pickerLeadingOffset) // Remove left padding for the first item
70+
71+
Button(action: action) {
72+
Text(UserText.HighlightsOnboardingExperiment.AppIconSelection.cta)
73+
}
74+
.buttonStyle(PrimaryButtonStyle())
75+
}
76+
.visibility(showContent.wrappedValue ? .visible : .invisible)
77+
}
78+
}
79+
80+
}
81+
82+
}
83+
84+
private enum Metrics {
85+
static let titleFont = Font.system(size: 20, weight: .semibold)
86+
static let messageFont = Font.system(size: 16)
87+
static let pickerLeadingOffset: CGFloat = -20
88+
}

0 commit comments

Comments
 (0)