Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Dynamic Dashboard] In-app feedback card #12636

Merged
merged 15 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
-----
- [*] Change text color in badge view in order products list to fix the dark mode readability. [https://github.com/woocommerce/woocommerce-ios/pull/12630]
- [*] Speculative fix for a crash when opening order notifications [https://github.com/woocommerce/woocommerce-ios/pull/12643]
- [internal] Show In-App feedback card in dashboard. [https://github.com/woocommerce/woocommerce-ios/pull/12636]

18.5
-----
Expand Down
76 changes: 47 additions & 29 deletions WooCommerce/Classes/ViewRelated/Dashboard/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ struct DashboardView: View {
onSave: { viewModel.didCustomizeDashboardCards($0) }
))
}
.sheet(isPresented: $viewModel.showingInAppFeedbackSurvey) {
Survey(source: .inAppFeedback)
}
.onAppear {
viewModel.onViewAppear()
}
}
}

Expand All @@ -135,35 +141,42 @@ private extension DashboardView {
@ViewBuilder
var dashboardCards: some View {
VStack(spacing: Layout.padding) {
ForEach(viewModel.showOnDashboardCards, id: \.hashValue) { card in
switch card.type {
case .onboarding:
StoreOnboardingView(viewModel: viewModel.storeOnboardingViewModel,
onTaskTapped: { task in
guard let currentSite else { return }
onboardingTaskTapped?(currentSite, task)
}, onViewAllTapped: {
guard let currentSite else { return }
viewAllOnboardingTasksTapped?(currentSite)
}, shareFeedbackAction: {
onboardingShareFeedbackAction?()
})
case .blaze:
BlazeCampaignDashboardView(viewModel: viewModel.blazeCampaignDashboardViewModel,
showAllCampaignsTapped: showAllBlazeCampaignsTapped,
createCampaignTapped: createBlazeCampaignTapped)
case .performance:
StorePerformanceView(viewModel: viewModel.storePerformanceViewModel,
onCustomRangeRedactedViewTap: {
onCustomRangeRedactedViewTap?()
}, onViewAllAnalytics: { siteID, siteTimeZone, timeRange in
onViewAllAnalytics?(siteID, siteTimeZone, timeRange)
})
case .topPerformers:
TopPerformersDashboardView(viewModel: viewModel.topPerformersViewModel,
onViewAllAnalytics: { siteID, siteTimeZone, timeRange in
onViewAllAnalytics?(siteID, siteTimeZone, timeRange)
})
ForEach(Array(viewModel.showOnDashboardCards.enumerated()), id: \.element.hashValue) { index, card in
VStack(spacing: Layout.padding) {
switch card.type {
case .onboarding:
StoreOnboardingView(viewModel: viewModel.storeOnboardingViewModel,
onTaskTapped: { task in
guard let currentSite else { return }
onboardingTaskTapped?(currentSite, task)
}, onViewAllTapped: {
guard let currentSite else { return }
viewAllOnboardingTasksTapped?(currentSite)
}, shareFeedbackAction: {
onboardingShareFeedbackAction?()
})
case .blaze:
BlazeCampaignDashboardView(viewModel: viewModel.blazeCampaignDashboardViewModel,
showAllCampaignsTapped: showAllBlazeCampaignsTapped,
createCampaignTapped: createBlazeCampaignTapped)
case .performance:
StorePerformanceView(viewModel: viewModel.storePerformanceViewModel,
onCustomRangeRedactedViewTap: {
onCustomRangeRedactedViewTap?()
}, onViewAllAnalytics: { siteID, siteTimeZone, timeRange in
onViewAllAnalytics?(siteID, siteTimeZone, timeRange)
})
case .topPerformers:
TopPerformersDashboardView(viewModel: viewModel.topPerformersViewModel,
onViewAllAnalytics: { siteID, siteTimeZone, timeRange in
onViewAllAnalytics?(siteID, siteTimeZone, timeRange)
})
}

// Append feedback card after the first card
if index == 0 && viewModel.isInAppFeedbackCardVisible {
feedbackCard
}
}
}

Expand All @@ -173,6 +186,11 @@ private extension DashboardView {
}
}

var feedbackCard: some View {
InAppFeedbackCardView(viewModel: viewModel.inAppFeedbackCardViewModel)
.padding(.horizontal, Layout.padding)
}

var shareStoreCard: some View {
VStack(spacing: .zero) {
Image(uiImage: .blazeSuccessImage)
Expand Down
71 changes: 71 additions & 0 deletions WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ final class DashboardViewModel: ObservableObject {
dashboardCards.filter { $0.availability == .show && $0.enabled }
}

@Published private(set) var isInAppFeedbackCardVisible = false

private(set) var inAppFeedbackCardViewModel = InAppFeedbackCardViewModel()

@Published var showingInAppFeedbackSurvey = false

@Published private(set) var jetpackBannerVisibleFromAppSettings = false

@Published private(set) var hasOrders: Bool = true
Expand Down Expand Up @@ -98,11 +104,25 @@ final class DashboardViewModel: ObservableObject {
self.topPerformersViewModel = .init(siteID: siteID,
usageTracksEventEmitter: usageTracksEventEmitter)
self.themeInstaller = themeInstaller
self.inAppFeedbackCardViewModel.onFeedbackGiven = { [weak self] feedback in
self?.showingInAppFeedbackSurvey = feedback == .didntLike
self?.onInAppFeedbackCardAction()
}
configureOrdersResultController()
setupDashboardCards()
installPendingThemeIfNeeded()
}

/// Must be called by the `View` during the `onAppear()` event. This will
/// update the visibility of the in-app feedback card.
///
/// The visibility is updated on `onAppear()` to consider scenarios when the app is
/// never terminated.
///
func onViewAppear() {
refreshIsInAppFeedbackCardVisibleValue()
}

@MainActor
func reloadAllData() async {
await withTaskGroup(of: Void.self) { group in
Expand Down Expand Up @@ -381,6 +401,57 @@ private extension DashboardViewModel {
}
}

// MARK: InAppFeedback card
//
private extension DashboardViewModel {
/// Updates the card visibility state stored in `isInAppFeedbackCardVisible` by updating the app last feedback date.
///
func onInAppFeedbackCardAction() {
let action = AppSettingsAction.updateFeedbackStatus(type: .general, status: .given(Date())) { [weak self] result in
guard let self = self else {
return
}

if let error = result.failure {
ServiceLocator.crashLogging.logError(error)
}

self.refreshIsInAppFeedbackCardVisibleValue()
}
stores.dispatch(action)
}

/// Calculates and updates the value of `isInAppFeedbackCardVisible`.
func refreshIsInAppFeedbackCardVisibleValue() {
let action = AppSettingsAction.loadFeedbackVisibility(type: .general) { [weak self] result in
guard let self = self else {
return
}

switch result {
case .success(let shouldBeVisible):
self.sendIsInAppFeedbackCardVisibleValueAndTrackIfNeeded(shouldBeVisible)
case .failure(let error):
ServiceLocator.crashLogging.logError(error)
// We'll just send a `false` value. I think this is the safer bet.
self.sendIsInAppFeedbackCardVisibleValueAndTrackIfNeeded(false)
}
}
stores.dispatch(action)
}

/// Updates the value of `isInAppFeedbackCardVisible` and tracks a "shown" event
/// if the value changed from `false` to `true`.
func sendIsInAppFeedbackCardVisibleValueAndTrackIfNeeded(_ newValue: Bool) {
let trackEvent = isInAppFeedbackCardVisible == false && newValue == true

isInAppFeedbackCardVisible = newValue
if trackEvent {
analytics.track(event: .appFeedbackPrompt(action: .shown))
}
}
}

// MARK: Theme install
//
private extension DashboardViewModel {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import SwiftUI

struct InAppFeedbackCardView: View {
private let viewModel: InAppFeedbackCardViewModel

init(viewModel: InAppFeedbackCardViewModel) {
self.viewModel = viewModel
}

var body: some View {
VStack(spacing: Layout.padding) {
Text(Localization.title)
.headlineStyle()
.multilineTextAlignment(.center)
.padding(.top, Layout.padding)

HStack(spacing: Layout.padding) {
Button(Localization.couldBeBetter) {
viewModel.didTapCouldBeBetter()
}
.buttonStyle(SecondaryButtonStyle())

Button(Localization.iLikeIt) {
viewModel.didTapILikeIt()
}
.buttonStyle(PrimaryButtonStyle())
}
.padding(Layout.padding)
}
.overlay(
RoundedRectangle(cornerRadius: Layout.cornerRadius)
.stroke(Color(.border), lineWidth: 1)
)
}
}

private extension InAppFeedbackCardView {
enum Layout {
static let padding: CGFloat = 16
static let cornerRadius: CGFloat = 8
}

enum Localization {
static let title = NSLocalizedString(
"inAppFeedbackCardView.title",
value: "Enjoying the WooCommerce app?",
comment: "The title used when asking the user for feedback for the app."
)
static let couldBeBetter = NSLocalizedString(
"inAppFeedbackCardView.couldBeBetter",
value: "Could be better",
comment: "The title of the button for giving a negative feedback for the app."
)
static let iLikeIt = NSLocalizedString(
"inAppFeedbackCardView.iLikeIt",
value: "I like it",
comment: "The title of the button for giving a positive feedback for the app."
)
}
}