diff --git a/WordPress/Classes/Extensions/SwiftUI+Backport.swift b/WordPress/Classes/Extensions/SwiftUI+Backport.swift new file mode 100644 index 000000000000..17a9802f91ab --- /dev/null +++ b/WordPress/Classes/Extensions/SwiftUI+Backport.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct Backport { + let view: T +} + +extension View { + var backport: Backport { Backport(view: self) } +} + +extension Backport { + @ViewBuilder + func refreshable(action: @Sendable @escaping () async -> Void) -> some View { + if #available(iOS 15, *) { + view.refreshable(action: action) + } else { + view + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Settings/SiteSettingsView.swift b/WordPress/Classes/ViewRelated/Blog/Settings/SiteSettingsView.swift new file mode 100644 index 000000000000..16a354487749 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Settings/SiteSettingsView.swift @@ -0,0 +1,138 @@ +import Foundation +import SwiftUI +import Combine +import SVProgressHUD + +struct SiteSettingsView: View { + @ObservedObject private var blog: Blog + @ObservedObject private var settings: BlogSettings + + @StateObject private var viewModel: SiteSettingsViewModel + + @SwiftUI.Environment(\.presentationMode) private var presentationMode + + init(blog: Blog) { + self.blog = blog + self.settings = blog.settings ?? BlogSettings(context: ContextManager.shared.mainContext) // Right-side should never happen + self._viewModel = StateObject(wrappedValue: SiteSettingsViewModel(blog: blog)) + } + + var body: some View { + List { + sections + } + .listStyle(.insetGrouped) + .onReceive(viewModel.onDismissableError) { + SVProgressHUD.showDismissibleError(withStatus: $0) + } + .backport.refreshable { + await viewModel.refresh() + } + .onAppear { + Task { await viewModel.refresh() } + } + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + } + + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + if presentationMode.wrappedValue.isPresented { + closeButton + } + } + } + + private var sections: some View { + Section(header: Text(Strings.Sections.general)) { + siteTitleRow + } + } + + // MARK: - General + + private var siteTitleRow: some View { + withAdminNavigationLink(destination: { + SettingsTextEditView( + value: settings.name, + placeholder: Strings.General.siteTitlePlaceholder, + onCommit: viewModel.updateSiteTitle + ) + .navigationTitle(Strings.General.siteTitle) + }) { + SettingsCell(title: Strings.General.siteTitle, value: settings.name ?? Strings.General.siteTitlePlaceholder) + } + } + + // MARK: - Helpers + + @ViewBuilder + private func withAdminNavigationLink( + @ViewBuilder destination: () -> T, + @ViewBuilder content: () -> U + ) -> some View { + if blog.isAdmin { + NavigationLink(destination: destination(), label: content) + } else { + content() + } + } + + private var closeButton: some View { + Button(action: { presentationMode.wrappedValue.dismiss() }) { + Text(Strings.done) + .font(.body.weight(.medium)) + .foregroundColor(Color.primary) + } + } +} + +private struct SettingsCell: View { + let title: String + let value: String + + var body: some View { + HStack { + Text(title) + .layoutPriority(1) + Spacer() + Text(value) + .foregroundColor(.secondary) + } + .lineLimit(1) + } +} + +private struct SettingsTextEditView: UIViewControllerRepresentable { + let value: String? + let placeholder: String + var hint: String? + let onCommit: ((String)) -> Void + + func makeUIViewController(context: Context) -> SettingsTextViewController { + let viewController = SettingsTextViewController(text: value ?? "", placeholder: placeholder, hint: hint ?? "") + viewController.onValueChanged = onCommit + return viewController + } + + func updateUIViewController(_ uiViewController: SettingsTextViewController, context: Context) { + // Do nothing + } +} + +private extension SiteSettingsView { + enum Strings { + static let title = NSLocalizedString("siteSettings.title", value: "Settings", comment: "Title for screen that allows configuration of your blog/site settings.") + static let done = NSLocalizedString("siteSettings.done", value: "Done", comment: "Label for Done button") + + enum Sections { + static let general = NSLocalizedString("siteSettings.general.title", value: "General", comment: "Title for the general section in site settings screen") + } + + enum General { + static let siteTitle = NSLocalizedString("siteSettings.general.siteTitle", value: "Site Title", comment: "Label for site title blog setting") + static let siteTitlePlaceholder = NSLocalizedString("siteSettings.general.siteTitlePlaceholder", value: "A title for the site", comment: "Placeholder text for the title of a site") + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Settings/SiteSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Settings/SiteSettingsViewModel.swift new file mode 100644 index 000000000000..849ccc9f3109 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Settings/SiteSettingsViewModel.swift @@ -0,0 +1,53 @@ +import Foundation +import SwiftUI +import Combine + +final class SiteSettingsViewModel: ObservableObject { + private let blog: Blog + private let service: BlogService + + let onDismissableError = PassthroughSubject() + + init(blog: Blog, + service: BlogService = BlogService(coreDataStack: ContextManager.shared)) { + self.blog = blog + self.service = service + } + + func refresh() async -> Void { + await withUnsafeContinuation { continuation in + service.syncSettings(for: blog, success: { + continuation.resume() + }, failure: { error in + continuation.resume() + DDLogError("Error while refreshing blog settings: \(error)") + }) + } + } + + func updateSiteTitle(_ value: String) { + guard value != blog.settings?.name else { return } + blog.settings?.name = value + save() + trackSettingsChange(fieldName: "site_title") + } + + private func save() { + service.updateSettings(for: blog, success: { + NotificationCenter.default.post(name: .WPBlogSettingsUpdated, object: nil) + }, failure: { [weak self] error in + self?.onDismissableError.send(Strings.saveFailed) + DDLogError("Error while trying to update BlogSettings: \(error)") + }) + } + + private func trackSettingsChange(fieldName: String, value: Any? = nil) { + WPAnalytics.trackSettingsChange("site_settings", fieldName: fieldName, value: value) + } +} + +private extension SiteSettingsViewModel { + enum Strings { + static let saveFailed = NSLocalizedString("siteSettings.updateFailedMessage", value: "Settings update failed", comment: "Message to show when setting save failed") + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index fc18f3e753eb..f34afee9b744 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -346,6 +346,12 @@ 0C391E612A3002950040EA91 /* DashboardBlazeCampaignStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391E602A3002950040EA91 /* DashboardBlazeCampaignStatusView.swift */; }; 0C391E622A3002950040EA91 /* DashboardBlazeCampaignStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391E602A3002950040EA91 /* DashboardBlazeCampaignStatusView.swift */; }; 0C391E642A312DB20040EA91 /* DashboardBlazeCampaignViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391E632A312DB20040EA91 /* DashboardBlazeCampaignViewModelTests.swift */; }; + 0C60DF792A377C3800509C91 /* SiteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60DF782A377C3800509C91 /* SiteSettingsView.swift */; }; + 0C60DF7A2A377C3800509C91 /* SiteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60DF782A377C3800509C91 /* SiteSettingsView.swift */; }; + 0C60DF7E2A37C90200509C91 /* SiteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60DF7D2A37C90200509C91 /* SiteSettingsViewModel.swift */; }; + 0C60DF7F2A37C90200509C91 /* SiteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60DF7D2A37C90200509C91 /* SiteSettingsViewModel.swift */; }; + 0C60DF812A37CFED00509C91 /* SwiftUI+Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60DF802A37CFED00509C91 /* SwiftUI+Backport.swift */; }; + 0C60DF822A37CFED00509C91 /* SwiftUI+Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60DF802A37CFED00509C91 /* SwiftUI+Backport.swift */; }; 0CB4056B29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; 0CB4056C29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; 0CB4056E29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */; }; @@ -6054,6 +6060,9 @@ 0C391E5D2A2FE5350040EA91 /* DashboardBlazeCampaignView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCampaignView.swift; sourceTree = ""; }; 0C391E602A3002950040EA91 /* DashboardBlazeCampaignStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCampaignStatusView.swift; sourceTree = ""; }; 0C391E632A312DB20040EA91 /* DashboardBlazeCampaignViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCampaignViewModelTests.swift; sourceTree = ""; }; + 0C60DF782A377C3800509C91 /* SiteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSettingsView.swift; sourceTree = ""; }; + 0C60DF7D2A37C90200509C91 /* SiteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSettingsViewModel.swift; sourceTree = ""; }; + 0C60DF802A37CFED00509C91 /* SwiftUI+Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+Backport.swift"; sourceTree = ""; }; 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationService.swift; sourceTree = ""; }; 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationServiceTests.swift; sourceTree = ""; }; 0CB4057029C8DCF4008EED0A /* BlogDashboardPersonalizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationViewModel.swift; sourceTree = ""; }; @@ -10025,6 +10034,15 @@ path = Mocks; sourceTree = ""; }; + 0C60DF7C2A37C8E800509C91 /* Settings */ = { + isa = PBXGroup; + children = ( + 0C60DF782A377C3800509C91 /* SiteSettingsView.swift */, + 0C60DF7D2A37C90200509C91 /* SiteSettingsViewModel.swift */, + ); + path = Settings; + sourceTree = ""; + }; 0CB4056F29C8DCD7008EED0A /* BlogPersonalization */ = { isa = PBXGroup; children = ( @@ -14755,6 +14773,7 @@ FA5C74091C596E69000B528C /* Site Management */, FA73D7E72798766300DF24B3 /* Site Picker */, 3F43603823F36A76001DEE70 /* Site Settings */, + 0C60DF7C2A37C8E800509C91 /* Settings */, BE6AB7FB1BC62E0B00D980FC /* Style */, ); path = Blog; @@ -15225,6 +15244,7 @@ C7AFF873283C0ADC000E01DF /* UIApplication+Helpers.swift */, C3AB4878292F114A001F7AF8 /* UIApplication+AppAvailability.swift */, F49D7BEA29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift */, + 0C60DF802A37CFED00509C91 /* SwiftUI+Backport.swift */, ); path = Extensions; sourceTree = ""; @@ -21356,6 +21376,7 @@ E64384831C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift in Sources */, F504D2B025D60C5900A2764C /* StoryPoster.swift in Sources */, 981C82B62193A7B900A06E84 /* Double+Stats.swift in Sources */, + 0C60DF792A377C3800509C91 /* SiteSettingsView.swift in Sources */, 175507B327A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */, 177074851FB209F100951A4A /* CircularProgressView.swift in Sources */, 3F8B45A9292C1F2C00730FA4 /* DashboardMigrationSuccessCell.swift in Sources */, @@ -21568,6 +21589,7 @@ C81CCD81243BF7A600A83E27 /* TenorService.swift in Sources */, 8B51844525893F140085488D /* FilterBarView.swift in Sources */, E105205B1F2B1CF400A948F6 /* BlogToBlogMigration_61_62.swift in Sources */, + 0C60DF7E2A37C90200509C91 /* SiteSettingsViewModel.swift in Sources */, FE3E83E526A58646008CE851 /* ListSimpleOverlayView.swift in Sources */, 98B88452261E4E09007ED7F8 /* LikeUserTableViewCell.swift in Sources */, E16FB7E31F8B61040004DD9F /* WebKitViewController.swift in Sources */, @@ -22315,6 +22337,7 @@ 176CE91627FB44C100F1E32B /* StatsBaseCell.swift in Sources */, E1C2260723901AAD0021D03C /* WordPressOrgRestApi+WordPress.swift in Sources */, B53B02B31CAC3AAC003190A0 /* GravatarPickerViewController.swift in Sources */, + 0C60DF812A37CFED00509C91 /* SwiftUI+Backport.swift in Sources */, D81879D920ABC647000CFA95 /* ReaderTableConfiguration.swift in Sources */, 3F758FD524F6FB4900BBA2FC /* AnnouncementsStore.swift in Sources */, E6D2E1691B8AAD9B0000ED14 /* ReaderListStreamHeader.swift in Sources */, @@ -23776,6 +23799,7 @@ FABB20F02602FC2C00C8785C /* ReaderDetailToolbar.swift in Sources */, 80A2154429D1177A002FE8EB /* RemoteConfigDebugViewController.swift in Sources */, FAD1263D2A0CF2F50004E24C /* String+NonbreakingSpace.swift in Sources */, + 0C60DF7A2A377C3800509C91 /* SiteSettingsView.swift in Sources */, FABB20F12602FC2C00C8785C /* RecentSitesService.swift in Sources */, FA332AD129C1F97A00182FBB /* MovedToJetpackViewController.swift in Sources */, 8BD66ED52787530C00CCD95A /* PostsCardViewModel.swift in Sources */, @@ -24310,6 +24334,7 @@ FABB226C2602FC2C00C8785C /* ParentPageSettingsViewController.swift in Sources */, FABB226D2602FC2C00C8785C /* Queue.swift in Sources */, FABB226E2602FC2C00C8785C /* SwitchTableViewCell.swift in Sources */, + 0C60DF822A37CFED00509C91 /* SwiftUI+Backport.swift in Sources */, 3FBB2D2C27FB6CB200C57BBF /* SiteNameViewController.swift in Sources */, 17171375265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift in Sources */, FABB226F2602FC2C00C8785C /* ReaderTopicToReaderSiteTopic37to38.swift in Sources */, @@ -24879,6 +24904,7 @@ FABB241A2602FC2C00C8785C /* PostToPost30To31.m in Sources */, FABB241B2602FC2C00C8785C /* GutenGhostView.swift in Sources */, FABB241C2602FC2C00C8785C /* ModelSettableCell.swift in Sources */, + 0C60DF7F2A37C90200509C91 /* SiteSettingsViewModel.swift in Sources */, FABB241D2602FC2C00C8785C /* FollowCommentsService.swift in Sources */, 4AA33F022999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift in Sources */, FABB241E2602FC2C00C8785C /* ForcePopoverPresenter.swift in Sources */,