Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Experiments/Experiments/DefaultFeatureFlagService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
return buildConfig == .localDeveloper || buildConfig == .alpha
case .productsBulkEditing:
return buildConfig == .localDeveloper || buildConfig == .alpha
case .domainSettings:
return buildConfig == .localDeveloper || buildConfig == .alpha
default:
return true
}
Expand Down
4 changes: 4 additions & 0 deletions Experiments/Experiments/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,8 @@ public enum FeatureFlag: Int {
/// Bulk editing of status and price in products list
///
case productsBulkEditing

/// Whether to enable domain updates from the settings for a WPCOM site.
///
case domainSettings
}
18 changes: 18 additions & 0 deletions Networking/Networking/Remote/DomainRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ public struct FreeDomainSuggestion: Decodable, Equatable {
}
}

/// Necessary data for a site's domain.
public struct SiteDomain: Equatable {
/// Domain name.
public let name: String

/// Whether the domain is the site's primary domain.
public let isPrimary: Bool

/// The next renewal date, if available.
public let renewalDate: Date?

public init(name: String, isPrimary: Bool, renewalDate: Date? = nil) {
self.name = name
self.isPrimary = isPrimary
self.renewalDate = renewalDate
}
}

// MARK: - Constants
//
private extension DomainRemote {
Expand Down
13 changes: 13 additions & 0 deletions Networking/Networking/Remote/PaymentRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ public struct WPComPlan: Decodable, Equatable {
}
}

/// Contains necessary data for a site's WPCOM plan.
public struct WPComSitePlan {
/// WPCOM plan of a site.
public let plan: WPComPlan
/// Whether a site has domain credit from the WPCOM plan.
public let hasDomainCredit: Bool

public init(plan: WPComPlan, hasDomainCredit: Bool) {
self.plan = plan
self.hasDomainCredit = hasDomainCredit
}
}

/// Possible error cases from loading a WPCOM plan.
public enum LoadPlanError: Error {
case noMatchingPlan
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import SwiftUI

/// Hosting controller that wraps the `DomainSettingsView` view.
final class DomainSettingsHostingController: UIHostingController<DomainSettingsView> {
init(viewModel: DomainSettingsViewModel) {
super.init(rootView: DomainSettingsView(viewModel: viewModel))
}

required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()

configureTransparentNavigationBar()
}
}

/// Shows a site's domains with actions to add a domain or redeem a domain credit.
struct DomainSettingsView: View {
@ObservedObject private var viewModel: DomainSettingsViewModel

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

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Layout.contentSpacing) {
if let freeDomain = viewModel.freeStagingDomain {
HStack {
FreeStagingDomainView(domain: freeDomain)
Spacer()
}
}

if viewModel.hasDomainCredit {
// TODO: 8558 - domain credit UI with redemption action
}

if viewModel.domains.isNotEmpty {
// TODO: 8558 - show domain list with search domain action
}
}
.padding(Layout.contentPadding)
}
.safeAreaInset(edge: .bottom) {
if viewModel.domains.isEmpty {
VStack {
Divider()
.frame(height: Layout.dividerHeight)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓Non-blocking: do we really need to set a height or the divider? I usually keep the frame as-is, as I think the default style works well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default height isn't mentioned in the official documentation 🤔 I feel like it's safer setting a height just in case the default behavior changes in future iOS versions so that there are no surprises.

.foregroundColor(Color(.separator))
Button(Localization.searchDomainButton) {
// TODO: 8558 - search domain action
}
.buttonStyle(PrimaryButtonStyle())
.padding(Layout.bottomContentPadding)
}
.background(Color(.systemBackground))
}
}
.navigationBarTitle(Localization.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
viewModel.onAppear()
}
}
}

private extension DomainSettingsView {
enum Localization {
static let title = NSLocalizedString("Domain", comment: "Navigation bar title of the domain settings screen.")
static let searchDomainButton = NSLocalizedString(
"Search for a Domain",
comment: "Title of the button on the domain settings screen to search for a domain."
)
}
}

private extension DomainSettingsView {
enum Layout {
static let dividerHeight: CGFloat = 1
static let bottomContentPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
static let contentPadding: EdgeInsets = .init(top: 39, leading: 16, bottom: 16, trailing: 16)
static let contentSpacing: CGFloat = 36
}
}

#if DEBUG

import Yosemite
import enum Networking.DotcomError

/// StoresManager that specifically handles actions for `DomainSettingsView` previews.
final class DomainSettingsViewStores: DefaultStoresManager {
private let domainsResult: Result<[SiteDomain], Error>
private let sitePlanResult: Result<WPComSitePlan, Error>

init(domainsResult: Result<[SiteDomain], Error>,
sitePlanResult: Result<WPComSitePlan, Error>) {
self.domainsResult = domainsResult
self.sitePlanResult = sitePlanResult
super.init(sessionManager: ServiceLocator.stores.sessionManager)
}

override func dispatch(_ action: Action) {
if let action = action as? DomainAction {
if case let .loadDomains(_, completion) = action {
completion(domainsResult)
}
} else if let action = action as? PaymentAction {
if case let .loadSiteCurrentPlan(_, completion) = action {
completion(sitePlanResult)
}
}
}
}

struct DomainSettingsView_Previews: PreviewProvider {
static var previews: some View {
Group {
NavigationView {
DomainSettingsView(viewModel:
.init(siteID: 134,
stores: DomainSettingsViewStores(
// There is one free domain and two paid domains.
domainsResult: .success([
.init(name: "free.test", isPrimary: true),
.init(name: "one.test", isPrimary: false, renewalDate: .distantFuture),
.init(name: "duo.test", isPrimary: true, renewalDate: .now)
]),
// The site has domain credit.
sitePlanResult: .success(.init(plan: .init(productID: 0,
name: "",
formattedPrice: ""),
hasDomainCredit: true)))))
}

NavigationView {
DomainSettingsView(viewModel:
.init(siteID: 134,
stores: DomainSettingsViewStores(
// There is one free domain and no other paid domains.
domainsResult: .success([
.init(name: "free.test", isPrimary: true)
]),
sitePlanResult: .success(.init(plan: .init(productID: 0,
name: "",
formattedPrice: ""),
hasDomainCredit: true)))))
}
}
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation
import Yosemite

/// View model for `DomainSettingsView`.
final class DomainSettingsViewModel: ObservableObject {
struct Domain {
/// Whether the domain is the site's primary domain.
let isPrimary: Bool

/// The address of the domain.
let name: String

// The next renewal date.
let autoRenewalDate: Date?
}

struct FreeStagingDomain {
/// Whether the domain is the site's primary domain.
let isPrimary: Bool

/// The address of the domain.
let name: String
}

@Published private(set) var hasDomainCredit: Bool = false
@Published private(set) var domains: [Domain] = []
@Published private(set) var freeStagingDomain: FreeStagingDomain?

private let siteID: Int64
private let stores: StoresManager

init(siteID: Int64, stores: StoresManager = ServiceLocator.stores) {
self.siteID = siteID
self.stores = stores
}

func onAppear() {
stores.dispatch(DomainAction.loadDomains(siteID: siteID) { [weak self] result in
self?.handleDomainsResult(result)
})

stores.dispatch(PaymentAction.loadSiteCurrentPlan(siteID: siteID) { [weak self] result in
self?.handleSiteCurrentPlanResult(result)
})
}
}

private extension DomainSettingsViewModel {
func handleDomainsResult(_ result: Result<[SiteDomain], Error>) {
switch result {
case .success(let domains):
let stagingDomain = domains.first(where: { $0.renewalDate == nil })
freeStagingDomain = stagingDomain
.map { FreeStagingDomain(isPrimary: $0.isPrimary, name: $0.name) }
self.domains = domains.filter { $0 != stagingDomain }
.map { Domain(isPrimary: $0.isPrimary, name: $0.name, autoRenewalDate: $0.renewalDate) }
case .failure(let error):
DDLogError("⛔️ Error retrieving domains for siteID \(siteID): \(error)")
}
}

func handleSiteCurrentPlanResult(_ result: Result<WPComSitePlan, Error>) {
switch result {
case .success(let sitePlan):
hasDomainCredit = sitePlan.hasDomainCredit
case .failure(let error):
DDLogError("⛔️ Error retrieving site plan for siteID \(siteID): \(error)")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import SwiftUI

/// Shows a site's free staging domain, with an optional badge if the domain is the primary domain.
struct FreeStagingDomainView: View {
let domain: DomainSettingsViewModel.FreeStagingDomain

var body: some View {
VStack(alignment: .leading, spacing: Layout.contentSpacing) {
VStack(alignment: .leading, spacing: 0) {
Text(Localization.freeDomainTitle)
Text(domain.name)
.bold()
}
if domain.isPrimary {
// TODO: 8558 - refactor to reuse `BadgeView`
Text(Localization.primaryDomainNotice)
.foregroundColor(Color(.textBrand))
.padding(.leading, Layout.horizontalPadding)
.padding(.trailing, Layout.horizontalPadding)
.padding(.top, Layout.verticalPadding)
.padding(.bottom, Layout.verticalPadding)
.background(RoundedRectangle(cornerRadius: Layout.cornerRadius)
.fill(Color(.withColorStudio(.wooCommercePurple, shade: .shade0))))
.font(.system(size: 12, weight: .bold))
}
}
}
}

private extension FreeStagingDomainView {
enum Localization {
static let freeDomainTitle = NSLocalizedString(
"Your free store address",
comment: "Title of the free domain view."
)
static let primaryDomainNotice = NSLocalizedString(
"Primary site address",
comment: "Title for a free domain if the domain is the primary site address."
)
}
}

private extension FreeStagingDomainView {
enum Layout {
static let horizontalPadding: CGFloat = 6
static let verticalPadding: CGFloat = 4
static let cornerRadius: CGFloat = 8
static let contentSpacing: CGFloat = 8
}
}

struct FreeStagingDomainView_Previews: PreviewProvider {
static var previews: some View {
VStack {
FreeStagingDomainView(domain: .init(isPrimary: true, name: "go.trees"))
FreeStagingDomainView(domain: .init(isPrimary: false, name: "go.trees"))
}
}
}
Loading