diff --git a/Modules/Sources/WordPressCore/ApiCache.swift b/Modules/Sources/WordPressCore/ApiCache.swift index 856eafdaeff9..e3425c42d226 100644 --- a/Modules/Sources/WordPressCore/ApiCache.swift +++ b/Modules/Sources/WordPressCore/ApiCache.swift @@ -4,22 +4,16 @@ import WordPressAPIInternal import WordPressApiCache extension WordPressApiCache { - static func bootstrap() -> WordPressApiCache? { - let instance: WordPressApiCache? = .onDiskCache() ?? .memoryCache() - instance?.startListeningForUpdates() + static func bootstrap() -> WordPressApiCache { + let instance: WordPressApiCache = .onDiskCache() ?? .memoryCache() + instance.startListeningForUpdates() return instance } + // TODO: + // - Log errors to sentry: https://github.com/wordpress-mobile/WordPress-iOS/pull/25157#discussion_r2785458461 private static func onDiskCache() -> WordPressApiCache? { - let cacheURL: URL - do { - cacheURL = try FileManager.default - .url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - .appending(path: "app.sqlite") - } catch { - NSLog("Failed to create api cache file: \(error)") - return nil - } + let cacheURL = URL.libraryDirectory.appending(path: "app.sqlite") if let cache = WordPressApiCache.onDiskCache(file: cacheURL) { return cache @@ -56,17 +50,22 @@ extension WordPressApiCache { return nil } - return cache - } - - private static func memoryCache() -> WordPressApiCache? { do { - let cache = try WordPressApiCache() - _ = try cache.performMigrations() - return cache + var url = file + var values = URLResourceValues() + values.isExcludedFromBackup = true + try url.setResourceValues(values) } catch { - NSLog("Failed to create memory cache: \(error)") - return nil + NSLog("Failed exclude the database file from iCloud backup: \(error)") } + + return cache + } + + private static func memoryCache() -> WordPressApiCache { + // Creating an in-memory database should always succeed. + let cache = try! WordPressApiCache() + _ = try! cache.performMigrations() + return cache } } diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 51fd53ed638e..69bdb56d5bb3 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -80,26 +80,26 @@ public actor WordPressClient { public let api: any WordPressClientAPI private var _cache: WordPressApiCache? - public var cache: WordPressApiCache? { + public var cache: WordPressApiCache { get { - if _cache == nil { - _cache = WordPressApiCache.bootstrap() + if let _cache { + return _cache } - return _cache + let cache = WordPressApiCache.bootstrap() + _cache = cache + return cache } } private var _service: WpSelfHostedService? - public var service: WpSelfHostedService? { - get { - if _service == nil, let cache { - do { - _service = try api.createSelfHostedService(cache: cache) - } catch { - NSLog("Failed to create service: \(error)") - } + public var service: WpSelfHostedService { + get throws { + if let _service { + return _service } - return _service + let service = try api.createSelfHostedService(cache: cache) + _service = service + return service } } diff --git a/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift b/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift index d105a5b3aed3..296f8e7e29e9 100644 --- a/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift +++ b/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift @@ -38,13 +38,13 @@ public class AdaptiveTabBar: UIControl { // MARK: - Properties - var items: [any AdaptiveTabBarItem] = [] { + public var items: [any AdaptiveTabBarItem] = [] { didSet { refreshTabs() } } private var buttons: [TabButton] = [] - private(set) var selectedIndex: Int = 0 { + public private(set) var selectedIndex: Int = 0 { didSet { buttons[oldValue].isSelected = false buttons[selectedIndex].isSelected = true @@ -58,7 +58,7 @@ public class AdaptiveTabBar: UIControl { private var indicatorCenterXConstraint: NSLayoutConstraint? private var previousWidth: CGFloat? - public let tabBarHeight: CGFloat = 40 + public static let tabBarHeight: CGFloat = 40 // MARK: - Initialization @@ -80,7 +80,7 @@ public class AdaptiveTabBar: UIControl { stackView.pinEdges() NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: tabBarHeight), + heightAnchor.constraint(equalToConstant: AdaptiveTabBar.tabBarHeight), stackView.heightAnchor.constraint(equalTo: heightAnchor) ]) @@ -161,7 +161,7 @@ public class AdaptiveTabBar: UIControl { // Calculate preferred width for each button let preferredWidths = buttons.map { - $0.systemLayoutSizeFitting(CGSize(width: UIView.noIntrinsicMetric, height: tabBarHeight)).width + $0.systemLayoutSizeFitting(CGSize(width: UIView.noIntrinsicMetric, height: AdaptiveTabBar.tabBarHeight)).width } let maxWidth = preferredWidths.max() ?? 0 @@ -188,7 +188,7 @@ public class AdaptiveTabBar: UIControl { // MARK: - Selection - func setSelectedIndex(_ index: Int, animated: Bool = true) { + public func setSelectedIndex(_ index: Int, animated: Bool = true) { guard items.indices.contains(index) else { return } UIView.performWithoutAnimation { diff --git a/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift b/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift index 3f0750f2749a..52e267b7fac9 100644 --- a/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift +++ b/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift @@ -53,7 +53,7 @@ public final class AdaptiveTabBarController { private func setupFilterBar() { // filterBarContainer.backgroundColor = .systemGroupedBackground // .secondarySystemGroupedBackground filterBarContainer.contentView.addSubview(filterBar) - filterBar.pinEdges(.top, to: filterBarContainer.safeAreaLayoutGuide, insets: UIEdgeInsets(.top, -filterBar.tabBarHeight)) + filterBar.pinEdges(.top, to: filterBarContainer.safeAreaLayoutGuide, insets: UIEdgeInsets(.top, -AdaptiveTabBar.tabBarHeight)) filterBar.pinEdges([.horizontal, .bottom]) filterBar.addTarget(self, action: #selector(selectedFilterDidChange), for: .valueChanged) @@ -98,7 +98,7 @@ public final class AdaptiveTabBarController { viewController.navigationItem.titleView = nil viewController.view.addSubview(filterBarContainer) filterBarContainer.pinEdges([.top, .horizontal]) - viewController.additionalSafeAreaInsets = UIEdgeInsets(.top, filterBar.tabBarHeight) + viewController.additionalSafeAreaInsets = UIEdgeInsets(.top, AdaptiveTabBar.tabBarHeight) } } diff --git a/WordPress/Classes/Utility/SiteStorage.swift b/WordPress/Classes/Utility/SiteStorage.swift new file mode 100644 index 000000000000..af71549a3b8d --- /dev/null +++ b/WordPress/Classes/Utility/SiteStorage.swift @@ -0,0 +1,99 @@ +import SwiftUI +import WordPressData + +@propertyWrapper +struct SiteStorage: DynamicProperty { + @AppStorage private var data: Data + private let defaultValue: Value + + var wrappedValue: Value { + get { + (try? JSONDecoder().decode(Value.self, from: data)) ?? defaultValue + } + nonmutating set { + data = (try? JSONEncoder().encode(newValue)) ?? Data() + } + } + + var projectedValue: Binding { + Binding(get: { wrappedValue }, set: { wrappedValue = $0 }) + } + + init(wrappedValue: Value, _ key: String, blog: TaggedManagedObjectID, + store: UserDefaults? = nil) { + self.defaultValue = wrappedValue + let scopedKey = SiteStorageAccess.scopedKey(key, blog: blog) + _data = AppStorage(wrappedValue: Data(), scopedKey, store: store) + } + + fileprivate init(wrappedValue: Value, _ key: String, scope: String, + store: UserDefaults? = nil) { + self.defaultValue = wrappedValue + let scopedKey = SiteStorageAccess.scopedKey(key, scope: scope) + _data = AppStorage(wrappedValue: Data(), scopedKey, store: store) + } +} + +enum SiteStorageAccess { + static func read(_ type: T.Type, key: String, blog: TaggedManagedObjectID) -> T? { + let scopedKey = scopedKey(key, blog: blog) + guard let data = UserDefaults.standard.data(forKey: scopedKey) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + static func write(_ value: T, key: String, blog: TaggedManagedObjectID) { + let scopedKey = scopedKey(key, blog: blog) + let data = (try? JSONEncoder().encode(value)) ?? Data() + UserDefaults.standard.set(data, forKey: scopedKey) + } + + static func exists(key: String, blog: TaggedManagedObjectID) -> Bool { + let scopedKey = scopedKey(key, blog: blog) + return UserDefaults.standard.object(forKey: scopedKey) != nil + } + + fileprivate static var prefix: String { "site-storage" } + fileprivate static var separator: String { "|" } + + fileprivate static func scopedKey( + _ key: String, + blog: TaggedManagedObjectID + ) -> String { + [prefix, blog.objectID.uriRepresentation().absoluteString, key] + .joined(separator: separator) + } + + fileprivate static func scopedKey( + _ key: String, + scope: String + ) -> String { + [prefix, scope, key] + .joined(separator: separator) + } +} + +#if DEBUG + +private struct SiteStoragePreviewContent: View { + @SiteStorage("counter", scope: "tests") private var counter = 0 + + var body: some View { + VStack(spacing: 20) { + Text("Counter: \(counter)") + .font(.headline) + Button("Increment") { + let key = SiteStorageAccess.scopedKey("counter", scope: "tests") + + let newValue = counter + 1 + let encoded = (try? JSONEncoder().encode(newValue)) ?? Data() + UserDefaults.standard.set(encoded, forKey: key) + } + } + } +} + +#Preview { + SiteStoragePreviewContent() +} + +#endif diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift index f8c9d9ec5349..c57ba48e268f 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -589,13 +589,17 @@ private extension BlogDetailsTableViewModel { rows.append(Row.pages(viewController: viewController)) } + rows.append(Row.media(viewController: viewController)) + rows.append(Row.comments(viewController: viewController)) + if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { + let pinned = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)) + for type in pinned { + rows.append(Row.pinnedPostType(type, viewController: viewController)) + } rows.append(Row.customPostTypes(viewController: viewController)) } - rows.append(Row.media(viewController: viewController)) - rows.append(Row.comments(viewController: viewController)) - let title = isSplitViewDisplayed ? nil : Strings.contentSectionTitle return Section(title: title, rows: rows, category: .content) } @@ -668,12 +672,16 @@ private extension BlogDetailsTableViewModel { rows.append(Row.pages(viewController: viewController)) } - if blog.isSelfHosted { + rows.append(Row.comments(viewController: viewController)) + + if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { + let pinned = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)) + for type in pinned { + rows.append(Row.pinnedPostType(type, viewController: viewController)) + } rows.append(Row.customPostTypes(viewController: viewController)) } - rows.append(Row.comments(viewController: viewController)) - let title = Strings.publishSection return Section(title: title, rows: rows, category: .content) } @@ -958,6 +966,7 @@ enum BlogDetailsRowKind { case viewSite case admin case siteSettings + case pinnedPostType case removeSite } @@ -1047,14 +1056,25 @@ extension Row { static func customPostTypes(viewController: BlogDetailsViewController?) -> Row { Row( kind: .customPostTypes, - title: "Custom Post Types", - image: UIImage(systemName: "square.3.layers.3d"), + title: CustomPostTypesView.title, + image: UIImage(systemName: "ellipsis"), action: { [weak viewController] _ in viewController?.showCustomPostTypes() } ) } + static func pinnedPostType(_ type: PinnedPostType, viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .pinnedPostType, + title: type.name, + image: UIImage(dashicon: type.icon), + action: { [weak viewController] _ in + viewController?.showPinnedPostType(type) + } + ) + } + static func media(viewController: BlogDetailsViewController?) -> Row { Row( kind: .media, diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 373e5d653787..5c4cc94ab79a 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -87,30 +87,57 @@ extension BlogDetailsViewController { } public func showCustomPostTypes() { - Task { - guard let client = try? WordPressClientFactory.shared.instance(for: .init(blog: blog)), - let service = await client.service - else { - return + let feature = NSLocalizedString( + "applicationPasswordRequired.feature.customPosts", + value: "Custom Post Types", + comment: "Feature name for managing custom post types in the app" + ) + let rootView = ApplicationPasswordRequiredView( + blog: blog, + localizedFeatureName: feature, + presentingViewController: self) { [blog] client in + CustomPostTypesView(blog: blog, service: CustomPostTypeService(client: client, blog: blog)) } + let controller = UIHostingController(rootView: rootView) + controller.navigationItem.largeTitleDisplayMode = .never + presentationDelegate?.presentBlogDetailsViewController(controller) + } - let feature = NSLocalizedString( - "applicationPasswordRequired.feature.customPosts", - value: "Custom Post Types", - comment: "Feature name for managing custom post types in the app" - ) - let rootView = ApplicationPasswordRequiredView( - blog: blog, - localizedFeatureName: feature, - presentingViewController: self) { [blog] client in - CustomPostTypesView(client: client, service: service, blog: blog) + func syncPostTypes() { + guard let service = CustomPostTypeService(blog: blog) else { return } + Task { @MainActor [weak self] in + do { + try await service.refresh() + + if let self { + configureTableViewData() + reloadTableViewPreservingSelection() } - let controller = UIHostingController(rootView: rootView) - controller.navigationItem.largeTitleDisplayMode = .never - presentationDelegate?.presentBlogDetailsViewController(controller) + } catch { + DDLogError("Failed to sync post types: \(error)") + } } } + func showPinnedPostType(_ postType: PinnedPostType) { + guard let site = try? WordPressSite(blog: blog), case .selfHosted = site else { return } + + let feature = NSLocalizedString( + "applicationPasswordRequired.feature.customPosts", + value: "Custom Post Types", + comment: "Feature name for managing custom post types in the app" + ) + let rootView = ApplicationPasswordRequiredView( + blog: blog, + localizedFeatureName: feature, + presentingViewController: self) { [blog] client in + PinnedPostTypeView(blog: blog, service: CustomPostTypeService(client: client, blog: blog), postType: postType) + } + let controller = UIHostingController(rootView: rootView) + controller.navigationItem.largeTitleDisplayMode = .never + presentationDelegate?.presentBlogDetailsViewController(controller) + } + public func showMediaLibrary(from source: BlogDetailsNavigationSource) { showMediaLibrary(from: source, showPicker: false) } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift index c6d4c953a9b7..130c151b5f1a 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift @@ -80,6 +80,7 @@ public class BlogDetailsViewController: UIViewController { observeManagedObjectContextObjectsDidChangeNotification() observeGravatarImageUpdate() downloadGravatarImage() + syncPostTypes() registerForTraitChanges([UITraitHorizontalSizeClass.self], action: #selector(handleTraitChanges)) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 80f56408090f..7bb9c9e96355 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -8,16 +8,41 @@ import WordPressUI /// Displays a paginated list of custom posts. /// /// Used to show posts filtered by status or search results. -struct CustomPostListView: View { +struct CustomPostListView: View { @ObservedObject var viewModel: CustomPostListViewModel let details: PostTypeDetailsWithEditContext let onSelectPost: (AnyPostWithEditContext) -> Void + @ViewBuilder let header: () -> Header + + init( + viewModel: CustomPostListViewModel, + details: PostTypeDetailsWithEditContext, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void + ) where Header == EmptyView { + self.viewModel = viewModel + self.details = details + self.onSelectPost = onSelectPost + self.header = { EmptyView() } + } + + init( + viewModel: CustomPostListViewModel, + details: PostTypeDetailsWithEditContext, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void, + @ViewBuilder header: @escaping () -> Header + ) { + self.viewModel = viewModel + self.details = details + self.onSelectPost = onSelectPost + self.header = header + } var body: some View { PaginatedList( items: viewModel.items, onLoadNextPage: { try await viewModel.loadNextPage() }, - onSelectPost: onSelectPost + onSelectPost: onSelectPost, + header: header ) .overlay { if viewModel.shouldDisplayEmptyView { @@ -27,6 +52,8 @@ struct CustomPostListView: View { EmptyStateView(emptyText, systemImage: "doc.text") } else if viewModel.shouldDisplayInitialLoading { ProgressView() + } else if let error = viewModel.errorToDisplay() { + EmptyStateView.failure(error: error) } } .refreshable { @@ -41,16 +68,47 @@ struct CustomPostListView: View { } } -private struct PaginatedList: View { +private struct PaginatedList: View { let items: [CustomPostCollectionItem] let onLoadNextPage: () async throws -> Void let onSelectPost: (AnyPostWithEditContext) -> Void + @ViewBuilder let header: () -> Header @State var isLoadingMore = false @State var loadMoreError: Error? + init( + items: [CustomPostCollectionItem], + onLoadNextPage: @escaping () async throws -> Void, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void + ) where Header == EmptyView { + self.items = items + self.onLoadNextPage = onLoadNextPage + self.onSelectPost = onSelectPost + self.header = { EmptyView() } + } + + init( + items: [CustomPostCollectionItem], + onLoadNextPage: @escaping () async throws -> Void, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void, + @ViewBuilder header: @escaping () -> Header + ) { + self.items = items + self.onLoadNextPage = onLoadNextPage + self.onSelectPost = onSelectPost + self.header = header + } + var body: some View { List { + Section { + header() + .listRowInsets(.zero) + } + .listSectionSpacing(0) + .listSectionSeparator(.hidden) + ForEach(items) { item in ForEachContent(item: item, onSelectPost: onSelectPost) .task { @@ -58,7 +116,10 @@ private struct PaginatedList: View { } } - makeFooterView() + Section { + makeFooterView() + } + .listSectionSeparator(.hidden) } .listStyle(.plain) } @@ -80,6 +141,7 @@ private struct PaginatedList: View { do { try await onLoadNextPage() } catch { + DDLogError("Failed to load next page: \(error)") self.loadMoreError = error } } @@ -91,15 +153,16 @@ private struct PaginatedList: View { .progressViewStyle(.circular) .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) .id(UUID()) // A hack to show the ProgressView after cell reusing. - } else if loadMoreError != nil { - Button { - Task { await loadNextPage() } - } label: { - HStack { - Image(systemName: "exclamationmark.circle") - Text(SharedStrings.Button.retry) + } else if let loadMoreError { + VStack { + Text(verbatim: loadMoreError.localizedDescription) + Button { + Task { await loadNextPage() } + } label: { + Text(verbatim: SharedStrings.Button.retry) } - } + .buttonStyle(.borderedProminent) + }.frame(maxWidth: .infinity, alignment: .center) } } } @@ -291,3 +354,20 @@ private enum Strings { onSelectPost: { _ in } ) } + +#Preview("Load Next Page Error") { + PaginatedList( + items: [ + .stale( + id: 1, + post: CustomPostCollectionDisplayPost( + date: .now, + title: "Published Post", + excerpt: "This post has stale data and is being refreshed." + ) + ), + ], + onLoadNextPage: { throw CollectionError.DatabaseError(errMessage: "SQL error") }, + onSelectPost: { _ in }, + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index ee935c70e50b..7b07a665e1e6 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -7,7 +7,6 @@ import WordPressShared @MainActor final class CustomPostListViewModel: ObservableObject { - private let service: WpSelfHostedService private let client: WordPressClient private let endpoint: PostEndpointType let filter: CustomPostListFilter @@ -16,6 +15,7 @@ final class CustomPostListViewModel: ObservableObject { @Published private(set) var items: [CustomPostCollectionItem] = [] @Published private(set) var listInfo: ListInfo? + @Published private var error: Error? var shouldDisplayEmptyView: Bool { items.isEmpty && listInfo?.isSyncing == false @@ -25,6 +25,10 @@ final class CustomPostListViewModel: ObservableObject { items.isEmpty && listInfo?.isSyncing == true } + func errorToDisplay() -> Error? { + items.isEmpty ? error : nil + } + init( client: WordPressClient, service: WpSelfHostedService, @@ -32,7 +36,6 @@ final class CustomPostListViewModel: ObservableObject { filter: CustomPostListFilter ) { self.client = client - self.service = service self.endpoint = endpoint self.filter = filter @@ -49,7 +52,8 @@ final class CustomPostListViewModel: ObservableObject { do { _ = try await collection?.refresh() } catch { - DDLogError("Pull to refresh failed: \(error)") + DDLogError("Failed to refresh posts: \(error)") + self.show(error: error) } } @@ -66,9 +70,7 @@ final class CustomPostListViewModel: ObservableObject { } func handleDataChanges() async { - guard let cache = await client.cache else { return } - - let updates = cache.databaseUpdatesPublisher() + let updates = await client.cache.databaseUpdatesPublisher() .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) .values for await hook in updates { @@ -95,6 +97,15 @@ final class CustomPostListViewModel: ObservableObject { } } } + + private func show(error: Error) { + self.error = error + + if !items.isEmpty { + // Show an error notice, on top of the list content. + Notice(error: error).post() + } + } } struct CustomPostCollectionDisplayPost: Equatable { diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift deleted file mode 100644 index 93307317d86e..000000000000 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Foundation -import SwiftUI -import WordPressCore -import WordPressAPI -import WordPressAPIInternal -import WordPressApiCache -import WordPressUI -import WordPressData - -/// The top-level screen for browsing custom posts of a specific type. -/// -/// Provides search, status filtering, and navigation to the post editor. -struct CustomPostMainView: View { - let client: WordPressClient - let service: WpSelfHostedService - let endpoint: PostEndpointType - let details: PostTypeDetailsWithEditContext - let blog: Blog - - @State private var filter = CustomPostListFilter.default - @State private var searchText = "" - @State private var selectedPost: AnyPostWithEditContext? - @State private var filteredViewModel: CustomPostListViewModel - - init( - client: WordPressClient, - service: WpSelfHostedService, - endpoint: PostEndpointType, - details: PostTypeDetailsWithEditContext, - blog: Blog - ) { - self.client = client - self.service = service - self.endpoint = endpoint - self.details = details - self.blog = blog - - _filteredViewModel = State(initialValue: CustomPostListViewModel( - client: client, - service: service, - endpoint: endpoint, - filter: .default - )) - } - - private var isFiltered: Bool { - filter.status != .custom("any") - } - - var body: some View { - ZStack { - if searchText.isEmpty { - CustomPostListView( - viewModel: filteredViewModel, - details: details, - onSelectPost: { selectedPost = $0 } - ) - } else { - CustomPostSearchResultView( - client: client, - service: service, - endpoint: endpoint, - details: details, - searchText: $searchText, - onSelectPost: { selectedPost = $0 } - ) - } - } - .onChange(of: filter) { - filteredViewModel = CustomPostListViewModel( - client: client, - service: service, - endpoint: endpoint, - filter: filter - ) - } - .searchable(text: $searchText) - .fullScreenCover(item: $selectedPost) { post in - // TODO: Check if the post supports Gutenberg first? - CustomPostEditor(client: client, post: post, details: details, blog: blog) - } - .navigationTitle(details.labels.itemsList) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - filterMenu - } - } - .task { - EditorDependencyManager.shared - .prefetchDependencies( - for: blog, - postType: .init( - postType: details.slug, - restBase: details.restBase, - restNamespace: details.restNamespace - ) - ) - } - } - - private var filterMenu: some View { - Menu { - Picker("", selection: $filter.status) { - Text(Strings.filterAll).tag(PostStatus.custom("any")) - Text(Strings.filterPublished).tag(PostStatus.publish) - Text(Strings.filterDraft).tag(PostStatus.draft) - Text(Strings.filterScheduled).tag(PostStatus.future) - } - .pickerStyle(.inline) - } label: { - Image(systemName: "line.3.horizontal.decrease") - } - .foregroundStyle(isFiltered ? Color.white : .primary) - .background { - if isFiltered { - Circle() - .fill(Color.accentColor) - } - } - } -} - -private struct CustomPostSearchResultView: View { - let client: WordPressClient - let service: WpSelfHostedService - let endpoint: PostEndpointType - let details: PostTypeDetailsWithEditContext - @Binding var searchText: String - let onSelectPost: (AnyPostWithEditContext) -> Void - - @State private var finalSearchText = "" - - var body: some View { - CustomPostListView( - viewModel: CustomPostListViewModel( - client: client, - service: service, - endpoint: endpoint, - filter: CustomPostListFilter.default.with(search: finalSearchText) - ), - details: details, - onSelectPost: onSelectPost - ) - .task(id: searchText) { - do { - try await Task.sleep(for: .milliseconds(100)) - finalSearchText = searchText - } catch { - // Do nothing. - } - } - } -} - -private enum Strings { - static let sortByDateCreated = NSLocalizedString( - "postList.menu.sortByDateCreated", - value: "Sort by Date Created", - comment: "Menu item to sort posts by creation date" - ) - static let sortByDateModified = NSLocalizedString( - "postList.menu.sortByDateModified", - value: "Sort by Date Modified", - comment: "Menu item to sort posts by modification date" - ) - static let filter = NSLocalizedString( - "postList.menu.filter", - value: "Filter", - comment: "Menu item to access filter options" - ) - static let filterAll = NSLocalizedString( - "postList.menu.filter.all", - value: "All", - comment: "Filter option to show all posts" - ) - static let filterPublished = NSLocalizedString( - "postList.menu.filter.published", - value: "Published", - comment: "Filter option to show only published posts" - ) - static let filterDraft = NSLocalizedString( - "postList.menu.filter.draft", - value: "Draft", - comment: "Filter option to show only draft posts" - ) - static let filterScheduled = NSLocalizedString( - "postList.menu.filter.scheduled", - value: "Scheduled", - comment: "Filter option to show only scheduled posts" - ) -} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift new file mode 100644 index 000000000000..634466cd2860 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftUI +import WordPressAPI +import WordPressAPIInternal +import WordPressCore + +struct CustomPostSearchResultView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + @Binding var searchText: String + let onSelectPost: (AnyPostWithEditContext) -> Void + + @State private var finalSearchText = "" + + var body: some View { + CustomPostListView( + viewModel: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter.default.with(search: finalSearchText) + ), + details: details, + onSelectPost: onSelectPost + ) + .task(id: searchText) { + do { + try await Task.sleep(for: .milliseconds(100)) + finalSearchText = searchText + } catch { + // Do nothing. + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift new file mode 100644 index 000000000000..c620e3a836cd --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift @@ -0,0 +1,238 @@ +import Foundation +import SwiftUI +import WordPressCore +import WordPressAPI +import WordPressAPIInternal +import WordPressApiCache +import WordPressUI +import WordPressData + +struct CustomPostTabView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + let blog: Blog + + @State private var selectedTab: CustomPostTab = .published + @State private var searchText = "" + @State private var publishedViewModel: CustomPostListViewModel + @State private var draftsViewModel: CustomPostListViewModel + @State private var scheduledViewModel: CustomPostListViewModel + @State private var trashViewModel: CustomPostListViewModel + @State private var selectedPost: AnyPostWithEditContext? + @State private var isShowingFeedback = false + + private var activeViewModel: CustomPostListViewModel { + switch selectedTab { + case .published: + publishedViewModel + case .drafts: + draftsViewModel + case .scheduled: + scheduledViewModel + case .trash: + trashViewModel + } + } + + init( + client: WordPressClient, + service: WpSelfHostedService, + endpoint: PostEndpointType, + details: PostTypeDetailsWithEditContext, + blog: Blog + ) { + self.client = client + self.service = service + self.endpoint = endpoint + self.details = details + self.blog = blog + + _publishedViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .publish) + )) + _draftsViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .draft) + )) + _scheduledViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .future) + )) + _trashViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .trash) + )) + } + + var body: some View { + ZStack { + if searchText.isEmpty { + CustomPostListView( + viewModel: activeViewModel, + details: details, + onSelectPost: { selectedPost = $0 }, + header: { tabBar } + ) + } else { + CustomPostSearchResultView( + client: client, + service: service, + endpoint: endpoint, + details: details, + searchText: $searchText, + onSelectPost: { selectedPost = $0 } + ) + } + } + .searchable(text: $searchText) + .navigationTitle(details.name) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(action: { isShowingFeedback = true }) { + Label(Strings.sendFeedback, systemImage: "envelope") + } + } label: { + Image(systemName: "ellipsis") + } + } + } + .sheet(isPresented: $isShowingFeedback) { + SubmitFeedbackViewRepresentable() + } + .fullScreenCover(item: $selectedPost) { post in + CustomPostEditor(client: client, post: post, details: details, blog: blog) + } + .task { + EditorDependencyManager.shared + .prefetchDependencies( + for: blog, + postType: .init( + postType: details.slug, + restBase: details.restBase, + restNamespace: details.restNamespace + ) + ) + } + } + + private var tabBar: some View { + AdaptiveTabBarRepresentable( + items: CustomPostTab.allCases, + selectedTab: $selectedTab + ) + .frame(height: AdaptiveTabBar.tabBarHeight) + } +} + +enum CustomPostTab: Int, CaseIterable, AdaptiveTabBarItem { + case published = 0 + case drafts + case scheduled + case trash + + var id: Self { self } + + var localizedTitle: String { + switch self { + case .published: return Strings.published + case .drafts: return Strings.drafts + case .scheduled: return Strings.scheduled + case .trash: return Strings.trash + } + } + + var status: PostStatus { + switch self { + case .published: return .publish + case .drafts: return .draft + case .scheduled: return .future + case .trash: return .trash + } + } +} + +private struct AdaptiveTabBarRepresentable: UIViewRepresentable { + let items: [CustomPostTab] + @Binding var selectedTab: CustomPostTab + + func makeUIView(context: Context) -> AdaptiveTabBar { + let tabBar = AdaptiveTabBar() + tabBar.items = items + tabBar.addTarget(context.coordinator, action: #selector(Coordinator.tabChanged(_:)), for: .valueChanged) + return tabBar + } + + func updateUIView(_ uiView: AdaptiveTabBar, context: Context) { + if let index = items.firstIndex(of: selectedTab), uiView.selectedIndex != index { + uiView.setSelectedIndex(index, animated: true) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(items: items, selectedTab: $selectedTab) + } + + class Coordinator: NSObject { + let items: [CustomPostTab] + @Binding var selectedTab: CustomPostTab + + init(items: [CustomPostTab], selectedTab: Binding) { + self.items = items + _selectedTab = selectedTab + } + + @objc func tabChanged(_ tabBar: AdaptiveTabBar) { + if items.indices.contains(tabBar.selectedIndex) { + selectedTab = items[tabBar.selectedIndex] + } + } + } +} + +private struct SubmitFeedbackViewRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> SubmitFeedbackViewController { + SubmitFeedbackViewController(source: "custom_post_types", feedbackPrefix: "CustomPostTypes") + } + + func updateUIViewController(_ uiViewController: SubmitFeedbackViewController, context: Context) {} +} + +private enum Strings { + static let published = NSLocalizedString( + "customPostTab.published", + value: "Published", + comment: "Tab title for published posts" + ) + static let drafts = NSLocalizedString( + "customPostTab.drafts", + value: "Drafts", + comment: "Tab title for draft posts" + ) + static let scheduled = NSLocalizedString( + "customPostTab.scheduled", + value: "Scheduled", + comment: "Tab title for scheduled posts" + ) + static let trash = NSLocalizedString( + "customPostTab.trash", + value: "Trash", + comment: "Tab title for trashed posts" + ) + static let sendFeedback = NSLocalizedString( + "customPostTab.sendFeedback", + value: "Send Feedback", + comment: "Menu item title for sending feedback on the custom post types screen" + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypeService.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypeService.swift new file mode 100644 index 000000000000..804acfee15ad --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypeService.swift @@ -0,0 +1,85 @@ +import Foundation +import WordPressCore +import WordPressData +import WordPressAPI +import WordPressAPIInternal + +class CustomPostTypeService { + let blog: TaggedManagedObjectID + let client: WordPressClient + + private(set) var wpService: WpSelfHostedService? + private var collection: PostTypeCollectionWithEditContext? + + init(client: WordPressClient, blog: Blog) { + self.client = client + self.blog = TaggedManagedObjectID(blog) + } + + init?(blog: Blog) { + guard FeatureFlag.customPostTypes.enabled, + let site = try? WordPressSite(blog: blog), + case .selfHosted = site else { return nil } + self.blog = TaggedManagedObjectID(blog) + self.client = WordPressClientFactory.shared.instance(for: site) + } + + func refresh() async throws { + let service = try await resolveService() + _ = try await service.postTypes().syncPostTypes() + + // If the user has not manually pinned any post types (typically right after first fetching post types), + // we automatically pin 3 post types, so that the user can see their content straight from the top-level screens. + if !SiteStorageAccess.pinnedPostTypesUpdated(for: blog) { + let pinned = try await customTypes() + .prefix(3) + .map { PinnedPostType(slug: $0.slug, name: $0.name, icon: $0.icon) } + SiteStorageAccess.writePinnedPostTypes(pinned, for: blog) + } + } + + func customTypes() async throws -> [PostTypeDetailsWithEditContext] { + let collection = try await resolveCollection() + return try await collection.loadData() + .compactMap { entry -> PostTypeDetailsWithEditContext? in + let details = entry.data + if case .custom = details.toPostEndpointType(), details.slug != "attachment" { + return details + } + return nil + } + .sorted { $0.slug < $1.slug } + } + + func resolvePostType(slug: String) async throws -> PostTypeDetailsWithEditContext? { + let service = try await resolveService() + let postTypes = service.postTypes() + + if let details = postTypes.getBySlug(slug: slug) { + return details + } + + _ = try await postTypes.syncPostTypes() + + return postTypes.getBySlug(slug: slug) + } + + private func resolveService() async throws -> WpSelfHostedService { + if let wpService { + return wpService + } + let service = try await client.service + self.wpService = service + return service + } + + private func resolveCollection() async throws -> PostTypeCollectionWithEditContext { + if let collection { + return collection + } + let service = try await resolveService() + let collection = service.postTypes().createPostTypeCollectionWithEditContext() + self.collection = collection + return collection + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index ae680f2d310e..09ca6766a2b5 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -7,42 +7,50 @@ import WordPressAPIInternal import WordPressUI struct CustomPostTypesView: View { - let client: WordPressClient - let service: WpSelfHostedService - let blog: Blog + static var title: String { + Strings.title + } - let collection: PostTypeCollectionWithEditContext + let blog: Blog + let service: CustomPostTypeService - @State private var types: [(PostEndpointType, PostTypeDetailsWithEditContext)] = [] + @State private var types: [PostTypeDetailsWithEditContext] = [] @State private var isLoading: Bool = true @State private var error: Error? + @State private var isEditing = false - init(client: WordPressClient, service: WpSelfHostedService, blog: Blog) { - self.client = client - self.service = service + @SiteStorage private var pinnedTypes: [PinnedPostType] + + init(blog: Blog, service: CustomPostTypeService) { self.blog = blog - self.collection = service.postTypes().createPostTypeCollectionWithEditContext() + self.service = service + _pinnedTypes = .pinnedPostTypes(for: service.blog) } var body: some View { List { - ForEach(types, id: \.1.slug) { (type, details) in - NavigationLink { - CustomPostMainView(client: client, service: service, endpoint: type, details: details, blog: blog) - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(details.name) - - if !details.description.isEmpty { - Text(details.description) - .foregroundColor(.secondary) - } - } + ForEach(types, id: \.slug) { details in + if isEditing { + editingRow(for: details) + } else { + navigationRow(for: details) } } } .listStyle(.plain) .navigationTitle(Strings.title) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + withAnimation { + isEditing.toggle() + } + } label: { + Text(isEditing ? SharedStrings.Button.done : SharedStrings.Button.edit) + } + .disabled(types.isEmpty) + } + } .overlay { if isLoading { ProgressView() @@ -54,36 +62,70 @@ struct CustomPostTypesView: View { } } .task { - await refresh() + do { + types = try await service.customTypes() + } catch { + DDLogError("Failed to load cached post types: \(error)") + } - isLoading = self.types.isEmpty + isLoading = types.isEmpty defer { isLoading = false } do { - _ = try await self.collection.fetch() - await refresh() + try await service.refresh() + types = try await service.customTypes() } catch { - self.error = error + if types.isEmpty { + self.error = error + } else { + Notice(error: error).post() + } } } } - private func refresh() async { - do { - self.types = try await self.collection.loadData() - .compactMap { - let details = $0.data - let endpoint = details.toPostEndpointType() - if case .custom = endpoint, details.slug != "attachment" { - return (endpoint, details) - } - return nil - } - .sorted { - $0.1.slug < $1.1.slug + private func editingRow(for details: PostTypeDetailsWithEditContext) -> some View { + let isPinned = pinnedTypes.contains { $0.slug == details.slug } + return HStack { + Image(dashicon: details.icon) + .frame(width: 36) + Text(details.name) + Spacer() + Button { + togglePin(for: details) + } label: { + Image(systemName: isPinned ? "pin.fill" : "pin") + } + .foregroundStyle(isPinned ? Color.accentColor : .secondary) + .accessibilityLabel(isPinned ? Strings.unpinButton : Strings.pinButton) + } + } + + private func navigationRow(for details: PostTypeDetailsWithEditContext) -> some View { + let isPinned = pinnedTypes.contains { $0.slug == details.slug } + return NavigationLink { + if let wpService = service.wpService { + CustomPostTabView(client: service.client, service: wpService, endpoint: details.toPostEndpointType(), details: details, blog: blog) + } + } label: { + HStack { + Image(dashicon: details.icon) + .frame(width: 36) + Text(details.name) + if isPinned { + Spacer() + Image(systemName: "pin.fill") + .foregroundStyle(Color.accentColor) } - } catch { - self.error = error + } + } + } + + private func togglePin(for details: PostTypeDetailsWithEditContext) { + if let index = pinnedTypes.firstIndex(where: { $0.slug == details.slug }) { + pinnedTypes.remove(at: index) + } else { + pinnedTypes.append(PinnedPostType(slug: details.slug, name: details.name, icon: details.icon)) } } } @@ -91,7 +133,7 @@ struct CustomPostTypesView: View { private enum Strings { static let title = NSLocalizedString( "customPostTypes.title", - value: "Custom Post Types", + value: "More", comment: "Title for the Custom Post Types screen" ) @@ -100,4 +142,16 @@ private enum Strings { value: "No Custom Post Types", comment: "Empty state message when there are no custom post types to display" ) + + static let pinButton = NSLocalizedString( + "customPostTypes.pin.accessibilityLabel", + value: "Pin", + comment: "Accessibility label for the button to pin a custom post type" + ) + + static let unpinButton = NSLocalizedString( + "customPostTypes.unpin.accessibilityLabel", + value: "Unpin", + comment: "Accessibility label for the button to unpin a custom post type" + ) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/Icons.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/Icons.swift new file mode 100644 index 000000000000..e6f6bf4779d2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/Icons.swift @@ -0,0 +1,115 @@ +import SwiftUI + +extension Image { + init(dashicon: String?) { + self.init(systemName: systemName(forDashicon: dashicon)) + } +} + +extension UIImage { + convenience init?(dashicon: String?) { + self.init(systemName: systemName(forDashicon: dashicon)) + } +} + +private func systemName(forDashicon dashicon: String?) -> String { + let fallback = "doc.richtext" + guard let dashicon, dashicon.hasPrefix("dashicons-") else { + return fallback + } + + let icon = dashicon.removingPrefix("dashicons-") + return mapping[icon] ?? fallback +} + +private let mapping: [String: String] = [ + // Commerce & Products + "products": "shippingbox", + "archive": "archivebox", + "cart": "cart", + "store": "storefront", + "money": "dollarsign.circle", + "money-alt": "dollarsign.circle", + "award": "rosette", + "tickets": "ticket", + "tickets-alt": "ticket", + + // Events & Scheduling + "calendar": "calendar", + "calendar-alt": "calendar", + "clock": "clock", + "location": "mappin", + "location-alt": "mappin", + + // Creative & Portfolio + "art": "paintbrush", + "portfolio": "rectangle.stack", + "images-alt": "photo.on.rectangle.angled", + "images-alt2": "photo.on.rectangle.angled", + "format-gallery": "photo.on.rectangle.angled", + "format-image": "photo", + "format-video": "video", + "format-audio": "music.note", + "camera": "camera", + "album": "square.stack", + + // People & Business + "businessman": "person", + "businesswoman": "person", + "businessperson": "person", + "groups": "person.3", + "testimonial": "quote.bubble", + "building": "building.2", + "id": "person.text.rectangle", + "id-alt": "person.text.rectangle", + + // Education & Content + "book": "book", + "book-alt": "book", + "lightbulb": "lightbulb", + "slides": "rectangle.on.rectangle", + "clipboard": "doc.on.clipboard", + "media-document": "doc", + "text-page": "doc.text", + + // Communication + "megaphone": "megaphone", + "email": "envelope", + "email-alt": "envelope", + "phone": "phone", + "microphone": "mic", + "format-quote": "quote.bubble", + + // Miscellaneous + "food": "fork.knife", + "heart": "heart", + "star-filled": "star.fill", + "star-empty": "star", + "flag": "flag", + "tag": "tag", + "hammer": "hammer", + "car": "car", + "airplane": "airplane", + "pets": "pawprint", + "games": "gamecontroller", + "admin-home": "house", + "admin-page": "doc", +] + +#Preview { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))], spacing: 16) { + ForEach(mapping.keys.sorted(), id: \.self) { key in + VStack(spacing: 8) { + Image(systemName: mapping[key]!) + .font(.title) + Text(key) + .font(.caption) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + } + .padding() + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift new file mode 100644 index 000000000000..4ec9a85028b7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift @@ -0,0 +1,110 @@ +import Foundation +import SwiftUI +import WordPressCore +import WordPressData +import WordPressAPI +import WordPressAPIInternal +import WordPressUI + +struct PinnedPostTypeView: View { + let blog: Blog + let customPostTypeService: CustomPostTypeService + let postType: PinnedPostType + + @SiteStorage private var pinnedTypes: [PinnedPostType] + + @State private var service: WpSelfHostedService? + @State private var details: PostTypeDetailsWithEditContext? + @State private var isLoading = true + @State private var error: Error? + + init(blog: Blog, service: CustomPostTypeService, postType: PinnedPostType) { + self.blog = blog + self.customPostTypeService = service + self.postType = postType + _pinnedTypes = .pinnedPostTypes(for: TaggedManagedObjectID(blog)) + } + + var body: some View { + Group { + if let details, let wpService = customPostTypeService.wpService { + CustomPostTabView(client: customPostTypeService.client, service: wpService, endpoint: details.toPostEndpointType(), details: details, blog: blog) + } else if isLoading { + ProgressView() + .progressViewStyle(.circular) + } else if let error { + EmptyStateView.failure(error: error, onRetry: error is PostTypeNotFoundError ? nil : { retry() }) + } + } + .task { + await resolve() + } + } + + private func retry() { + error = nil + isLoading = true + Task { + await resolve() + } + } + + private func resolve() async { + defer { isLoading = false } + do { + service = try await customPostTypeService.client.service + + if let details = try await customPostTypeService.resolvePostType(slug: postType.slug) { + self.details = details + } else { + pinnedTypes.removeAll { $0.slug == postType.slug } + self.error = PostTypeNotFoundError(name: postType.name) + } + } catch { + DDLogError("Failed to resolve post type '\(postType.slug)': \(error)") + self.error = error + } + } +} + +struct PinnedPostType: Codable, Hashable { + let slug: String + let name: String + let icon: String? +} + +extension SiteStorage where Value == [PinnedPostType] { + static func pinnedPostTypes(for blog: TaggedManagedObjectID) -> Self { + SiteStorage(wrappedValue: [], "pinned-post-types", blog: blog) + } +} + +extension SiteStorageAccess { + static func pinnedPostTypes(for blog: TaggedManagedObjectID) -> [PinnedPostType] { + read([PinnedPostType].self, key: "pinned-post-types", blog: blog) ?? [] + } + + static func writePinnedPostTypes(_ value: [PinnedPostType], for blog: TaggedManagedObjectID) { + write(value, key: "pinned-post-types", blog: blog) + } + + static func pinnedPostTypesUpdated(for blog: TaggedManagedObjectID) -> Bool { + exists(key: "pinned-post-types", blog: blog) + } +} + +private struct PostTypeNotFoundError: LocalizedError { + let name: String + + var errorDescription: String? { + String.localizedStringWithFormat(Strings.notFound, name) + } +} + +private enum Strings { + static let notFound = NSLocalizedString( + "pinnedPostType.error.notFound", + value: "\"%1$@\" is not available on this site.", + comment: "Error message when a pinned custom post type cannot be found. %1$@ is the post type name." + ) +} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift index fe4c7bb68df8..8faca4f52aaa 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift @@ -230,7 +230,7 @@ private extension CustomPostEditorViewController { // Refresh post in the background. This ensures the post list is up-to-date with the new changes. Task { - try await client.service?.posts().refreshPost(postId: post.id, endpointType: endpoint) + try await client.service.posts().refreshPost(postId: post.id, endpointType: endpoint) } }