Skip to content

Commit 64e6b9b

Browse files
committed
Merge branch 'trunk' into issue/5874-notification-badge-in-the-Hub-Menu-and-inside-the-hub-menu-cell-in-case-there-is-a-new-notification
2 parents 9590ff7 + 4686863 commit 64e6b9b

File tree

15 files changed

+612
-206
lines changed

15 files changed

+612
-206
lines changed
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import SwiftUI
2+
import UIKit
3+
4+
/// View Modifier that shows a notice in front of a view.
5+
///
6+
/// NOTE: This currently does not support:
7+
/// - Enqueuing multiple notices like `DefaultNoticePresenter` does.
8+
/// - Presenting foreground system notifications.
9+
///
10+
struct NoticeModifier: ViewModifier {
11+
/// Notice object to render.
12+
///
13+
@Binding var notice: Notice?
14+
15+
/// Cancelable task that clears a notice.
16+
///
17+
@State private var clearNoticeTask = DispatchWorkItem(block: {})
18+
19+
/// Time the notice will remain on screen.
20+
///
21+
private let onScreenNoticeTime = 5.0
22+
23+
/// Feedback generator.
24+
///
25+
private let feedbackGenerator = UINotificationFeedbackGenerator()
26+
27+
/// Current horizontal size class.
28+
///
29+
@Environment(\.horizontalSizeClass) var horizontalSizeClass
30+
31+
func body(content: Content) -> some View {
32+
content
33+
.overlay(buildNoticeStack())
34+
.animation(.easeInOut, value: notice)
35+
}
36+
37+
/// Builds a notice view at the bottom of the screen.
38+
///
39+
@ViewBuilder private func buildNoticeStack() -> some View {
40+
if let notice = notice {
41+
// Geometry reader to provide the correct view width.
42+
GeometryReader { geometry in
43+
44+
// VStack with spacer to push content to the bottom
45+
VStack {
46+
Spacer()
47+
48+
// NoticeView wrapper
49+
NoticeAlert(notice: notice, width: preferredSizeClassWidth(geometry))
50+
.onDismiss {
51+
performClearNoticeTask()
52+
}
53+
.onChange(of: notice) { _ in
54+
provideHapticFeedbackIfNecessary(notice.feedbackType)
55+
dispatchClearNoticeTask()
56+
}
57+
.onAppear {
58+
provideHapticFeedbackIfNecessary(notice.feedbackType)
59+
dispatchClearNoticeTask()
60+
}
61+
.fixedSize()
62+
}
63+
.frame(width: geometry.size.width) // Force a full container width so the notice is always centered.
64+
}
65+
}
66+
}
67+
68+
/// Cancels any ongoing clear notice task and dispatches it again.
69+
///
70+
private func dispatchClearNoticeTask() {
71+
clearNoticeTask.cancel()
72+
clearNoticeTask = .init {
73+
$notice.wrappedValue = nil
74+
}
75+
DispatchQueue.main.asyncAfter(deadline: .now() + onScreenNoticeTime, execute: clearNoticeTask)
76+
}
77+
78+
/// Synchronously performs the clear notice task and cancels it to prevent any future execution.
79+
///
80+
private func performClearNoticeTask() {
81+
clearNoticeTask.perform()
82+
clearNoticeTask.cancel()
83+
}
84+
85+
/// Sends haptic feedback if required.
86+
///
87+
private func provideHapticFeedbackIfNecessary(_ feedbackType: UINotificationFeedbackGenerator.FeedbackType?) {
88+
if let feedbackType = feedbackType {
89+
feedbackGenerator.notificationOccurred(feedbackType)
90+
}
91+
}
92+
93+
/// Returns a scaled width for a regular horizontal size class.
94+
///
95+
private func preferredSizeClassWidth(_ geometry: GeometryProxy) -> CGFloat {
96+
let multiplier = horizontalSizeClass == .regular ? 0.5 : 1.0
97+
return geometry.size.width * multiplier
98+
}
99+
}
100+
101+
// MARK: Custom Views
102+
103+
/// `SwiftUI` representable type for `NoticeView`.
104+
///
105+
private struct NoticeAlert: UIViewRepresentable {
106+
107+
/// Notice object to render.
108+
///
109+
let notice: Notice
110+
111+
/// Desired width of the view.
112+
///
113+
let width: CGFloat
114+
115+
/// Action to be invoked when the view is tapped.
116+
///
117+
var onDismiss: (() -> Void)?
118+
119+
func makeUIView(context: Context) -> NoticeWrapper {
120+
let noticeView = NoticeView(notice: notice)
121+
let wrapperView = NoticeWrapper(noticeView: noticeView)
122+
wrapperView.translatesAutoresizingMaskIntoConstraints = false
123+
return wrapperView
124+
}
125+
126+
func updateUIView(_ uiView: NoticeWrapper, context: Context) {
127+
uiView.noticeView = NoticeView(notice: notice)
128+
uiView.noticeView.dismissHandler = onDismiss
129+
uiView.width = width
130+
}
131+
132+
/// Updates the notice dismiss closure.
133+
///
134+
func onDismiss(_ onDismiss: @escaping (() -> Void)) -> Self {
135+
var copy = self
136+
copy.onDismiss = onDismiss
137+
return copy
138+
}
139+
}
140+
141+
142+
private extension NoticeAlert {
143+
/// Wrapper type to force the underlying `NoticeView` to have a fixed width.
144+
///
145+
class NoticeWrapper: UIView {
146+
/// Underlying notice view
147+
///
148+
var noticeView: NoticeView {
149+
didSet {
150+
oldValue.removeFromSuperview()
151+
setUpNoticeLayout()
152+
}
153+
}
154+
155+
/// Fixed width constraint.
156+
///
157+
var width: CGFloat = 0 {
158+
didSet {
159+
noticeViewWidthConstraint.constant = width
160+
}
161+
}
162+
163+
/// Width constraint for the notice view.
164+
///
165+
private var noticeViewWidthConstraint = NSLayoutConstraint()
166+
167+
/// Notice view padding.
168+
///
169+
let defaultInsets = UIEdgeInsets(top: 16, left: 16, bottom: 32, right: 16)
170+
171+
init(noticeView: NoticeView) {
172+
self.noticeView = noticeView
173+
super.init(frame: .zero)
174+
175+
setUpNoticeLayout()
176+
createWidthConstraint()
177+
}
178+
179+
/// Set ups the notice layout.
180+
///
181+
private func setUpNoticeLayout() {
182+
// Add notice view to edges
183+
noticeView.translatesAutoresizingMaskIntoConstraints = false
184+
addSubview(noticeView)
185+
186+
layoutMargins = defaultInsets
187+
pinSubviewToAllEdgeMargins(noticeView)
188+
}
189+
190+
/// Forces the wrapper view to have a fixed width.
191+
///
192+
private func createWidthConstraint() {
193+
noticeViewWidthConstraint = widthAnchor.constraint(equalToConstant: width)
194+
noticeViewWidthConstraint.isActive = true
195+
}
196+
197+
/// Returns the preferred size of the view using the fixed width.
198+
///
199+
override var intrinsicContentSize: CGSize {
200+
let targetSize = CGSize(width: width - defaultInsets.left - defaultInsets.right, height: 0)
201+
let noticeHeight = noticeView.systemLayoutSizeFitting(
202+
targetSize,
203+
withHorizontalFittingPriority: .required,
204+
verticalFittingPriority: .defaultLow
205+
).height
206+
return CGSize(width: width, height: noticeHeight + defaultInsets.top + defaultInsets.bottom)
207+
}
208+
209+
required init?(coder: NSCoder) {
210+
fatalError("init(coder:) has not been implemented")
211+
}
212+
}
213+
}
214+
215+
// MARK: View Extension
216+
217+
extension View {
218+
/// Shows the provided notice in front of the view.
219+
///
220+
func notice(_ notice: Binding<Notice?>) -> some View {
221+
modifier(NoticeModifier(notice: notice))
222+
}
223+
}
224+
225+
// MARK: Preview
226+
227+
struct NoticeModifier_Previews: PreviewProvider {
228+
static var previews: some View {
229+
Rectangle().foregroundColor(.white)
230+
.notice(.constant(
231+
.init(title: "API Error",
232+
subtitle: "Restricted Access",
233+
message: "Your photos could not be downloaded, please ask for the correct permissions!",
234+
feedbackType: .error,
235+
notificationInfo: nil,
236+
actionTitle: "Retry",
237+
actionHandler: {
238+
print("Retry")
239+
})
240+
))
241+
.environment(\.colorScheme, .light)
242+
.previewDisplayName("Light Content")
243+
}
244+
}

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/AddProductToOrder.swift

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,31 @@
11
import SwiftUI
22

3-
/// View showing a list of products or product variations to add to an order.
3+
/// View showing a list of products to add to an order.
44
///
5-
struct AddProductToOrder<ViewModel: AddProductToOrderViewModelProtocol>: View {
5+
struct AddProductToOrder: View {
66
/// Defines whether the view is presented.
77
///
88
@Binding var isPresented: Bool
99

1010
/// View model to drive the view.
1111
///
12-
@ObservedObject var viewModel: ViewModel
12+
@ObservedObject var viewModel: AddProductToOrderViewModel
1313

1414
var body: some View {
1515
NavigationView {
1616
Group {
1717
switch viewModel.syncStatus {
1818
case .results:
19-
List {
19+
InfiniteScrollList(isLoading: viewModel.shouldShowScrollIndicator,
20+
loadAction: viewModel.syncNextPage) {
2021
ForEach(viewModel.productRows) { rowViewModel in
2122
ProductRow(viewModel: rowViewModel)
2223
.onTapGesture {
23-
viewModel.selectProductOrVariation(rowViewModel.productOrVariationID)
24+
viewModel.selectProduct(rowViewModel.productOrVariationID)
2425
isPresented.toggle()
2526
}
2627
}
27-
28-
InfiniteScrollIndicator(showContent: viewModel.shouldShowScrollIndicator)
29-
.onAppear {
30-
viewModel.syncNextPage()
31-
}
3228
}
33-
.listStyle(PlainListStyle())
3429
case .empty:
3530
EmptyState(title: Localization.emptyStateMessage, image: .emptyProductsTabImage)
3631
.frame(maxHeight: .infinity)
@@ -64,35 +59,13 @@ struct AddProductToOrder<ViewModel: AddProductToOrderViewModelProtocol>: View {
6459
}
6560
}
6661

67-
private struct InfiniteScrollIndicator: View {
68-
69-
let showContent: Bool
70-
71-
var body: some View {
72-
if #available(iOS 15.0, *) {
73-
createProgressView()
74-
.listRowSeparator(.hidden, edges: .bottom)
75-
} else {
76-
createProgressView()
77-
}
62+
private extension AddProductToOrder {
63+
enum Localization {
64+
static let title = NSLocalizedString("Add Product", comment: "Title for the screen to add a product to an order")
65+
static let close = NSLocalizedString("Close", comment: "Text for the close button in the Add Product screen")
66+
static let emptyStateMessage = NSLocalizedString("No products found",
67+
comment: "Message displayed if there are no products to display in the Add Product screen")
7868
}
79-
80-
@ViewBuilder func createProgressView() -> some View {
81-
ProgressView()
82-
.frame(maxWidth: .infinity, alignment: .center)
83-
.listRowInsets(EdgeInsets())
84-
.listRowBackground(Color(.listBackground))
85-
.if(!showContent) { progressView in
86-
progressView.hidden() // Hidden but still in view hierarchy so `onAppear` will trigger sync when needed
87-
}
88-
}
89-
}
90-
91-
private enum Localization {
92-
static let title = NSLocalizedString("Add Product", comment: "Title for the screen to add a product to an order")
93-
static let close = NSLocalizedString("Close", comment: "Text for the close button in the Add Product screen")
94-
static let emptyStateMessage = NSLocalizedString("No products found",
95-
comment: "Message displayed if there are no products to display in the Add Product screen")
9669
}
9770

9871
struct AddProduct_Previews: PreviewProvider {

0 commit comments

Comments
 (0)