From c44a4c4d9c03b172e014443a62e929930824cb08 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 09:44:39 -0400 Subject: [PATCH 01/90] Add PaginatedResponse --- .../{ => Pagination}/LoadMoreFooterView.swift | 0 .../Views/Pagination/PaginatedResponse.swift | 104 ++++++++++++++++++ 2 files changed, 104 insertions(+) rename Modules/Sources/WordPressUI/Views/{ => Pagination}/LoadMoreFooterView.swift (100%) create mode 100644 Modules/Sources/WordPressUI/Views/Pagination/PaginatedResponse.swift diff --git a/Modules/Sources/WordPressUI/Views/LoadMoreFooterView.swift b/Modules/Sources/WordPressUI/Views/Pagination/LoadMoreFooterView.swift similarity index 100% rename from Modules/Sources/WordPressUI/Views/LoadMoreFooterView.swift rename to Modules/Sources/WordPressUI/Views/Pagination/LoadMoreFooterView.swift diff --git a/Modules/Sources/WordPressUI/Views/Pagination/PaginatedResponse.swift b/Modules/Sources/WordPressUI/Views/Pagination/PaginatedResponse.swift new file mode 100644 index 000000000000..a7dcdf7c017c --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/Pagination/PaginatedResponse.swift @@ -0,0 +1,104 @@ +import Foundation +import SwiftUI + +/// A generic paginated response handler that manages loading items in pages. +/// +/// `PaginatedResponse` handles the common pagination logic including: +/// - Loading initial and subsequent pages +/// - Managing loading states and errors +/// - Filtering duplicate items +/// - Triggering automatic loading when scrolling near the end of the list +/// +/// Example usage: +/// ```swift +/// let response = try await PaginatedResponse { page in +/// let data = try await api.fetchItems(page: page) +/// return ( +/// items: data.items, +/// total: data.totalCount, +/// hasMore: page < data.totalPages +/// ) +/// } +/// ``` +@MainActor +public final class PaginatedResponse: ObservableObject { + @Published public private(set) var total = 0 + @Published public private(set) var items: [Element] = [] + @Published public private(set) var hasMore = true + @Published public private(set) var isLoading = false + @Published public private(set) var error: Error? + + public var isEmpty: Bool { items.isEmpty } + + private var currentPage = 1 + private let _loadMore: (Int) async throws -> (items: [Element], total: Int, hasMore: Bool) + + /// Creates a new paginated response handler. + /// + /// - Parameter loadMore: A closure that loads a specific page of items. + /// - Parameter page: The page number to load (1-based). + /// - Returns: A tuple containing the items for the page, the total count, and whether more pages exist. + /// - Throws: Any error from the initial page load. + public init(loadMore: @escaping (Int) async throws -> (items: [Element], total: Int, hasMore: Bool)) async throws { + self._loadMore = loadMore + + let response = try await loadMore(currentPage) + didLoad(response) + } + + /// Loads the next page of items. + /// + /// This method will do nothing if: + /// - There are no more pages to load + /// - A page is currently being loaded + public func loadMore() { + guard hasMore && !isLoading else { + return + } + error = nil + isLoading = true + Task { + defer { isLoading = false } + do { + let response = try await _loadMore(currentPage) + didLoad(response) + } catch { + self.error = error + } + } + } + + private func didLoad(_ response: (items: [Element], total: Int, hasMore: Bool)) { + total = response.total + currentPage += 1 + hasMore = response.hasMore + + let existingIDs = Set(items.map(\.id)) + let newItems = response.items.filter { + !existingIDs.contains($0.id) + } + items += newItems + } + + /// Triggers loading more items when a row appears. + /// + /// Call this method when a row becomes visible. If the row is within the last 10 items + /// and there's no current error, it will trigger loading the next page. + /// + /// - Parameter row: The row that appeared. + public func onRowAppear(_ row: Element) { + guard items.suffix(16).contains(where: { $0.id == row.id }) else { + return + } + if error == nil { + loadMore() + } + } + + /// Removes an item with the specified ID from the loaded items. + /// + /// - Parameter id: The ID of the item to remove. + public func deleteItem(withID id: Element.ID) { + items.removeAll { $0.id == id } + } +} From 9ab1930a64b78c202a6b6f10ac2e395f9534891f Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 11:24:29 -0400 Subject: [PATCH 02/90] Refactor PaginatedResponse to DataViewPaginatedResponse - Rename PaginatedResponse to DataViewPaginatedResponse for better naming consistency - Move files from Pagination to DataView directory - Update documentation to focus on UI usage with PaginatedForEach - Add comprehensive unit tests for DataViewPaginatedResponse --- .../DataViewPaginatedResponse.swift} | 29 +- .../LoadMoreFooterView.swift | 0 .../DataViewPaginatedResponseTests.swift | 363 ++++++++++++++++++ .../Extensions/UIImage+ScaleTests.swift | 31 +- 4 files changed, 386 insertions(+), 37 deletions(-) rename Modules/Sources/WordPressUI/Views/{Pagination/PaginatedResponse.swift => DataView/DataViewPaginatedResponse.swift} (79%) rename Modules/Sources/WordPressUI/Views/{Pagination => DataView}/LoadMoreFooterView.swift (100%) create mode 100644 Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift diff --git a/Modules/Sources/WordPressUI/Views/Pagination/PaginatedResponse.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift similarity index 79% rename from Modules/Sources/WordPressUI/Views/Pagination/PaginatedResponse.swift rename to Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift index a7dcdf7c017c..6c561ef5a958 100644 --- a/Modules/Sources/WordPressUI/Views/Pagination/PaginatedResponse.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift @@ -2,26 +2,9 @@ import Foundation import SwiftUI /// A generic paginated response handler that manages loading items in pages. -/// -/// `PaginatedResponse` handles the common pagination logic including: -/// - Loading initial and subsequent pages -/// - Managing loading states and errors -/// - Filtering duplicate items -/// - Triggering automatic loading when scrolling near the end of the list -/// -/// Example usage: -/// ```swift -/// let response = try await PaginatedResponse { page in -/// let data = try await api.fetchItems(page: page) -/// return ( -/// items: data.items, -/// total: data.totalCount, -/// hasMore: page < data.totalPages -/// ) -/// } -/// ``` +/// This class is designed to be used in the UI in conjunction with `PaginatedForEach`. @MainActor -public final class PaginatedResponse: ObservableObject { +public final class DataViewPaginatedResponse: ObservableObject { @Published public private(set) var total = 0 @Published public private(set) var items: [Element] = [] @Published public private(set) var hasMore = true @@ -51,19 +34,21 @@ public final class PaginatedResponse: ObservableObject { /// This method will do nothing if: /// - There are no more pages to load /// - A page is currently being loaded - public func loadMore() { + @discardableResult + public func loadMore() -> Task? { guard hasMore && !isLoading else { - return + return nil } error = nil isLoading = true - Task { + return Task { defer { isLoading = false } do { let response = try await _loadMore(currentPage) didLoad(response) } catch { self.error = error + throw error } } } diff --git a/Modules/Sources/WordPressUI/Views/Pagination/LoadMoreFooterView.swift b/Modules/Sources/WordPressUI/Views/DataView/LoadMoreFooterView.swift similarity index 100% rename from Modules/Sources/WordPressUI/Views/Pagination/LoadMoreFooterView.swift rename to Modules/Sources/WordPressUI/Views/DataView/LoadMoreFooterView.swift diff --git a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift new file mode 100644 index 000000000000..43987dec1f56 --- /dev/null +++ b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift @@ -0,0 +1,363 @@ +import Foundation +import Testing +import WordPressUI + +@MainActor +@Suite final class DataViewPaginatedResponseTests { + struct TestItem: Identifiable, Equatable { + let id: Int + let name: String + } + + @Test func initLoadFirstPage() async throws { + // GIVEN + let expectedItems = [ + TestItem(id: 1, name: "Item 1"), + TestItem(id: 2, name: "Item 2") + ] + + // WHEN + let response = try await DataViewPaginatedResponse { page in + #expect(page == 1) + return ( + items: expectedItems, + total: 10, + hasMore: true + ) + } + + // THEN + #expect(response.items == expectedItems) + #expect(response.total == 10) + #expect(response.hasMore == true) + #expect(response.isEmpty == false) + #expect(response.isLoading == false) + #expect(response.error == nil) + } + + @Test func initThrowsError() async throws { + // GIVEN + struct TestError: Error {} + + // WHEN/THEN + await #expect(throws: TestError.self) { + _ = try await DataViewPaginatedResponse { _ in + throw TestError() + } + } + } + + @Test func loadMoreSuccessfully() async throws { + // GIVEN + var pageRequests: [Int] = [] + let response = try await DataViewPaginatedResponse { page in + pageRequests.append(page) + + switch page { + case 1: + return ( + items: [TestItem(id: 1, name: "Item 1")], + total: 3, + hasMore: true + ) + case 2: + return ( + items: [TestItem(id: 2, name: "Item 2")], + total: 3, + hasMore: true + ) + case 3: + return ( + items: [TestItem(id: 3, name: "Item 3")], + total: 3, + hasMore: false + ) + default: + fatalError("Unexpected page: \(page)") + } + } + + #expect(pageRequests == [1]) + + // WHEN loading page 2 + do { + let task = response.loadMore() + #expect(response.isLoading) + try await task?.value + } + + // THEN + #expect(response.items.count == 2) + #expect(response.items.map(\.id) == [1, 2]) + #expect(response.hasMore == true) + #expect(pageRequests == [1, 2]) + + // WHEN loading page 3 + do { + let task = response.loadMore() + #expect(response.isLoading) + try await task?.value + } + + // THEN + #expect(response.items.count == 3) + #expect(response.items.map(\.id) == [1, 2, 3]) + #expect(response.hasMore == false) + #expect(pageRequests == [1, 2, 3]) + + // WHEN trying to load more when hasMore is false + do { + let task = response.loadMore() + #expect(response.isLoading == false) + #expect(task == nil) + } + + // THEN no additional requests are made + #expect(pageRequests == [1, 2, 3]) + } + + @Test func loadMoreHandlesError() async throws { + // GIVEN + struct TestError: Error {} + var shouldThrow = false + + let response = try await DataViewPaginatedResponse { page in + if shouldThrow { + throw TestError() + } + return ( + items: [TestItem(id: page, name: "Item \(page)")], + total: 10, + hasMore: true + ) + } + + // WHEN loading successfully + do { + let task = response.loadMore() + #expect(response.isLoading) + try await task?.value + } + #expect(response.items.count == 2) + #expect(response.error == nil) + + // WHEN error occurs + shouldThrow = true + do { + let task = response.loadMore() + #expect(response.isLoading) + do { + try await task?.value + Issue.record("Expected it to fail") + } catch { + // Do nothing + } + } + + // THEN + #expect(response.error != nil) + #expect(response.error is TestError) + #expect(response.items.count == 2) // Items remain unchanged + + // WHEN retrying after error + shouldThrow = false + do { + let task = response.loadMore() + #expect(response.isLoading) + try await task?.value + } + + // THEN retry succeeds + #expect(response.error == nil) + #expect(response.items.count == 3) + } + + @Test func filtersDuplicateItems() async throws { + // GIVEN + let response = try await DataViewPaginatedResponse { page in + if page == 1 { + return ( + items: [ + TestItem(id: 1, name: "Item 1"), + TestItem(id: 2, name: "Item 2") + ], + total: 4, + hasMore: true + ) + } else { + // Page 2 includes a duplicate item + return ( + items: [ + TestItem(id: 2, name: "Item 2 Duplicate"), + TestItem(id: 3, name: "Item 3"), + TestItem(id: 4, name: "Item 4") + ], + total: 4, + hasMore: false + ) + } + } + + // WHEN + do { + let task = response.loadMore() + #expect(response.isLoading) + try await task?.value + } + + // THEN duplicates are filtered out + #expect(response.items.count == 4) + #expect(response.items.map(\.id) == [1, 2, 3, 4]) + #expect(response.items[1].name == "Item 2") // Original item is kept + } + + @Test func preventsConcurrentLoads() async throws { + // GIVEN + var loadCount = 0 + let response = try await DataViewPaginatedResponse { page in + loadCount += 1 + return ( + items: [TestItem(id: page, name: "Item \(page)")], + total: 10, + hasMore: true + ) + } + + // WHEN multiple loadMore calls are made concurrently + let task = response.loadMore() + #expect(response.loadMore() == nil) + #expect(response.loadMore() == nil) + + try await task?.value + + // THEN only one load occurs + #expect(loadCount == 2) // Counting the initial load + #expect(response.items.count == 2) + } + + @Test func onRowAppearTriggersLoad() async throws { + // GIVEN + var items: [TestItem] = [] + for i in 1...20 { + items.append(TestItem(id: i, name: "Item \(i)")) + } + + let response = try await DataViewPaginatedResponse { page in + if page == 1 { + return ( + items: Array(items.prefix(20)), + total: 30, + hasMore: true + ) + } else { + return ( + items: Array(items.suffix(10)), + total: 30, + hasMore: false + ) + } + } + + // WHEN row in the middle appears + response.onRowAppear(response.items[0]) + + // THEN no load is triggered + #expect(response.isLoading == false) + + // WHEN row in the last 16 items appears + response.onRowAppear(response.items[15]) + #expect(response.isLoading) + } + + @Test func onRowAppearDoesNotLoadWhenError() async throws { + // GIVEN + struct TestError: Error {} + var shouldThrow = false + var loadAttempts = 0 + + let response = try await DataViewPaginatedResponse { page in + loadAttempts += 1 + if shouldThrow { + throw TestError() + } + + var items: [TestItem] = [] + for i in 1...20 { + items.append(TestItem(id: i + (page - 1) * 20, name: "Item \(i)")) + } + + return ( + items: items, + total: 40, + hasMore: page < 2 + ) + } + + // WHEN error occurs on second page + shouldThrow = true + do { + let task = response.loadMore() + #expect(response.isLoading) + do { + try await task?.value + Issue.record("Expected it to fail") + } catch { + // Do nothing + } + } + + #expect(response.error != nil) + + // WHEN row appears after error + response.onRowAppear(response.items[15]) + #expect(response.isLoading == false) + + // THEN no additional load attempts are made + } + + @Test func deleteItem() async throws { + // GIVEN + let response = try await DataViewPaginatedResponse { _ in + return ( + items: [ + TestItem(id: 1, name: "Item 1"), + TestItem(id: 2, name: "Item 2"), + TestItem(id: 3, name: "Item 3") + ], + total: 3, + hasMore: false + ) + } + + #expect(response.items.count == 3) + + // WHEN + response.deleteItem(withID: 2) + + // THEN + #expect(response.items.count == 2) + #expect(response.items.map(\.id) == [1, 3]) + #expect(response.total == 3) // Total remains unchanged + } + + @Test func deleteNonExistentItem() async throws { + // GIVEN + let response = try await DataViewPaginatedResponse { _ in + return ( + items: [ + TestItem(id: 1, name: "Item 1"), + TestItem(id: 2, name: "Item 2") + ], + total: 2, + hasMore: false + ) + } + + // WHEN deleting non-existent item + response.deleteItem(withID: 999) + + // THEN nothing changes + #expect(response.items.count == 2) + #expect(response.items.map(\.id) == [1, 2]) + } +} diff --git a/Modules/Tests/WordPressUIUnitTests/Extensions/UIImage+ScaleTests.swift b/Modules/Tests/WordPressUIUnitTests/Extensions/UIImage+ScaleTests.swift index 4b410158490e..307a661086cb 100644 --- a/Modules/Tests/WordPressUIUnitTests/Extensions/UIImage+ScaleTests.swift +++ b/Modules/Tests/WordPressUIUnitTests/Extensions/UIImage+ScaleTests.swift @@ -1,49 +1,50 @@ -import XCTest +import Testing import WordPressUI -final class UIImage_ScaleTests: XCTestCase { +struct UIImageScaleTests { let originalImage = UIImage(color: .blue, size: CGSize(width: 1024, height: 768)) - func testAspectFitIntoSquare() { + @Test func aspectFitIntoSquare() { let targetSize = CGSize(width: 1000, height: 1000) let size = originalImage.dimensions(forSuggestedSize: targetSize, format: .scaleAspectFit) - XCTAssertEqual(size, CGSize(width: 1000, height: 750)) + #expect(size == CGSize(width: 1000, height: 750)) } - func testAspectFitIntoSmallerSize() { + @Test func aspectFitIntoSmallerSize() { let targetSize = CGSize(width: 101, height: 76) let size = originalImage.dimensions(forSuggestedSize: targetSize, format: .scaleAspectFit) - XCTAssertEqual(size, targetSize) + #expect(size == targetSize) } - func testAspectFitIntoLargerSize() { + @Test func aspectFitIntoLargerSize() { let targetSize = CGSize(width: 2000, height: 1000) let size = originalImage.dimensions(forSuggestedSize: targetSize, format: .scaleAspectFit) - XCTAssertEqual(size, CGSize(width: 1333, height: 1000)) + #expect(size == CGSize(width: 1333, height: 1000)) } - func testAspectFillIntoSquare() { + @Test func aspectFillIntoSquare() { let targetSize = CGSize(width: 100, height: 100) let size = originalImage.dimensions(forSuggestedSize: targetSize, format: .scaleAspectFill) - XCTAssertEqual(size, CGSize(width: 133, height: 100)) + #expect(size == CGSize(width: 133, height: 100)) } - func testAspectFillIntoSmallerSize() { + @Test func aspectFillIntoSmallerSize() { let targetSize = CGSize(width: 103, height: 77) let size = originalImage.dimensions(forSuggestedSize: targetSize, format: .scaleAspectFill) - XCTAssertEqual(size, targetSize) + #expect(size == targetSize) } - func testAspectFillIntoLargerSize() { + @Test func aspectFillIntoLargerSize() { let targetSize = CGSize(width: 2000, height: 1000) let size = originalImage.dimensions(forSuggestedSize: targetSize, format: .scaleAspectFill) - XCTAssertEqual(size, CGSize(width: 2000, height: 1500)) + #expect(size == CGSize(width: 2000, height: 1500)) } - func testFoo() { + @Test func zeroTargetSize() { let targetSize = CGSize(width: 0, height: 0) let originalImage = UIImage(color: .blue, size: CGSize(width: 1024, height: 680)) let size = originalImage.dimensions(forSuggestedSize: targetSize, format: .scaleAspectFill) + #expect(size == CGSize(width: 0, height: 0)) } } From 48d56ee7a2f83e75f8cc13ba9d67676c1910d463 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 11:28:06 -0400 Subject: [PATCH 03/90] Fix deleteItem to update total count - Update deleteItem method to properly decrement total when removing items - Update tests to verify total count is correctly maintained after deletion --- .../Views/DataView/DataViewPaginatedResponse.swift | 6 +++++- .../DataView/DataViewPaginatedResponseTests.swift | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift index 6c561ef5a958..086bfbcc68d6 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift @@ -84,6 +84,10 @@ public final class DataViewPaginatedResponse: ObservableO /// /// - Parameter id: The ID of the item to remove. public func deleteItem(withID id: Element.ID) { - items.removeAll { $0.id == id } + guard let index = items.firstIndex(where: { $0.id == id }) else { + return + } + items.remove(at: index) + total -= 1 } } diff --git a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift index 43987dec1f56..150c6a6d5df8 100644 --- a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift +++ b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift @@ -337,7 +337,7 @@ import WordPressUI // THEN #expect(response.items.count == 2) #expect(response.items.map(\.id) == [1, 3]) - #expect(response.total == 3) // Total remains unchanged + #expect(response.total == 2) // Total is updated } @Test func deleteNonExistentItem() async throws { @@ -359,5 +359,6 @@ import WordPressUI // THEN nothing changes #expect(response.items.count == 2) #expect(response.items.map(\.id) == [1, 2]) + #expect(response.total == 2) // Total remains unchanged } } From 96824f9e0d0b557f64313ae0b19f582aff65ec38 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 11:33:37 -0400 Subject: [PATCH 04/90] Rename LoadMoreFooterView --- ...dMoreFooterView.swift => DataViewPagingFooterView.swift} | 4 ++-- .../ViewRelated/Blog/Subscribers/List/SubscribersView.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename Modules/Sources/WordPressUI/Views/DataView/{LoadMoreFooterView.swift => DataViewPagingFooterView.swift} (90%) diff --git a/Modules/Sources/WordPressUI/Views/DataView/LoadMoreFooterView.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPagingFooterView.swift similarity index 90% rename from Modules/Sources/WordPressUI/Views/DataView/LoadMoreFooterView.swift rename to Modules/Sources/WordPressUI/Views/DataView/DataViewPagingFooterView.swift index 47067a773b6c..87c59e180fc7 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/LoadMoreFooterView.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPagingFooterView.swift @@ -1,6 +1,6 @@ import SwiftUI -public struct LoadMoreFooterView: View { +public struct DataViewPagingFooterView: View { public enum State { case loading case failure @@ -13,7 +13,7 @@ public struct LoadMoreFooterView: View { self.state = state } - public func onRetry(_ closure: (() -> Void)?) -> LoadMoreFooterView { + public func onRetry(_ closure: (() -> Void)?) -> DataViewPagingFooterView { var copy = self copy.onRetry = closure return copy diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift index 8074d2e81bbc..d7bfb43f7ae6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift @@ -95,7 +95,7 @@ private struct SubscribersSearchView: View { if let response { SubscribersPaginatedForEach(response: response) } else if error == nil { - LoadMoreFooterView(.loading) + DataViewPagingFooterView(.loading) } } .listStyle(.plain) @@ -130,9 +130,9 @@ private struct SubscribersPaginatedForEach: View { makeRow(with: $0) } if response.isLoading { - LoadMoreFooterView(.loading) + DataViewPagingFooterView(.loading) } else if response.error != nil { - LoadMoreFooterView(.failure).onRetry { + DataViewPagingFooterView(.failure).onRetry { response.loadMore() } } From 4bd090333caeeeed444ad6ef4138d2b4caf6de77 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 11:41:53 -0400 Subject: [PATCH 05/90] Add DataViewPaginatedForEach component --- .../DataView/DataViewPaginatedForEach.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift new file mode 100644 index 000000000000..f8c7311d2fba --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// A SwiftUI view that displays paginated data using ForEach with automatic loading triggers. +public struct DataViewPaginatedForEach: View { + @ObservedObject private var response: DataViewPaginatedResponse + private let content: (Element) -> Content + + /// Creates a paginated ForEach view. + /// + /// - Parameters: + /// - response: The paginated response handler that manages the data. + /// - content: A view builder that creates the content for each item. + public init( + response: DataViewPaginatedResponse, + @ViewBuilder content: @escaping (Element) -> Content + ) { + self.response = response + self.content = content + } + + public var body: some View { + ForEach(response.items) { item in + content(item) + .onAppear { + response.onRowAppear(item) + } + } + + if response.isLoading { + DataViewPagingFooterView(.loading) + } else if response.error != nil { + DataViewPagingFooterView(.failure) + .onRetry { + response.loadMore() + } + } + } +} \ No newline at end of file From 86903ee4ba3dade777f7e52e334b025cf7e3e3d6 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 11:58:42 -0400 Subject: [PATCH 06/90] Add DataViewPaginatedResponseProtocol --- .../DataView/DataViewPaginatedForEach.swift | 19 ++- .../DataView/DataViewPaginatedResponse.swift | 55 ++++++-- .../DataViewPaginatedResponseTests.swift | 126 ++++++++++-------- 3 files changed, 121 insertions(+), 79 deletions(-) diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift index f8c7311d2fba..753821b0cafb 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift @@ -1,18 +1,18 @@ import SwiftUI -/// A SwiftUI view that displays paginated data using ForEach with automatic loading triggers. -public struct DataViewPaginatedForEach: View { - @ObservedObject private var response: DataViewPaginatedResponse - private let content: (Element) -> Content - +/// A view that displays paginated data using ForEach with automatic loading triggers. +public struct DataViewPaginatedForEach: View { + @ObservedObject private var response: Response + private let content: (Response.Element) -> Content + /// Creates a paginated ForEach view. /// /// - Parameters: /// - response: The paginated response handler that manages the data. /// - content: A view builder that creates the content for each item. public init( - response: DataViewPaginatedResponse, - @ViewBuilder content: @escaping (Element) -> Content + response: Response, + @ViewBuilder content: @escaping (Response.Element) -> Content ) { self.response = response self.content = content @@ -22,10 +22,9 @@ public struct DataViewPaginatedForEach: Vi ForEach(response.items) { item in content(item) .onAppear { - response.onRowAppear(item) + response.onRowAppeared(item) } } - if response.isLoading { DataViewPagingFooterView(.loading) } else if response.error != nil { @@ -35,4 +34,4 @@ public struct DataViewPaginatedForEach: Vi } } } -} \ No newline at end of file +} diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift index 086bfbcc68d6..20898b13f23c 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift @@ -1,31 +1,58 @@ import Foundation import SwiftUI -/// A generic paginated response handler that manages loading items in pages. +@MainActor +public protocol DataViewPaginatedResponseProtocol: ObservableObject { + associatedtype Element: Identifiable + + var items: [Element] { get } + var isLoading: Bool { get } + var error: Error? { get } + + func onRowAppeared(_ item: Element) + @discardableResult func loadMore() -> Task? +} + +/// A generic paginated response handler that manages loading items with flexible pagination. /// This class is designed to be used in the UI in conjunction with `PaginatedForEach`. @MainActor -public final class DataViewPaginatedResponse: ObservableObject { +public final class DataViewPaginatedResponse: DataViewPaginatedResponseProtocol { @Published public private(set) var total = 0 @Published public private(set) var items: [Element] = [] @Published public private(set) var hasMore = true @Published public private(set) var isLoading = false @Published public private(set) var error: Error? + /// Result of a paginated load operation. + public struct Page { + public let items: [Element] + public let total: Int + public let hasMore: Bool + public let nextPage: PageIndex? + + public init(items: [Element], total: Int, hasMore: Bool, nextPage: PageIndex?) { + self.items = items + self.total = total + self.hasMore = hasMore + self.nextPage = nextPage + } + } + public var isEmpty: Bool { items.isEmpty } - private var currentPage = 1 - private let _loadMore: (Int) async throws -> (items: [Element], total: Int, hasMore: Bool) + private var nextPage: PageIndex? + private let loadPage: (PageIndex?) async throws -> Page /// Creates a new paginated response handler. /// - /// - Parameter loadMore: A closure that loads a specific page of items. - /// - Parameter page: The page number to load (1-based). - /// - Returns: A tuple containing the items for the page, the total count, and whether more pages exist. + /// - Parameter loadPage: A closure that loads items using pagination. + /// - Parameter pageIndex: The page index to load (nil for initial load). + /// - Returns: A PaginatedResult containing the items, total count, whether more pages exist, and next page index. /// - Throws: Any error from the initial page load. - public init(loadMore: @escaping (Int) async throws -> (items: [Element], total: Int, hasMore: Bool)) async throws { - self._loadMore = loadMore + public init(loadPage: @escaping (PageIndex?) async throws -> Page) async throws { + self.loadPage = loadPage - let response = try await loadMore(currentPage) + let response = try await loadPage(nil) didLoad(response) } @@ -44,7 +71,7 @@ public final class DataViewPaginatedResponse: ObservableO return Task { defer { isLoading = false } do { - let response = try await _loadMore(currentPage) + let response = try await loadPage(nextPage) didLoad(response) } catch { self.error = error @@ -53,9 +80,9 @@ public final class DataViewPaginatedResponse: ObservableO } } - private func didLoad(_ response: (items: [Element], total: Int, hasMore: Bool)) { + private func didLoad(_ response: Page) { total = response.total - currentPage += 1 + nextPage = response.nextPage hasMore = response.hasMore let existingIDs = Set(items.map(\.id)) @@ -71,7 +98,7 @@ public final class DataViewPaginatedResponse: ObservableO /// and there's no current error, it will trigger loading the next page. /// /// - Parameter row: The row that appeared. - public func onRowAppear(_ row: Element) { + public func onRowAppeared(_ row: Element) { guard items.suffix(16).contains(where: { $0.id == row.id }) else { return } diff --git a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift index 150c6a6d5df8..2f266850bd9c 100644 --- a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift +++ b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift @@ -17,12 +17,13 @@ import WordPressUI ] // WHEN - let response = try await DataViewPaginatedResponse { page in - #expect(page == 1) - return ( + let response = try await DataViewPaginatedResponse { pageIndex in + #expect(pageIndex == nil) // Initial load + return DataViewPaginatedResponse.Page( items: expectedItems, total: 10, - hasMore: true + hasMore: true, + nextPage: 2 ) } @@ -41,7 +42,7 @@ import WordPressUI // WHEN/THEN await #expect(throws: TestError.self) { - _ = try await DataViewPaginatedResponse { _ in + _ = try await DataViewPaginatedResponse { _ in throw TestError() } } @@ -49,35 +50,38 @@ import WordPressUI @Test func loadMoreSuccessfully() async throws { // GIVEN - var pageRequests: [Int] = [] - let response = try await DataViewPaginatedResponse { page in - pageRequests.append(page) + var pageRequests: [Int?] = [] + let response = try await DataViewPaginatedResponse { pageIndex in + pageRequests.append(pageIndex) - switch page { - case 1: - return ( + switch pageIndex { + case nil: + return DataViewPaginatedResponse.Page( items: [TestItem(id: 1, name: "Item 1")], total: 3, - hasMore: true + hasMore: true, + nextPage: 2 ) case 2: - return ( + return DataViewPaginatedResponse.Page( items: [TestItem(id: 2, name: "Item 2")], total: 3, - hasMore: true + hasMore: true, + nextPage: 3 ) case 3: - return ( + return DataViewPaginatedResponse.Page( items: [TestItem(id: 3, name: "Item 3")], total: 3, - hasMore: false + hasMore: false, + nextPage: nil ) default: - fatalError("Unexpected page: \(page)") + fatalError("Unexpected page: \(String(describing: pageIndex))") } } - #expect(pageRequests == [1]) + #expect(pageRequests == [nil]) // WHEN loading page 2 do { @@ -90,7 +94,7 @@ import WordPressUI #expect(response.items.count == 2) #expect(response.items.map(\.id) == [1, 2]) #expect(response.hasMore == true) - #expect(pageRequests == [1, 2]) + #expect(pageRequests == [nil, 2]) // WHEN loading page 3 do { @@ -103,7 +107,7 @@ import WordPressUI #expect(response.items.count == 3) #expect(response.items.map(\.id) == [1, 2, 3]) #expect(response.hasMore == false) - #expect(pageRequests == [1, 2, 3]) + #expect(pageRequests == [nil, 2, 3]) // WHEN trying to load more when hasMore is false do { @@ -113,7 +117,7 @@ import WordPressUI } // THEN no additional requests are made - #expect(pageRequests == [1, 2, 3]) + #expect(pageRequests == [nil, 2, 3]) } @Test func loadMoreHandlesError() async throws { @@ -121,14 +125,16 @@ import WordPressUI struct TestError: Error {} var shouldThrow = false - let response = try await DataViewPaginatedResponse { page in + let response = try await DataViewPaginatedResponse { pageIndex in if shouldThrow { throw TestError() } - return ( - items: [TestItem(id: page, name: "Item \(page)")], + let id = pageIndex ?? 1 + return DataViewPaginatedResponse.Page( + items: [TestItem(id: id, name: "Item \(id)")], total: 10, - hasMore: true + hasMore: true, + nextPage: (pageIndex ?? 1) + 1 ) } @@ -174,26 +180,28 @@ import WordPressUI @Test func filtersDuplicateItems() async throws { // GIVEN - let response = try await DataViewPaginatedResponse { page in - if page == 1 { - return ( + let response = try await DataViewPaginatedResponse { pageIndex in + if pageIndex == nil { + return DataViewPaginatedResponse.Page( items: [ TestItem(id: 1, name: "Item 1"), TestItem(id: 2, name: "Item 2") ], total: 4, - hasMore: true + hasMore: true, + nextPage: 2 ) } else { // Page 2 includes a duplicate item - return ( + return DataViewPaginatedResponse.Page( items: [ TestItem(id: 2, name: "Item 2 Duplicate"), TestItem(id: 3, name: "Item 3"), TestItem(id: 4, name: "Item 4") ], total: 4, - hasMore: false + hasMore: false, + nextPage: nil ) } } @@ -214,12 +222,14 @@ import WordPressUI @Test func preventsConcurrentLoads() async throws { // GIVEN var loadCount = 0 - let response = try await DataViewPaginatedResponse { page in + let response = try await DataViewPaginatedResponse { pageIndex in loadCount += 1 - return ( - items: [TestItem(id: page, name: "Item \(page)")], + let id = pageIndex ?? 1 + return DataViewPaginatedResponse.Page( + items: [TestItem(id: id, name: "Item \(id)")], total: 10, - hasMore: true + hasMore: true, + nextPage: (pageIndex ?? 1) + 1 ) } @@ -235,61 +245,65 @@ import WordPressUI #expect(response.items.count == 2) } - @Test func onRowAppearTriggersLoad() async throws { + @Test func onRowAppearedTriggersLoad() async throws { // GIVEN var items: [TestItem] = [] for i in 1...20 { items.append(TestItem(id: i, name: "Item \(i)")) } - let response = try await DataViewPaginatedResponse { page in - if page == 1 { - return ( + let response = try await DataViewPaginatedResponse { pageIndex in + if pageIndex == nil { + return DataViewPaginatedResponse.Page( items: Array(items.prefix(20)), total: 30, - hasMore: true + hasMore: true, + nextPage: 2 ) } else { - return ( + return DataViewPaginatedResponse.Page( items: Array(items.suffix(10)), total: 30, - hasMore: false + hasMore: false, + nextPage: nil ) } } // WHEN row in the middle appears - response.onRowAppear(response.items[0]) + response.onRowAppeared(response.items[0]) // THEN no load is triggered #expect(response.isLoading == false) // WHEN row in the last 16 items appears - response.onRowAppear(response.items[15]) + response.onRowAppeared(response.items[15]) #expect(response.isLoading) } - @Test func onRowAppearDoesNotLoadWhenError() async throws { + @Test func onRowAppearedDoesNotLoadWhenError() async throws { // GIVEN struct TestError: Error {} var shouldThrow = false var loadAttempts = 0 - let response = try await DataViewPaginatedResponse { page in + let response = try await DataViewPaginatedResponse { pageIndex in loadAttempts += 1 if shouldThrow { throw TestError() } + let page = pageIndex ?? 1 var items: [TestItem] = [] for i in 1...20 { items.append(TestItem(id: i + (page - 1) * 20, name: "Item \(i)")) } - return ( + return DataViewPaginatedResponse.Page( items: items, total: 40, - hasMore: page < 2 + hasMore: page < 2, + nextPage: page < 2 ? page + 1 : nil ) } @@ -309,7 +323,7 @@ import WordPressUI #expect(response.error != nil) // WHEN row appears after error - response.onRowAppear(response.items[15]) + response.onRowAppeared(response.items[15]) #expect(response.isLoading == false) // THEN no additional load attempts are made @@ -317,15 +331,16 @@ import WordPressUI @Test func deleteItem() async throws { // GIVEN - let response = try await DataViewPaginatedResponse { _ in - return ( + let response = try await DataViewPaginatedResponse { _ in + return DataViewPaginatedResponse.Page( items: [ TestItem(id: 1, name: "Item 1"), TestItem(id: 2, name: "Item 2"), TestItem(id: 3, name: "Item 3") ], total: 3, - hasMore: false + hasMore: false, + nextPage: nil ) } @@ -342,14 +357,15 @@ import WordPressUI @Test func deleteNonExistentItem() async throws { // GIVEN - let response = try await DataViewPaginatedResponse { _ in - return ( + let response = try await DataViewPaginatedResponse { _ in + return DataViewPaginatedResponse.Page( items: [ TestItem(id: 1, name: "Item 1"), TestItem(id: 2, name: "Item 2") ], total: 2, - hasMore: false + hasMore: false, + nextPage: nil ) } From a2f5eba182fb1230972ec4a72cf2591ff069297b Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 14:47:08 -0400 Subject: [PATCH 07/90] Refactor subscribers to use DataViewPaginatedResponse Replace custom SubscribersPaginatedResponse with generic DataViewPaginatedResponse system. Add notification-based subscriber deletion updates and improve pagination handling with DataViewPaginatedForEach component. --- .../DataView/DataViewPaginatedForEach.swift | 2 +- .../Details/SubscriberDetailsView.swift | 9 -- .../Details/SubsriberDetailsViewModel.swift | 8 ++ .../Subscribers/List/SubscriberRowView.swift | 12 +-- .../List/SubscribersPaginatedResponse.swift | 90 ------------------- .../Subscribers/List/SubscribersView.swift | 23 +++-- .../List/SubscribersViewModel.swift | 43 +++++++-- 7 files changed, 62 insertions(+), 125 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersPaginatedResponse.swift diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift index 753821b0cafb..0589aadb2a49 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift @@ -17,7 +17,7 @@ public struct DataViewPaginatedForEach Void)? - init(viewModel: SubsriberDetailsViewModel) { self.viewModel = viewModel } @@ -113,7 +111,6 @@ struct SubscriberDetailsView: View { do { try await viewModel.delete(details) UINotificationFeedbackGenerator().notificationOccurred(.success) - onDeleted?(details.subscriberID) dismiss() } catch { UINotificationFeedbackGenerator().notificationOccurred(.error) @@ -123,12 +120,6 @@ struct SubscriberDetailsView: View { } } - func onDeleted(_ closure: @escaping (Int) -> Void) -> SubscriberDetailsView { - var copy = self - copy.onDeleted = closure - return copy - } - // MARK: Views private func makeNewsletterSubscriptionSection(for details: SubscribersServiceRemote.GetSubscriberDetailsResponse) -> some View { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift index 76d97ecac703..182b85196bc9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift @@ -54,6 +54,9 @@ struct SubsriberDetailsViewModel { } extension SubscribersServiceRemote { + static let subscriberIDKey = "subscriberIDKey" + + @MainActor func deleteSubscriber(_ subscriber: SubscribersServiceRemote.SubsciberBasicInfoResponse, siteID: Int) async throws { let service = PeopleServiceRemote(wordPressComRestApi: wordPressComRestApi) try await withUnsafeThrowingContinuation { continuation in @@ -71,5 +74,10 @@ extension SubscribersServiceRemote { }) } } + NotificationCenter.default.post( + name: .subscriberDeleted, + object: nil, + userInfo: [SubscribersServiceRemote.subscriberIDKey: subscriber.subscriberID] + ) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift index 80a67b8de9ff..5e13e3255a0b 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift @@ -55,9 +55,9 @@ struct SubscriberRowView: View { } @MainActor -final class SubscriberRowViewModel: Identifiable { +final class SubscriberRowViewModel: @preconcurrency Identifiable { let subscriber: SubscribersServiceRemote.GetSubscribersResponse.Subscriber - var identifier: Int { subscriberID } + var id: Int { subscriberID } var subscriberID: Int { subscriber.subscriberID } let title: String @@ -73,8 +73,6 @@ final class SubscriberRowViewModel: Identifiable { private let blog: SubscribersBlog - weak var response: SubscribersPaginatedResponse? - init(blog: SubscribersBlog, subscriber: SubscribersServiceRemote.GetSubscribersResponse.Subscriber) { self.blog = blog self.subscriber = subscriber @@ -99,7 +97,6 @@ final class SubscriberRowViewModel: Identifiable { try await blog.getSubscribersService() .deleteSubscriber(subscriber, siteID: blog.dotComSiteID) UINotificationFeedbackGenerator().notificationOccurred(.success) - response?.deleteSubscriber(withID: subscriberID) } catch { UINotificationFeedbackGenerator().notificationOccurred(.error) Notice(error: error).post() @@ -109,6 +106,11 @@ final class SubscriberRowViewModel: Identifiable { } } +extension Foundation.Notification.Name { + @MainActor + static let subscriberDeleted = Foundation.Notification.Name("subscriberDeleted") +} + private enum Strings { static let delete = NSLocalizedString("subscribers.buttonDeleteSubscriber", value: "Delete Subscriber", comment: "Button title") static let confirmDeleteTitle = NSLocalizedString("subscribers.deleteSubscriberConfirmationDialog.title", value: "Delete the subscriber", comment: "Remove subscriber confirmation dialog title") diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersPaginatedResponse.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersPaginatedResponse.swift deleted file mode 100644 index 2985145ac258..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersPaginatedResponse.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation -import SwiftUI -import WordPressShared -import WordPressKit - -/// Loads paginated subscribers for the given parameters. -@MainActor -final class SubscribersPaginatedResponse: ObservableObject { - @Published private(set) var total = 0 - @Published private(set) var items: [SubscriberRowViewModel] = [] - @Published private(set) var hasMore = true - @Published private(set) var isLoading = false - @Published private(set) var error: Error? - - let parameters: SubscribersServiceRemote.GetSubscribersParameters - var isEmpty: Bool { items.isEmpty } - - private var currentPage = 1 - private let blog: SubscribersBlog - private let search: String? - - init(blog: SubscribersBlog, parameters: SubscribersServiceRemote.GetSubscribersParameters = .init(), search: String? = nil) async throws { - self.blog = blog - self.parameters = parameters - self.search = search - - let response = try await next() - didLoad(response) - } - - func loadMore() { - guard hasMore && !isLoading else { - return - } - error = nil - isLoading = true - Task { - defer { isLoading = false } - do { - let response = try await next() - didLoad(response) - } catch { - self.error = error - } - } - } - - private func didLoad(_ response: SubscribersServiceRemote.GetSubscribersResponse) { - total = response.total - currentPage += 1 - hasMore = response.page < response.pages - - let existingIDs = Set(items.map(\.subscriberID)) - let newItems = response.subscribers.filter { - !existingIDs.contains($0.subscriberID) - } - items += newItems.map { - let viewModel = SubscriberRowViewModel(blog: blog, subscriber: $0) - viewModel.response = self - return viewModel - } - } - - func onRowAppear(_ row: SubscriberRowViewModel) { - guard items.suffix(10).contains(where: { $0.id == row.id }) else { - return - } - if error == nil { - loadMore() - } - } - - func deleteSubscriber(withID subscriberID: Int) { - items.removeAll { $0.subscriberID == subscriberID } - } - - private func next() async throws -> SubscribersServiceRemote.GetSubscribersResponse { - guard let api = blog.getRestAPI() else { - throw URLError(.unknown) - } - let service = SubscribersServiceRemote(wordPressComRestApi: api) - return try await service.getSubscribers( - siteID: blog.dotComSiteID, - page: currentPage, - perPage: 50, - parameters: parameters, - search: search - ) - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift index d7bfb43f7ae6..ab0abb7da4ce 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift @@ -126,32 +126,29 @@ private struct SubscribersPaginatedForEach: View { @ObservedObject var response: SubscribersPaginatedResponse var body: some View { - ForEach(response.items) { - makeRow(with: $0) - } - if response.isLoading { - DataViewPagingFooterView(.loading) - } else if response.error != nil { - DataViewPagingFooterView(.failure).onRetry { - response.loadMore() + DataViewPaginatedForEach(response: response, content: makeRow) + .onReceive(NotificationCenter.default.publisher(for: .subscriberDeleted)) { notification in + subscriberDeleted(userInfo: notification.userInfo) } - } } private func makeRow(with item: SubscriberRowViewModel) -> some View { SubscriberRowView(viewModel: item) - .onAppear { response.onRowAppear(item) } + .onAppear { response.onRowAppeared(item) } .background { NavigationLink { SubscriberDetailsView(viewModel: item.makeDetailsViewModel()) - .onDeleted { [weak response] in - response?.deleteSubscriber(withID: $0) - } } label: { EmptyView() }.opacity(0) } } + + private func subscriberDeleted(userInfo: [AnyHashable: Any]?) { + if let subscriberID = userInfo?[SubscribersServiceRemote.subscriberIDKey] as? Int { + response.deleteItem(withID: subscriberID) + } + } } private enum Strings { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift index 8a37383cacb1..b8b6ddc4c9f1 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift @@ -1,5 +1,8 @@ import SwiftUI import WordPressKit +import WordPressUI + +typealias SubscribersPaginatedResponse = DataViewPaginatedResponse @MainActor final class SubscribersViewModel: ObservableObject { @@ -39,11 +42,11 @@ final class SubscribersViewModel: ObservableObject { error = nil isLoading = true do { - let response = try await SubscribersPaginatedResponse(blog: blog, parameters: parameters) + let response = try await makeResponse(parameters: parameters) guard !Task.isCancelled else { return } self.isLoading = false self.response = response - if response.parameters.filters.isEmpty { + if parameters.filters.isEmpty { totalCount = response.total } } catch { @@ -57,18 +60,44 @@ final class SubscribersViewModel: ObservableObject { } func search() async throws -> SubscribersPaginatedResponse { - try await SubscribersPaginatedResponse(blog: blog, parameters: parameters, search: searchText) + try await makeResponse(parameters: parameters, search: searchText) } func makeFormattedSubscribersCount(for response: SubscribersPaginatedResponse) -> String { - if response.parameters.filters.isEmpty { - return "\(response.total)" - } - guard let totalCount else { + guard !parameters.filters.isEmpty, let totalCount else { return "\(response.total)" } return String(format: Strings.nOutOf, response.total.description, totalCount.description) } + + private func makeResponse( + parameters: SubscribersServiceRemote.GetSubscribersParameters, + search: String? = nil + ) async throws -> SubscribersPaginatedResponse { + return try await SubscribersPaginatedResponse { [blog] page in + guard let api = blog.getRestAPI() else { + throw URLError(.unknown) + } + let service = SubscribersServiceRemote(wordPressComRestApi: api) + let currentPage = page ?? 1 + let response = try await service.getSubscribers( + siteID: blog.dotComSiteID, + page: currentPage, + perPage: 50, + parameters: parameters, + search: search + ) + let items = response.subscribers.map { subscriber in + SubscriberRowViewModel(blog: blog, subscriber: subscriber) + } + return SubscribersPaginatedResponse.Page( + items: items, + total: response.total, + hasMore: response.page < response.pages, + nextPage: response.page + 1 + ) + } + } } private enum Strings { From 077a2de64add850eb41ac464e0f2024deac8b145 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 15:13:23 -0400 Subject: [PATCH 08/90] Refactor subscribers service creation and move deleteSubscriber extension - Rename getSubscribersService() to maketSubscribersService() to better reflect factory pattern - Move deleteSubscriber extension from SubsriberDetailsViewModel to dedicated SubscribersServiceRemote+Extensions file - Update all callers to use the renamed method --- .../Details/SubsriberDetailsViewModel.swift | 35 ++----------------- .../Subscribers/Helpers/SubscribersBlog.swift | 2 +- .../SubscribersServiceRemote+Extensions.swift | 30 ++++++++++++++++ .../Invite/SubscriberInviteView.swift | 5 +-- .../Subscribers/List/SubscriberRowView.swift | 2 +- .../List/SubscribersViewModel.swift | 8 ++--- 6 files changed, 38 insertions(+), 44 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift index 182b85196bc9..efad6ddbfd9d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift @@ -32,17 +32,17 @@ struct SubsriberDetailsViewModel { } func getDetails() async throws -> SubscribersServiceRemote.GetSubscriberDetailsResponse { - try await blog.getSubscribersService() + try await blog.maketSubscribersService() .getSubsciberDetails(siteID: blog.dotComSiteID, subscriberID: subscriberID) } func getStats() async throws -> SubscribersServiceRemote.GetSubscriberStatsResponse { - try await blog.getSubscribersService() + try await blog.maketSubscribersService() .getSubsciberStats(siteID: blog.dotComSiteID, subscriberID: subscriberID) } func delete(_ subscriber: SubscribersServiceRemote.SubsciberBasicInfoResponse) async throws { - try await blog.getSubscribersService() + try await blog.maketSubscribersService() .deleteSubscriber(subscriber, siteID: blog.dotComSiteID) } @@ -52,32 +52,3 @@ struct SubsriberDetailsViewModel { return absolute + " (\(relative))" } } - -extension SubscribersServiceRemote { - static let subscriberIDKey = "subscriberIDKey" - - @MainActor - func deleteSubscriber(_ subscriber: SubscribersServiceRemote.SubsciberBasicInfoResponse, siteID: Int) async throws { - let service = PeopleServiceRemote(wordPressComRestApi: wordPressComRestApi) - try await withUnsafeThrowingContinuation { continuation in - if subscriber.isDotComUser { - service.deleteFollower(siteID, userID: subscriber.dotComUserID, success: { - continuation.resume() - }, failure: { - continuation.resume(throwing: $0) - }) - } else { - service.deleteEmailFollower(siteID, userID: subscriber.subscriberID, success: { - continuation.resume() - }, failure: { - continuation.resume(throwing: $0) - }) - } - } - NotificationCenter.default.post( - name: .subscriberDeleted, - object: nil, - userInfo: [SubscribersServiceRemote.subscriberIDKey: subscriber.subscriberID] - ) - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift index 39ed40fbea05..da60d7b0b9d7 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift @@ -25,7 +25,7 @@ struct SubscribersBlog { SubscribersBlog(dotComSiteID: 1, getRestAPI: { nil }) } - func getSubscribersService() throws -> SubscribersServiceRemote { + func maketSubscribersService() throws -> SubscribersServiceRemote { guard let api = getRestAPI() else { throw URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: SharedStrings.Error.generic]) } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersServiceRemote+Extensions.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersServiceRemote+Extensions.swift index 933f15322a7b..bf4fd8e846f9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersServiceRemote+Extensions.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersServiceRemote+Extensions.swift @@ -1,5 +1,35 @@ +import Foundation import WordPressKit +extension SubscribersServiceRemote { + static let subscriberIDKey = "subscriberIDKey" + + @MainActor + func deleteSubscriber(_ subscriber: SubscribersServiceRemote.SubsciberBasicInfoResponse, siteID: Int) async throws { + let service = PeopleServiceRemote(wordPressComRestApi: wordPressComRestApi) + try await withUnsafeThrowingContinuation { continuation in + if subscriber.isDotComUser { + service.deleteFollower(siteID, userID: subscriber.dotComUserID, success: { + continuation.resume() + }, failure: { + continuation.resume(throwing: $0) + }) + } else { + service.deleteEmailFollower(siteID, userID: subscriber.subscriberID, success: { + continuation.resume() + }, failure: { + continuation.resume(throwing: $0) + }) + } + } + NotificationCenter.default.post( + name: .subscriberDeleted, + object: nil, + userInfo: [SubscribersServiceRemote.subscriberIDKey: subscriber.subscriberID] + ) + } +} + extension SubscribersServiceRemote.GetSubscribersParameters.FilterSubscriptionType { var localizedTitle: String { switch self { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift index c4440e460267..6b5e2ccbfc94 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift @@ -104,10 +104,7 @@ struct SubscriberInviteView: View { isSending = true Task { do { - guard let api = blog.getRestAPI() else { - throw URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: SharedStrings.Error.generic]) - } - let service = SubscribersServiceRemote(wordPressComRestApi: api) + let service = try blog.maketSubscribersService() try await service.importSubscribers(siteID: blog.dotComSiteID, emails: emails) UINotificationFeedbackGenerator().notificationOccurred(.success) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift index 5e13e3255a0b..2f419f1f9fa0 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift @@ -94,7 +94,7 @@ final class SubscriberRowViewModel: @preconcurrency Identifiable { isDeleting = true Task { do { - try await blog.getSubscribersService() + try await blog.maketSubscribersService() .deleteSubscriber(subscriber, siteID: blog.dotComSiteID) UINotificationFeedbackGenerator().notificationOccurred(.success) } catch { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift index b8b6ddc4c9f1..94d0d210b36b 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift @@ -75,14 +75,10 @@ final class SubscribersViewModel: ObservableObject { search: String? = nil ) async throws -> SubscribersPaginatedResponse { return try await SubscribersPaginatedResponse { [blog] page in - guard let api = blog.getRestAPI() else { - throw URLError(.unknown) - } - let service = SubscribersServiceRemote(wordPressComRestApi: api) - let currentPage = page ?? 1 + let service = try blog.maketSubscribersService() let response = try await service.getSubscribers( siteID: blog.dotComSiteID, - page: currentPage, + page: page ?? 1, perPage: 50, parameters: parameters, search: search From d882b55b9fae39d4d89af93ac3d4cd44db261c3c Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 15:26:29 -0400 Subject: [PATCH 09/90] Fix typo in Subscriber --- .../Details/SubscriberDetailsView.swift | 4 ++-- ...swift => SubscriberDetailsViewModel.swift} | 6 +++--- .../Subscribers/List/SubscriberRowView.swift | 4 ++-- WordPress/WordPress.xcodeproj/project.pbxproj | 20 +++++++++---------- 4 files changed, 17 insertions(+), 17 deletions(-) rename WordPress/Classes/ViewRelated/Blog/Subscribers/Details/{SubsriberDetailsViewModel.swift => SubscriberDetailsViewModel.swift} (91%) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift index 80614e136a3b..d9a5a41bb6fe 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift @@ -3,7 +3,7 @@ import WordPressKit import WordPressUI struct SubscriberDetailsView: View { - let viewModel: SubsriberDetailsViewModel + let viewModel: SubscriberDetailsViewModel @State private var details: SubscribersServiceRemote.GetSubscriberDetailsResponse? @State private var stats: SubscribersServiceRemote.GetSubscriberStatsResponse? @@ -16,7 +16,7 @@ struct SubscriberDetailsView: View { @Environment(\.dismiss) var dismiss - init(viewModel: SubsriberDetailsViewModel) { + init(viewModel: SubscriberDetailsViewModel) { self.viewModel = viewModel } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift similarity index 91% rename from WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift rename to WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift index efad6ddbfd9d..b3a4ea731fdd 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift @@ -2,7 +2,7 @@ import Foundation import WordPressKit @MainActor -struct SubsriberDetailsViewModel { +struct SubscriberDetailsViewModel { let subscriberID: Int let subscriber: SubscribersServiceRemote.SubsciberBasicInfoResponse? @@ -27,8 +27,8 @@ struct SubsriberDetailsViewModel { self.subscriber = nil } - static func mock() -> SubsriberDetailsViewModel { - SubsriberDetailsViewModel(blog: .mock(), subscriberID: 1) + static func mock() -> SubscriberDetailsViewModel { + SubscriberDetailsViewModel(blog: .mock(), subscriberID: 1) } func getDetails() async throws -> SubscribersServiceRemote.GetSubscriberDetailsResponse { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift index 2f419f1f9fa0..d3e0c88d35c6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift @@ -86,8 +86,8 @@ final class SubscriberRowViewModel: @preconcurrency Identifiable { self.details = subscriber.dateSubscribed.toShortString() } - func makeDetailsViewModel() -> SubsriberDetailsViewModel { - SubsriberDetailsViewModel(blog: blog, subscriber: subscriber) + func makeDetailsViewModel() -> SubscriberDetailsViewModel { + SubscriberDetailsViewModel(blog: blog, subscriber: subscriber) } func delete() { diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 7a24176c2c81..6ddcdc8a1d2a 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -151,8 +151,8 @@ 4ABCAB2A2DE52E80005A6B84 /* Secrets-JetpackDraftActionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABCAB272DE52E6B005A6B84 /* Secrets-JetpackDraftActionExtension.swift */; }; 4ABCAB2B2DE52E86005A6B84 /* Secrets-JetpackShareExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABCAB282DE52E6B005A6B84 /* Secrets-JetpackShareExtension.swift */; }; 4ABCAB2E2DE53092005A6B84 /* Secrets-JetpackNotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABCAB2D2DE53092005A6B84 /* Secrets-JetpackNotificationServiceExtension.swift */; }; - 4ABCAB332DE53168005A6B84 /* Secrets-JetpackIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABCAB322DE53168005A6B84 /* Secrets-JetpackIntents.swift */; }; 4ABCAB3A2DE533A5005A6B84 /* Secrets-WordPressNotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABCAB392DE533A5005A6B84 /* Secrets-WordPressNotificationServiceExtension.swift */; }; + 4ABCAB332DE53168005A6B84 /* Secrets-JetpackIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABCAB322DE53168005A6B84 /* Secrets-JetpackIntents.swift */; }; 4AC9545A2DE51FE40095EA51 /* Secrets-JetpackStatsWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC954592DE51FE40095EA51 /* Secrets-JetpackStatsWidgets.swift */; }; 4AC9F8182DE528E40095EA51 /* Secrets-WordPressShareExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC9F8172DE528E40095EA51 /* Secrets-WordPressShareExtension.swift */; }; 4AD953C72C21451700D0EEFA /* WordPressAuthenticator.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AD953B42C21451700D0EEFA /* WordPressAuthenticator.framework */; }; @@ -824,8 +824,8 @@ 4ABCAB272DE52E6B005A6B84 /* Secrets-JetpackDraftActionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-JetpackDraftActionExtension.swift"; path = "../Secrets/Secrets-JetpackDraftActionExtension.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; 4ABCAB282DE52E6B005A6B84 /* Secrets-JetpackShareExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-JetpackShareExtension.swift"; path = "../Secrets/Secrets-JetpackShareExtension.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; 4ABCAB2D2DE53092005A6B84 /* Secrets-JetpackNotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-JetpackNotificationServiceExtension.swift"; path = "../Secrets/Secrets-JetpackNotificationServiceExtension.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; - 4ABCAB322DE53168005A6B84 /* Secrets-JetpackIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-JetpackIntents.swift"; path = "../Secrets/Secrets-JetpackIntents.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; 4ABCAB392DE533A5005A6B84 /* Secrets-WordPressNotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-WordPressNotificationServiceExtension.swift"; path = "../Secrets/Secrets-WordPressNotificationServiceExtension.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; + 4ABCAB322DE53168005A6B84 /* Secrets-JetpackIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-JetpackIntents.swift"; path = "../Secrets/Secrets-JetpackIntents.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; 4AC954592DE51FE40095EA51 /* Secrets-JetpackStatsWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-JetpackStatsWidgets.swift"; path = "../Secrets/Secrets-JetpackStatsWidgets.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; 4AC9F8172DE528E40095EA51 /* Secrets-WordPressShareExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Secrets-WordPressShareExtension.swift"; path = "../Secrets/Secrets-WordPressShareExtension.swift"; sourceTree = BUILT_PRODUCTS_DIR; }; 4AD953B42C21451700D0EEFA /* WordPressAuthenticator.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = WordPressAuthenticator.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1205,19 +1205,19 @@ ); target = 80F6D01F28EE866A00953C1A /* JetpackNotificationServiceExtension */; }; - 4ABCAB352DE531B6005A6B84 /* Exceptions for "Classes" folder in "JetpackIntents" target */ = { + 4ABCAB382DE5333C005A6B84 /* Exceptions for "Classes" folder in "WordPressNotificationServiceExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( "System/ApiCredentials+BuildSecrets.swift", ); - target = 0107E13828FE9DB200DE87DB /* JetpackIntents */; + target = 7358E6B7210BD318002323EB /* WordPressNotificationServiceExtension */; }; - 4ABCAB382DE5333C005A6B84 /* Exceptions for "Classes" folder in "WordPressNotificationServiceExtension" target */ = { + 4ABCAB352DE531B6005A6B84 /* Exceptions for "Classes" folder in "JetpackIntents" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( "System/ApiCredentials+BuildSecrets.swift", ); - target = 7358E6B7210BD318002323EB /* WordPressNotificationServiceExtension */; + target = 0107E13828FE9DB200DE87DB /* JetpackIntents */; }; 4AC9CF442DE5228C0095EA51 /* Exceptions for "Classes" folder in "JetpackStatsWidgets" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; @@ -3291,7 +3291,7 @@ shellPath = /bin/sh; shellScript = "$SRCROOT/../Scripts/BuildPhases/GenerateCredentials.sh\n"; }; - 4ABCAB312DE530F1005A6B84 /* Generate Credentials */ = { + 4ABCAB362DE532E7005A6B84 /* Generate Credentials */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3305,13 +3305,13 @@ outputFileListPaths = ( ); outputPaths = ( - "$(BUILD_DIR)/Secrets/Secrets-JetpackIntents.swift", + "$(BUILD_DIR)/Secrets/Secrets-WordPressNotificationServiceExtension.swift", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "$SRCROOT/../Scripts/BuildPhases/GenerateCredentials.sh\n"; }; - 4ABCAB362DE532E7005A6B84 /* Generate Credentials */ = { + 4ABCAB312DE530F1005A6B84 /* Generate Credentials */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -3325,7 +3325,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(BUILD_DIR)/Secrets/Secrets-WordPressNotificationServiceExtension.swift", + "$(BUILD_DIR)/Secrets/Secrets-JetpackIntents.swift", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; From 915719228621c91047010664d4d6a4bc29b6b913 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 16 Jun 2025 16:39:38 -0400 Subject: [PATCH 10/90] Fix formatting --- .../DataViewPaginatedResponseTests.swift | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift index 2f266850bd9c..ef53c9dac6d5 100644 --- a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift +++ b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift @@ -8,14 +8,14 @@ import WordPressUI let id: Int let name: String } - + @Test func initLoadFirstPage() async throws { // GIVEN let expectedItems = [ TestItem(id: 1, name: "Item 1"), TestItem(id: 2, name: "Item 2") ] - + // WHEN let response = try await DataViewPaginatedResponse { pageIndex in #expect(pageIndex == nil) // Initial load @@ -35,11 +35,11 @@ import WordPressUI #expect(response.isLoading == false) #expect(response.error == nil) } - + @Test func initThrowsError() async throws { // GIVEN struct TestError: Error {} - + // WHEN/THEN await #expect(throws: TestError.self) { _ = try await DataViewPaginatedResponse { _ in @@ -47,13 +47,13 @@ import WordPressUI } } } - + @Test func loadMoreSuccessfully() async throws { // GIVEN var pageRequests: [Int?] = [] let response = try await DataViewPaginatedResponse { pageIndex in pageRequests.append(pageIndex) - + switch pageIndex { case nil: return DataViewPaginatedResponse.Page( @@ -80,35 +80,35 @@ import WordPressUI fatalError("Unexpected page: \(String(describing: pageIndex))") } } - + #expect(pageRequests == [nil]) - + // WHEN loading page 2 do { let task = response.loadMore() #expect(response.isLoading) try await task?.value } - + // THEN #expect(response.items.count == 2) #expect(response.items.map(\.id) == [1, 2]) #expect(response.hasMore == true) #expect(pageRequests == [nil, 2]) - + // WHEN loading page 3 do { let task = response.loadMore() #expect(response.isLoading) try await task?.value } - + // THEN #expect(response.items.count == 3) #expect(response.items.map(\.id) == [1, 2, 3]) #expect(response.hasMore == false) #expect(pageRequests == [nil, 2, 3]) - + // WHEN trying to load more when hasMore is false do { let task = response.loadMore() @@ -119,12 +119,12 @@ import WordPressUI // THEN no additional requests are made #expect(pageRequests == [nil, 2, 3]) } - + @Test func loadMoreHandlesError() async throws { // GIVEN struct TestError: Error {} var shouldThrow = false - + let response = try await DataViewPaginatedResponse { pageIndex in if shouldThrow { throw TestError() @@ -137,7 +137,7 @@ import WordPressUI nextPage: (pageIndex ?? 1) + 1 ) } - + // WHEN loading successfully do { let task = response.loadMore() @@ -146,7 +146,7 @@ import WordPressUI } #expect(response.items.count == 2) #expect(response.error == nil) - + // WHEN error occurs shouldThrow = true do { @@ -159,12 +159,12 @@ import WordPressUI // Do nothing } } - + // THEN #expect(response.error != nil) #expect(response.error is TestError) #expect(response.items.count == 2) // Items remain unchanged - + // WHEN retrying after error shouldThrow = false do { @@ -172,12 +172,12 @@ import WordPressUI #expect(response.isLoading) try await task?.value } - + // THEN retry succeeds #expect(response.error == nil) #expect(response.items.count == 3) } - + @Test func filtersDuplicateItems() async throws { // GIVEN let response = try await DataViewPaginatedResponse { pageIndex in @@ -205,20 +205,20 @@ import WordPressUI ) } } - + // WHEN do { let task = response.loadMore() #expect(response.isLoading) try await task?.value } - + // THEN duplicates are filtered out #expect(response.items.count == 4) #expect(response.items.map(\.id) == [1, 2, 3, 4]) #expect(response.items[1].name == "Item 2") // Original item is kept } - + @Test func preventsConcurrentLoads() async throws { // GIVEN var loadCount = 0 @@ -232,7 +232,7 @@ import WordPressUI nextPage: (pageIndex ?? 1) + 1 ) } - + // WHEN multiple loadMore calls are made concurrently let task = response.loadMore() #expect(response.loadMore() == nil) @@ -244,7 +244,7 @@ import WordPressUI #expect(loadCount == 2) // Counting the initial load #expect(response.items.count == 2) } - + @Test func onRowAppearedTriggersLoad() async throws { // GIVEN var items: [TestItem] = [] @@ -269,10 +269,10 @@ import WordPressUI ) } } - + // WHEN row in the middle appears response.onRowAppeared(response.items[0]) - + // THEN no load is triggered #expect(response.isLoading == false) @@ -280,25 +280,25 @@ import WordPressUI response.onRowAppeared(response.items[15]) #expect(response.isLoading) } - + @Test func onRowAppearedDoesNotLoadWhenError() async throws { // GIVEN struct TestError: Error {} var shouldThrow = false var loadAttempts = 0 - + let response = try await DataViewPaginatedResponse { pageIndex in loadAttempts += 1 if shouldThrow { throw TestError() } - + let page = pageIndex ?? 1 var items: [TestItem] = [] for i in 1...20 { items.append(TestItem(id: i + (page - 1) * 20, name: "Item \(i)")) } - + return DataViewPaginatedResponse.Page( items: items, total: 40, @@ -306,7 +306,7 @@ import WordPressUI nextPage: page < 2 ? page + 1 : nil ) } - + // WHEN error occurs on second page shouldThrow = true do { @@ -319,16 +319,16 @@ import WordPressUI // Do nothing } } - + #expect(response.error != nil) - + // WHEN row appears after error response.onRowAppeared(response.items[15]) #expect(response.isLoading == false) // THEN no additional load attempts are made } - + @Test func deleteItem() async throws { // GIVEN let response = try await DataViewPaginatedResponse { _ in @@ -343,18 +343,18 @@ import WordPressUI nextPage: nil ) } - + #expect(response.items.count == 3) - + // WHEN response.deleteItem(withID: 2) - + // THEN #expect(response.items.count == 2) #expect(response.items.map(\.id) == [1, 3]) #expect(response.total == 2) // Total is updated } - + @Test func deleteNonExistentItem() async throws { // GIVEN let response = try await DataViewPaginatedResponse { _ in @@ -368,10 +368,10 @@ import WordPressUI nextPage: nil ) } - + // WHEN deleting non-existent item response.deleteItem(withID: 999) - + // THEN nothing changes #expect(response.items.count == 2) #expect(response.items.map(\.id) == [1, 2]) From c07e5bca850d37f0b6178dbebdc761b1d6442080 Mon Sep 17 00:00:00 2001 From: kean Date: Tue, 17 Jun 2025 15:32:52 -0400 Subject: [PATCH 11/90] Fix typos and address comments --- .../Views/DataView/DataViewPaginatedResponse.swift | 2 +- .../Subscribers/Details/SubscriberDetailsViewModel.swift | 6 +++--- .../Blog/Subscribers/Helpers/SubscribersBlog.swift | 2 +- .../Blog/Subscribers/Invite/SubscriberInviteView.swift | 2 +- .../Blog/Subscribers/List/SubscriberRowView.swift | 2 +- .../Blog/Subscribers/List/SubscribersViewModel.swift | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift index 20898b13f23c..2ac6ee6d8299 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift @@ -99,7 +99,7 @@ public final class DataViewPaginatedResponse: /// /// - Parameter row: The row that appeared. public func onRowAppeared(_ row: Element) { - guard items.suffix(16).contains(where: { $0.id == row.id }) else { + guard items.suffix(10).contains(where: { $0.id == row.id }) else { return } if error == nil { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift index b3a4ea731fdd..c5de3055d079 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift @@ -32,17 +32,17 @@ struct SubscriberDetailsViewModel { } func getDetails() async throws -> SubscribersServiceRemote.GetSubscriberDetailsResponse { - try await blog.maketSubscribersService() + try await blog.makeSubscribersService() .getSubsciberDetails(siteID: blog.dotComSiteID, subscriberID: subscriberID) } func getStats() async throws -> SubscribersServiceRemote.GetSubscriberStatsResponse { - try await blog.maketSubscribersService() + try await blog.makeSubscribersService() .getSubsciberStats(siteID: blog.dotComSiteID, subscriberID: subscriberID) } func delete(_ subscriber: SubscribersServiceRemote.SubsciberBasicInfoResponse) async throws { - try await blog.maketSubscribersService() + try await blog.makeSubscribersService() .deleteSubscriber(subscriber, siteID: blog.dotComSiteID) } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift index da60d7b0b9d7..30ae60184248 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift @@ -25,7 +25,7 @@ struct SubscribersBlog { SubscribersBlog(dotComSiteID: 1, getRestAPI: { nil }) } - func maketSubscribersService() throws -> SubscribersServiceRemote { + func makeSubscribersService() throws -> SubscribersServiceRemote { guard let api = getRestAPI() else { throw URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: SharedStrings.Error.generic]) } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift index 6b5e2ccbfc94..b1ffec7715ae 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Invite/SubscriberInviteView.swift @@ -104,7 +104,7 @@ struct SubscriberInviteView: View { isSending = true Task { do { - let service = try blog.maketSubscribersService() + let service = try blog.makeSubscribersService() try await service.importSubscribers(siteID: blog.dotComSiteID, emails: emails) UINotificationFeedbackGenerator().notificationOccurred(.success) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift index d3e0c88d35c6..4939f250aa34 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscriberRowView.swift @@ -94,7 +94,7 @@ final class SubscriberRowViewModel: @preconcurrency Identifiable { isDeleting = true Task { do { - try await blog.maketSubscribersService() + try await blog.makeSubscribersService() .deleteSubscriber(subscriber, siteID: blog.dotComSiteID) UINotificationFeedbackGenerator().notificationOccurred(.success) } catch { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift index 94d0d210b36b..d18e758a1411 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift @@ -75,7 +75,7 @@ final class SubscribersViewModel: ObservableObject { search: String? = nil ) async throws -> SubscribersPaginatedResponse { return try await SubscribersPaginatedResponse { [blog] page in - let service = try blog.maketSubscribersService() + let service = try blog.makeSubscribersService() let response = try await service.getSubscribers( siteID: blog.dotComSiteID, page: page ?? 1, From 96f9cfd970a56706f1ec8f28b63d56bb54b2d66f Mon Sep 17 00:00:00 2001 From: kean Date: Wed, 18 Jun 2025 16:10:03 -0400 Subject: [PATCH 12/90] Implement new Jetpack Activity Logs screen --- .claude/settings.json | 17 ++ CLAUDE.md | 2 +- Modules/Package.resolved | 4 +- Modules/Package.swift | 2 +- .../DataView/DataViewPaginatedForEach.swift | 4 +- .../DataView/DataViewPaginatedResponse.swift | 10 +- .../Views/DataView/DataViewSearchView.swift | 63 +++++++ .../WordPressUI/Views/EmptyStateView.swift | 25 +++ .../EmptyStateView+Extensions.swift | 27 --- WordPress/Classes/Utility/SharedStrings.swift | 3 +- .../JetpackActivityLogViewController.swift | 1 - .../Activity/List/ActivityLogRowView.swift | 86 +++++++++ .../List/ActivityLogRowViewModel.swift | 39 ++++ .../Activity/List/ActivityLogsMenu.swift | 138 ++++++++++++++ .../Activity/List/ActivityLogsView.swift | 129 +++++++++++++ .../List/ActivityLogsViewController.swift | 22 +++ .../Activity/List/ActivityLogsViewModel.swift | 154 ++++++++++++++++ .../List/ActivityTypeSelectionView.swift | 169 ++++++++++++++++++ .../DashboardActivityLogCardCell.swift | 9 +- .../BlogDetailsViewController+Swift.swift | 9 +- .../Subscribers/List/SubscribersMenu.swift | 4 +- .../Subscribers/List/SubscribersView.swift | 35 +--- .../List/SubscribersViewModel.swift | 9 +- 23 files changed, 884 insertions(+), 77 deletions(-) create mode 100644 .claude/settings.json create mode 100644 Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift delete mode 100644 WordPress/Classes/Extensions/EmptyStateView+Extensions.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000000..3ea346da52b2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(ls:*)", + "Bash(rg:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(tree:*)", + "Bash(git:log,status,diff,branch)", + ], + "deny": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 25ccd77c9b10..e69c9ec831a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ WordPress-iOS uses a modular architecture with the main app and separate Swift p - Use strict access control modifiers where possible - Use four spaces (not tabs) -### Development Workflow +## Development Workflow - Branch from `trunk` (main branch) - PR target should be `trunk` - When writing commit messages, never include references to Claude diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 1ded252e6fb7..22d66276f8bd 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "29e01dfb9ab627b32c39ba2dee8ef4ff18afa7f8ac3ba52560cf900d6d11368c", + "originHash" : "1aecad5b79a89459675bbe1705ca2f091cedc0a3874c3ce3e07aef3d60f2b061", "pins" : [ { "identity" : "alamofire", @@ -390,7 +390,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "cc7fd8a7ea609fc139e7b9d9f53b12c51002ddf4" + "revision" : "30dadcab01a980eb16976340c1e9e8a9527ddc05" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 11396b5c0e03..c5ef1152b7a9 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "cc7fd8a7ea609fc139e7b9d9f53b12c51002ddf4" // see wpios-edition branch + revision: "30dadcab01a980eb16976340c1e9e8a9527ddc05" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift index 0589aadb2a49..7d7eb79abd31 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift @@ -29,9 +29,7 @@ public struct DataViewPaginatedForEach: DataViewPaginatedResponseProtocol { - @Published public private(set) var total = 0 + @Published public private(set) var total: Int? @Published public private(set) var items: [Element] = [] @Published public private(set) var hasMore = true @Published public private(set) var isLoading = false @@ -26,11 +26,11 @@ public final class DataViewPaginatedResponse: /// Result of a paginated load operation. public struct Page { public let items: [Element] - public let total: Int + public let total: Int? public let hasMore: Bool public let nextPage: PageIndex? - public init(items: [Element], total: Int, hasMore: Bool, nextPage: PageIndex?) { + public init(items: [Element], total: Int? = nil, hasMore: Bool, nextPage: PageIndex?) { self.items = items self.total = total self.hasMore = hasMore @@ -115,6 +115,8 @@ public final class DataViewPaginatedResponse: return } items.remove(at: index) - total -= 1 + if let total { + self.total = total - 1 + } } } diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift new file mode 100644 index 000000000000..4d486b80c19a --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// A generic search view that works with DataViewPaginatedResponse. +/// Provides search functionality with debouncing, loading states, and error handling. +public struct DataViewSearchView: View { + /// The search text to monitor for changes + let searchText: String + + /// The async function to perform the search + let search: () async throws -> Response + + /// Content builder for the paginated list + let content: (Response) -> Content + + /// Delay in milliseconds before executing search (default: 500ms) + let debounceDelay: UInt64 + + @State private var response: Response? + @State private var error: Error? + + public init( + searchText: String, + debounceDelay: UInt64 = 500, + search: @escaping () async throws -> Response, + @ViewBuilder content: @escaping (Response) -> Content + ) { + self.searchText = searchText + self.debounceDelay = debounceDelay + self.search = search + self.content = content + } + + public var body: some View { + List { + if let response { + content(response) + } else if error == nil { + DataViewPagingFooterView(.loading) + } + } + .listStyle(.plain) + .overlay { + if let response, response.items.isEmpty { + EmptyStateView.search() + } else if let error { + EmptyStateView.failure(error: error) + } + } + .task(id: searchText) { + error = nil + do { + try await Task.sleep(for: .milliseconds(debounceDelay)) + let response = try await search() + guard !Task.isCancelled else { return } + self.response = response + } catch { + guard !Task.isCancelled else { return } + self.response = nil + self.error = error + } + } + } +} diff --git a/Modules/Sources/WordPressUI/Views/EmptyStateView.swift b/Modules/Sources/WordPressUI/Views/EmptyStateView.swift index fe0df57afb0a..6317c3025caa 100644 --- a/Modules/Sources/WordPressUI/Views/EmptyStateView.swift +++ b/Modules/Sources/WordPressUI/Views/EmptyStateView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressShared public struct EmptyStateView: View { @ViewBuilder let label: () -> Label @@ -73,6 +74,30 @@ private struct EmptyStateViewLabelStyle: LabelStyle { } } +extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == EmptyView { + public static func search() -> Self { + EmptyStateView( + AppLocalizedString("emptyStateView.noSearchResult.title", value: "No Results", comment: "Shared empty state view"), + systemImage: "magnifyingglass", + description: AppLocalizedString("emptyStateView.noSearchResult.description", value: "Try a new search", comment: "Shared empty state view") + ) + } +} + +extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == Button? { + public static func failure(error: Error, onRetry: (() -> Void)? = nil) -> Self { + EmptyStateView { + Label(AppLocalizedString("shared.error.generic", value: "Something went wrong", comment: "A generic error message"), systemImage: "exclamationmark.circle") + } description: { + Text(error.localizedDescription) + } actions: { + if let onRetry { + Button(AppLocalizedString("shared.button.retry", value: "Retry", comment: "A shared button title used in different contexts"), action: onRetry) + } + } + } +} + #Preview("Standard") { EmptyStateView("You don't have any tags", systemImage: "magnifyingglass", description: "Tags created here can be easily added to new posts") } diff --git a/WordPress/Classes/Extensions/EmptyStateView+Extensions.swift b/WordPress/Classes/Extensions/EmptyStateView+Extensions.swift deleted file mode 100644 index 5fe00fa7502f..000000000000 --- a/WordPress/Classes/Extensions/EmptyStateView+Extensions.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import SwiftUI -import WordPressUI - -extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == EmptyView { - static func search() -> Self { - EmptyStateView( - NSLocalizedString("emptyStateView.noSearchResult.title", value: "No Results", comment: "Shared empty state view"), - systemImage: "magnifyingglass", - description: NSLocalizedString("emptyStateView.noSearchResult.description", value: "Try a new search", comment: "Shared empty state view") - ) - } -} - -extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == Button? { - static func failure(error: Error, onRetry: (() -> Void)? = nil) -> Self { - EmptyStateView { - Label(SharedStrings.Error.generic, systemImage: "exclamationmark.circle") - } description: { - Text(error.localizedDescription) - } actions: { - if let onRetry { - Button(SharedStrings.Button.retry, action: onRetry) - } - } - } -} diff --git a/WordPress/Classes/Utility/SharedStrings.swift b/WordPress/Classes/Utility/SharedStrings.swift index 423770a10f3e..853128af78b6 100644 --- a/WordPress/Classes/Utility/SharedStrings.swift +++ b/WordPress/Classes/Utility/SharedStrings.swift @@ -20,6 +20,7 @@ enum SharedStrings { static let copyLink = NSLocalizedString("shared.button.copyLink", value: "Copy Link", comment: "A shared button title used in different contexts") static let `continue` = NSLocalizedString("shared.button.continue", value: "Continue", comment: "A shared button title used in different contexts") static let undo = NSLocalizedString("shared.button.undo", value: "Undo", comment: "A shared button title used in different contexts") + static let clear = NSLocalizedString("shared.button.clear", value: "Clear", comment: "A shared button title used in different contexts") } enum Misc { @@ -36,7 +37,7 @@ enum SharedStrings { } enum Error { - static let generic = NSLocalizedString("shared.error.geneirc", value: "Something went wrong", comment: "A generic error message") + static let generic = NSLocalizedString("shared.error.generic", value: "Something went wrong", comment: "A generic error message") static let refreshFailed = NSLocalizedString("shared.error.failiedToReloadData", value: "Failed to update data", comment: "A generic error title indicating that a screen failed to fetch the latest data") } diff --git a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift index 72b47d9268c4..eef433e3fa7b 100644 --- a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift @@ -38,7 +38,6 @@ class JetpackActivityLogViewController: BaseActivityListViewController { guard let siteRef = JetpackSiteRef(blog: blog) else { return nil } - let isFreeWPCom = blog.isHostedAtWPcom && !blog.hasPaidPlan self.init(site: siteRef, store: StoreContainer.shared.activity, isFreeWPCom: isFreeWPCom) } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift new file mode 100644 index 000000000000..1a31068c70ae --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import WordPressUI + +struct ActivityLogRowView: View { + let viewModel: ActivityLogRowViewModel + + var body: some View { + HStack(alignment: .center, spacing: 12) { + icon + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(viewModel.subtitle) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Spacer() + Text(viewModel.time) + .font(.caption2) + .foregroundColor(.secondary) + } + + Text(viewModel.title) + .font(.subheadline) + .lineLimit(2) + + if let actor = viewModel.actor { + HStack(spacing: 6) { + avatar + HStack(spacing: 4) { + Text(actor) + .font(.footnote) + .foregroundColor(.secondary) + if let role = viewModel.actorRole { + Text("·") + .font(.footnote) + .foregroundColor(.secondary) + Text(role) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + .padding(.top, 4) + } + } + } + } + + private var avatar: some View { + Group { + if let avatarURL = viewModel.actorAvatarURL { + AvatarView(style: .single(avatarURL), diameter: 16) + } else if viewModel.actor?.lowercased() == "jetpack" { + Image("icon-jetpack") + .resizable() + } else { + Circle() + .fill(Color(.secondarySystemBackground)) + .overlay( + Text((viewModel.actor ?? "").prefix(1).uppercased()) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.secondary) + ) + } + } + .frame(width: 16, height: 16) + } + + private var icon: some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(viewModel.tintColor.opacity(0.15)) + .frame(width: 36, height: 36) + + if let iconImage = viewModel.icon { + Image(uiImage: iconImage) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundColor(viewModel.tintColor) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift new file mode 100644 index 000000000000..8b82267914fb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -0,0 +1,39 @@ +import Foundation +import SwiftUI +import UIKit +import WordPressKit +import WordPressUI +import FormattableContentKit + +struct ActivityLogRowViewModel: Identifiable { + let id: String + let actorAvatarURL: URL? + var actor: String? + var actorRole: String? + let title: String + let subtitle: String + let date: Date + let time: String + let icon: UIImage? + let tintColor: Color + let activity: Activity + + init(activity: Activity) { + self.activity = activity + self.id = activity.activityID + self.actorAvatarURL = activity.actor.flatMap { URL(string: $0.avatarURL) } + if let actor = activity.actor { + self.actor = actor.displayName + if !actor.role.isEmpty { + self.actorRole = actor.role.localizedCapitalized + } + } + self.date = activity.published + self.time = activity.published.formatted(date: .omitted, time: .shortened) + self.title = activity.text + self.subtitle = activity.summary.localizedCapitalized + + self.icon = WPStyleGuide.ActivityStyleGuide.getIconForActivity(activity) + self.tintColor = Color(WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity)) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift new file mode 100644 index 000000000000..7b7d26780941 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift @@ -0,0 +1,138 @@ +import SwiftUI +import WordPressKit + +struct ActivityLogsMenu: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + @State private var isShowingActivityTypePicker = false + @State private var isShowingStartDatePicker = false + @State private var isShowingEndDatePicker = false + + var body: some View { + Menu { + Section { + dateFilters + activityTypeFilter + if !viewModel.parameters.isEmpty { + resetFiltersButton + } + } + } label: { + Image(systemName: "ellipsis") + } + .sheet(isPresented: $isShowingActivityTypePicker) { + NavigationView { + ActivityTypeSelectionView(viewModel: viewModel) + } + } + .sheet(isPresented: $isShowingStartDatePicker) { + DatePickerSheet( + title: Strings.startDate, + selection: $viewModel.parameters.startDate, + isPresented: $isShowingStartDatePicker + ) + } + .sheet(isPresented: $isShowingEndDatePicker) { + DatePickerSheet( + title: Strings.endDate, + selection: $viewModel.parameters.endDate, + isPresented: $isShowingEndDatePicker + ) + } + } + + private var dateFilters: some View { + Group { + // Start Date + Button { + isShowingStartDatePicker = true + } label: { + Text(Strings.startDate) + if let date = viewModel.parameters.startDate { + Text(date.formatted(date: .abbreviated, time: .shortened)) + } + Image(systemName: "calendar") + } + + // End Date + Button { + isShowingEndDatePicker = true + } label: { + Text(Strings.endDate) + if let date = viewModel.parameters.endDate { + Text(date.formatted(date: .abbreviated, time: .shortened)) + } + Image(systemName: "calendar") + } + } + } + + private var activityTypeFilter: some View { + Button { + isShowingActivityTypePicker = true + } label: { + Text(Strings.activityTypes) + if !viewModel.parameters.activityTypes.isEmpty { + Text("\(viewModel.parameters.activityTypes.count)") + } + Image(systemName: "list.bullet") + } + } + + private var resetFiltersButton: some View { + Button(role: .destructive) { + viewModel.parameters = GetActivityLogsParameters() + } label: { + Label(Strings.resetFilters, systemImage: "arrow.counterclockwise") + } + } +} + +private struct DatePickerSheet: View { + let title: String + @Binding var selection: Date? + @Binding var isPresented: Bool + + @State private var date = Date() + + var body: some View { + NavigationView { + picker + .frame(maxHeight: .infinity, alignment: .top) + } + .onAppear { + date = selection ?? Date() + } + } + + private var picker: some View { + DatePicker(title, selection: $date, displayedComponents: [.date, .hourAndMinute]) + .datePickerStyle(.graphical) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(SharedStrings.Button.done) { + selection = date + isPresented = false + } + .fontWeight(.semibold) + } + ToolbarItem(placement: .bottomBar) { + Button(role: .destructive) { + selection = nil + isPresented = false + } label: { + Text(SharedStrings.Button.clear) + } + } + } + } +} + +private enum Strings { + static let startDate = NSLocalizedString("activityLogs.filter.startDate", value: "Start Date", comment: "Start date filter label") + static let endDate = NSLocalizedString("activityLogs.filter.endDate", value: "End Date", comment: "End date filter label") + static let activityTypes = NSLocalizedString("activityLogs.filter.activityTypes", value: "Activity Types", comment: "Activity types filter label") + static let resetFilters = NSLocalizedString("activityLogs.filter.reset", value: "Reset Filters", comment: "Reset filters button label") +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift new file mode 100644 index 000000000000..89535025f845 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -0,0 +1,129 @@ +import SwiftUI +import WordPressUI +import WordPressKit + +struct ActivityLogsView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + Group { + if !viewModel.searchText.isEmpty { + ActivityLogsSearchView(viewModel: viewModel) + } else { + ActivityLogsListView(viewModel: viewModel) + } + } + .searchable(text: $viewModel.searchText) + } +} + +private struct ActivityLogsListView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + List { + if let response = viewModel.response { + ActivityLogsPaginatedForEach(response: response) + + if viewModel.isFreePlan { + Text(Strings.freePlanNotice) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .listRowSeparator(.hidden) + } + } + } + .listStyle(.plain) + .overlay { + if let response = viewModel.response { + if response.isEmpty { + EmptyStateView(Strings.empty, systemImage: "archivebox") + } + } else if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + EmptyStateView.failure(error: error) { + Task { await viewModel.refresh() } + } + } + } + .onAppear { + viewModel.onAppear() + } + .refreshable { + await viewModel.refresh() + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + ActivityLogsMenu(viewModel: viewModel) + } + } + } +} + +private struct ActivityLogsSearchView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + DataViewSearchView( + searchText: viewModel.searchText, + search: viewModel.search + ) { response in + ActivityLogsPaginatedForEach(response: response) + } + } +} + +private struct ActivityLogsPaginatedForEach: View { + @ObservedObject var response: ActivityLogsPaginatedResponse + + struct ActivityGroup: Identifiable { + var id: Date { date } + let date: Date + var title: String { date.formatted(date: .long, time: .omitted) } + let items: [ActivityLogRowViewModel] + } + + private var groupedItems: [ActivityGroup] { + let grouped = Dictionary(grouping: response.items) { item in + Calendar.current.startOfDay(for: item.date) + } + return grouped.map { ActivityGroup(date: $0.key, items: $0.value) } + .sorted { $0.date > $1.date } + } + + var body: some View { + ForEach(groupedItems) { group in + Section(group.title) { + ForEach(group.items) { + makeRow(with: $0) + .listRowSeparator($0.id == group.items.first?.id ? .hidden : .automatic, edges: .top) + } + } + } + if response.isLoading { + DataViewPagingFooterView(.loading) + } else if response.error != nil { + DataViewPagingFooterView(.failure) + .onRetry { response.loadMore() } + } + } + + private func makeRow(with item: ActivityLogRowViewModel) -> some View { + ActivityLogRowView(viewModel: item) + .onAppear { response.onRowAppeared(item) } + .background { + NavigationLink { + // TODO: Update to show ActivityDetailViewController + } label: { + EmptyView() + }.opacity(0) + } + } +} + +private enum Strings { + static let empty = NSLocalizedString("activityLogs.empty", value: "No Activity", comment: "Empty state message for activity logs") + static let freePlanNotice = NSLocalizedString("activityLogs.freePlan.notice", value: "Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Notice shown to free plan users about limited activity log events") +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift new file mode 100644 index 000000000000..6b5905cda685 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift @@ -0,0 +1,22 @@ +import UIKit +import SwiftUI +import WordPressUI +import WordPressKit + +final class ActivityLogsViewController: UIHostingController { + private let viewModel: ActivityLogsViewModel + + init(blog: Blog) { + self.viewModel = ActivityLogsViewModel(blog: blog) + super.init(rootView: AnyView(ActivityLogsView(viewModel: viewModel))) + self.title = Strings.title + } + + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private enum Strings { + static let title = NSLocalizedString("activity.logs.title", value: "Activity", comment: "Title for the activity logs screen") +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift new file mode 100644 index 000000000000..06fd2acd9095 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -0,0 +1,154 @@ +import Foundation +import WordPressKit +import WordPressUI + +typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse + +@MainActor +final class ActivityLogsViewModel: ObservableObject { + let blog: Blog + + @Published var searchText = "" + @Published var parameters = GetActivityLogsParameters() { + didSet { + response = nil + onRefreshNeeded() + } + } + @Published var response: ActivityLogsPaginatedResponse? + @Published var isLoading = false + @Published var error: Error? + + private var refreshTask: Task? + + var isFreePlan: Bool { + blog.isHostedAtWPcom && !blog.hasPaidPlan + } + + init(blog: Blog) { + self.blog = blog + } + + func onAppear() { + guard response == nil else { return } + onRefreshNeeded() + } + + func onRefreshNeeded() { + refreshTask?.cancel() + refreshTask = Task { + await refresh() + } + } + + func refresh() async { + isLoading = true + error = nil + Task { + do { + let response = try await makeResponse(searchText: searchText, parameters: parameters) + guard !Task.isCancelled else { return } + self.isLoading = false + self.response = response + } catch { + guard !Task.isCancelled else { return } + self.isLoading = false + self.error = error + if response != nil { + Notice(error: error).post() + } + } + } + } + + func search() async throws -> ActivityLogsPaginatedResponse { + try await makeResponse(searchText: searchText, parameters: parameters) + } + + func fetchActivityGroups(after: Date? = nil, before: Date? = nil) async throws -> [WordPressKit.ActivityGroup] { + guard let siteID = blog.dotComID?.intValue, + let api = blog.wordPressComRestApi else { + throw NSError(domain: "ActivityLogs", code: 0, userInfo: [NSLocalizedDescriptionKey: "Site ID or API not available"]) + } + + let service = ActivityServiceRemote(wordPressComRestApi: api) + let groups = try await service.getActivityGroups( + siteID: siteID, + after: after, + before: before + ) + return groups.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private func makeResponse(searchText: String?, parameters: GetActivityLogsParameters) async throws -> ActivityLogsPaginatedResponse { + try await ActivityLogsPaginatedResponse { [blog] offset in + guard let siteID = blog.dotComID?.intValue, + let api = blog.wordPressComRestApi else { + throw NSError(domain: "ActivityLogs", code: 0, userInfo: [NSLocalizedDescriptionKey: SharedStrings.Error.generic]) + } + let service = ActivityServiceRemote(wordPressComRestApi: api) + let offset = offset ?? 0 + let (activities, hasMore) = try await service.getActivities( + siteID: siteID, + offset: offset, + pageSize: 32, + searchText: searchText, + parameters: parameters + ) + let viewModels = await makeViewModels(for: activities) + return ActivityLogsPaginatedResponse.Page( + items: viewModels, + hasMore: hasMore, + nextPage: hasMore ? offset + activities.count : nil + ) + } + } +} + +private func makeViewModels(for activities: [Activity]) async -> [ActivityLogRowViewModel] { + activities.map(ActivityLogRowViewModel.init) +} + +struct GetActivityLogsParameters: Hashable { + var startDate: Date? + var endDate: Date? + var activityTypes: Set = [] + + var isEmpty: Bool { + startDate == nil && endDate == nil && activityTypes.isEmpty + } +} + +private extension ActivityServiceRemote { + func getActivities(siteID: Int, offset: Int, pageSize: Int, searchText: String? = nil, parameters: GetActivityLogsParameters = .init()) async throws -> ([Activity], hasMore: Bool) { + return try await withCheckedThrowingContinuation { continuation in + getActivityForSite( + siteID, + offset: offset, + count: pageSize, + after: parameters.startDate, + before: parameters.endDate, + group: Array(parameters.activityTypes), + searchText: searchText + ) { activities, hasMore in + continuation.resume(returning: (activities, hasMore)) + } failure: { error in + continuation.resume(throwing: error) + } + } + } + + func getActivityGroups(siteID: Int, after: Date? = nil, before: Date? = nil) async throws -> [WordPressKit.ActivityGroup] { + try await withCheckedThrowingContinuation { continuation in + getActivityGroupsForSite( + siteID, + after: after, + before: before + ) { groups in + continuation.resume(returning: groups) + } failure: { error in + continuation.resume(throwing: error) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift new file mode 100644 index 000000000000..753c52396d2a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift @@ -0,0 +1,169 @@ +import SwiftUI +import WordPressKit +import WordPressUI + +struct ActivityTypeSelectionView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + @Environment(\.dismiss) private var dismiss + @State private var selectedTypes: Set + @State private var availableActivityGroups: [WordPressKit.ActivityGroup] = [] + @State private var isLoading = false + @State private var error: Error? + + init(viewModel: ActivityLogsViewModel) { + self.viewModel = viewModel + self._selectedTypes = State(initialValue: viewModel.parameters.activityTypes) + } + + var body: some View { + Group { + if isLoading && availableActivityGroups.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = error, availableActivityGroups.isEmpty { + EmptyStateView.failure(error: error) { + Task { await fetchActivityGroups() } + } + } else if availableActivityGroups.isEmpty { + EmptyStateView( + Strings.emptyActivityTypes, + systemImage: "list.bullet" + ) + } else { + List { + Section { + selectionControlsSection + } + Section { + activityTypesSection + } + } + } + } + .onAppear { + Task { await fetchActivityGroups() } + } + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + cancelButton + } + ToolbarItem(placement: .confirmationAction) { + doneButton + } + } + } + + // MARK: - View Components + + private var selectionControlsSection: some View { + HStack { + Button(Strings.selectAll) { + selectedTypes = Set(availableActivityGroups.map { $0.key }) + } + .disabled(selectedTypes.count == availableActivityGroups.count) + + Spacer() + + Button(Strings.deselectAll) { + selectedTypes.removeAll() + } + .disabled(selectedTypes.isEmpty) + } + .font(.subheadline) + } + + private var activityTypesSection: some View { + ForEach(availableActivityGroups, id: \.key) { group in + ActivityTypeRow( + group: group, + isSelected: selectedTypes.contains(group.key), + onToggle: { toggleSelection(for: group.key) } + ) + } + } + + private var cancelButton: some View { + Button(SharedStrings.Button.cancel) { + dismiss() + } + } + + private var doneButton: some View { + Button(SharedStrings.Button.done) { + viewModel.parameters.activityTypes = selectedTypes + dismiss() + } + .fontWeight(.semibold) + } + + // MARK: - Helper Methods + + private func toggleSelection(for key: String) { + if selectedTypes.contains(key) { + selectedTypes.remove(key) + } else { + selectedTypes.insert(key) + } + } + + private func fetchActivityGroups() async { + isLoading = true + error = nil + + do { + let groups = try await viewModel.fetchActivityGroups( + after: viewModel.parameters.startDate, + before: viewModel.parameters.endDate + ) + availableActivityGroups = groups + isLoading = false + } catch { + self.error = error + isLoading = false + } + } +} + +// MARK: - Activity Type Row + +private struct ActivityTypeRow: View { + let group: WordPressKit.ActivityGroup + let isSelected: Bool + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + HStack { + groupInfo + Spacer() + selectionIndicator + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var groupInfo: some View { + HStack { + Text(group.name) + Spacer() + Text("\(group.count)") + .foregroundColor(.secondary) + } + } + + private var selectionIndicator: some View { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .accentColor : Color(.separator)) + .imageScale(.large) + } +} + +private enum Strings { + static let title = NSLocalizedString("activityLogs.activityTypes.title", value: "Activity Types", comment: "Activity type selection screen title") + static let selectAll = NSLocalizedString("activityLogs.activityTypes.selectAll", value: "Select All", comment: "Select all button") + static let deselectAll = NSLocalizedString("activityLogs.activityTypes.deselectAll", value: "Deselect All", comment: "Deselect all button") + static let emptyActivityTypes = NSLocalizedString("activityLogs.activityTypes.empty", value: "No activity types available", comment: "Empty state message when no activity types are available") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift index c86090074a2c..b6bc1f58fbd9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift @@ -124,9 +124,16 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { // MARK: - Navigation private func showActivityLog(for blog: Blog, tapSource: String) { - guard let activityLogController = JetpackActivityLogViewController(blog: blog) else { + let activityLogController: UIViewController + + if FeatureFlag.dataViews.enabled { + activityLogController = ActivityLogsViewController(blog: blog) + } else if let jetpackController = JetpackActivityLogViewController(blog: blog) { + activityLogController = jetpackController + } else { return } + presentingViewController?.navigationController?.pushViewController(activityLogController, animated: true) WPAnalytics.track(.activityLogViewed, diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 93c0a121b08f..321c90ee284d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -149,9 +149,16 @@ extension BlogDetailsViewController { } @objc public func showActivity() { - guard let controller = JetpackActivityLogViewController(blog: blog) else { + let controller: UIViewController + + if FeatureFlag.dataViews.enabled { + controller = ActivityLogsViewController(blog: blog) + } else if let jetpackController = JetpackActivityLogViewController(blog: blog) { + controller = jetpackController + } else { return wpAssertionFailure("failed to instantiate") } + controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift index 30e90aef141d..efb5afcaf906 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift @@ -10,8 +10,8 @@ struct SubscribersMenu: View { filterByEmailSubscriptionType filterByPaymenetType } - if let response = viewModel.response { - Text("\(Strings.subscribers) \(viewModel.makeFormattedSubscribersCount(for: response))") + if let response = viewModel.response, let count = viewModel.makeFormattedSubscribersCount(for: response) { + Text("\(Strings.subscribers) \(count)") } } label: { Image(systemName: "ellipsis.circle") diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift index ab0abb7da4ce..4840aab8ff8d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift @@ -87,37 +87,12 @@ private struct SubscribersListView: View { private struct SubscribersSearchView: View { @ObservedObject var viewModel: SubscribersViewModel - @State private var response: SubscribersPaginatedResponse? - @State private var error: Error? - var body: some View { - List { - if let response { - SubscribersPaginatedForEach(response: response) - } else if error == nil { - DataViewPagingFooterView(.loading) - } - } - .listStyle(.plain) - .overlay { - if let response, response.isEmpty { - EmptyStateView.search() - } else if let error { - EmptyStateView.failure(error: error) - } - } - .task(id: viewModel.searchText) { - error = nil - do { - try await Task.sleep(for: .milliseconds(500)) - let response = try await viewModel.search() - guard !Task.isCancelled else { return } - self.response = response - } catch { - guard !Task.isCancelled else { return } - self.response = nil - self.error = error - } + DataViewSearchView( + searchText: viewModel.searchText, + search: viewModel.search + ) { response in + SubscribersPaginatedForEach(response: response) } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift index d18e758a1411..39fcc4c3ca26 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift @@ -63,11 +63,14 @@ final class SubscribersViewModel: ObservableObject { try await makeResponse(parameters: parameters, search: searchText) } - func makeFormattedSubscribersCount(for response: SubscribersPaginatedResponse) -> String { + func makeFormattedSubscribersCount(for response: SubscribersPaginatedResponse) -> String? { + guard let count = response.total else { + return nil + } guard !parameters.filters.isEmpty, let totalCount else { - return "\(response.total)" + return "\(count)" } - return String(format: Strings.nOutOf, response.total.description, totalCount.description) + return String(format: Strings.nOutOf, count.description, totalCount.description) } private func makeResponse( From 79dd91c11dee975b9969e54df78e20193add208c Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 08:22:14 -0400 Subject: [PATCH 13/90] Fix SwiftLint warnings --- .../ViewRelated/Activity/List/ActivityTypeSelectionView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift index 753c52396d2a..e52d60b5a7f4 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift @@ -20,7 +20,7 @@ struct ActivityTypeSelectionView: View { if isLoading && availableActivityGroups.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error, availableActivityGroups.isEmpty { + } else if let error, availableActivityGroups.isEmpty { EmptyStateView.failure(error: error) { Task { await fetchActivityGroups() } } From 2b0dba8a8722e58d1a080fff48dd26fa1fb087bc Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 08:40:16 -0400 Subject: [PATCH 14/90] Add Miniature app --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 36 ++ .../AppIcon.appiconset/miniture-icon.png | Bin 0 -> 151648 bytes .../Miniature/Assets.xcassets/Contents.json | 6 + Sources/Miniature/ContentView.swift | 17 + Sources/Miniature/MiniatureApp.swift | 10 + Tests/MiniatureTests/MiniatureTests.swift | 18 + WordPress/WordPress.xcodeproj/project.pbxproj | 444 +++++++++++++++++- 8 files changed, 531 insertions(+), 11 deletions(-) create mode 100644 Sources/Miniature/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Sources/Miniature/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Sources/Miniature/Assets.xcassets/AppIcon.appiconset/miniture-icon.png create mode 100644 Sources/Miniature/Assets.xcassets/Contents.json create mode 100644 Sources/Miniature/ContentView.swift create mode 100644 Sources/Miniature/MiniatureApp.swift create mode 100644 Tests/MiniatureTests/MiniatureTests.swift diff --git a/Sources/Miniature/Assets.xcassets/AccentColor.colorset/Contents.json b/Sources/Miniature/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000000..eb8789700816 --- /dev/null +++ b/Sources/Miniature/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Miniature/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Miniature/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..0189653a478e --- /dev/null +++ b/Sources/Miniature/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "miniture-icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Miniature/Assets.xcassets/AppIcon.appiconset/miniture-icon.png b/Sources/Miniature/Assets.xcassets/AppIcon.appiconset/miniture-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..af783225dbea7ce3608a16362cdc4d502fc63945 GIT binary patch literal 151648 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

5TjKBr%Pr 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

HbqLQu1; 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®qbMfsV?jmb!%N=0=%jNs{F9FJ7$^gE1 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 Date: Thu, 19 Jun 2025 08:42:02 -0400 Subject: [PATCH 15/90] Add XcodeTarget_App as a dependency --- Sources/Miniature/ContentView.swift | 16 ++--- WordPress/WordPress.xcodeproj/project.pbxproj | 72 ++++++++++++++++--- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index b000a7e46182..4368e029973e 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -1,17 +1,17 @@ import SwiftUI +import WordPressUI +import WordPressData +import WordPressShared +import WordPressKit struct ContentView: View { var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() + Text("Hello, world!") } } #Preview { - ContentView() + NavigationView { + ContentView() + } } diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 3926499dde26..76c32fedc624 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -87,6 +87,12 @@ 0CCA995C2DAD76D80048F0A9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4A690C142BA790BC00A8E0C5 /* PrivacyInfo.xcprivacy */; }; 0CCA995D2DAD76E00048F0A9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 931DF4D818D09A2F00540BDD /* InfoPlist.strings */; }; 0CCA995E2DAD76E00048F0A9 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E1D91454134A853D0089019C /* Localizable.strings */; }; + 0CECA92B2E043CEC00F4EE83 /* XcodeTarget_App in Frameworks */ = {isa = PBXBuildFile; productRef = 0CECA92A2E043CEC00F4EE83 /* XcodeTarget_App */; }; + 0CECA92D2E043D0200F4EE83 /* XcodeTarget_App in Frameworks */ = {isa = PBXBuildFile; productRef = 0CECA92C2E043D0200F4EE83 /* XcodeTarget_App */; }; + 0CECA92E2E043D2800F4EE83 /* WordPressData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F7AE0B52D9B30A100AB4892 /* WordPressData.framework */; }; + 0CECA92F2E043D2800F4EE83 /* WordPressData.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3F7AE0B52D9B30A100AB4892 /* WordPressData.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 0CECA9332E043D3700F4EE83 /* WordPressAuthenticator.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AD953B42C21451700D0EEFA /* WordPressAuthenticator.framework */; }; + 0CECA9342E043D3700F4EE83 /* WordPressAuthenticator.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4AD953B42C21451700D0EEFA /* WordPressAuthenticator.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0CED2AD92D95BB46003015CF /* Gutenberg.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F60D3902D2C4BA3008ACD86 /* Gutenberg.xcframework */; }; 0CED2ADB2D95BB46003015CF /* hermes.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F60D3922D2C4BA3008ACD86 /* hermes.xcframework */; }; 0CED2ADD2D95BB46003015CF /* React.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F60D38F2D2C4BA3008ACD86 /* React.xcframework */; }; @@ -351,6 +357,20 @@ remoteGlobalIDString = 4AD953B32C21451700D0EEFA; remoteInfo = WordPressAuthenticator; }; + 0CECA9302E043D2800F4EE83 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3F7AE0B42D9B30A100AB4892; + remoteInfo = WordPressData; + }; + 0CECA9352E043D3700F4EE83 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4AD953B32C21451700D0EEFA; + remoteInfo = WordPressAuthenticator; + }; 0CED2AE32D95BB46003015CF /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; @@ -626,6 +646,18 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + 0CECA9322E043D2800F4EE83 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 0CECA9342E043D3700F4EE83 /* WordPressAuthenticator.framework in Embed Frameworks */, + 0CECA92F2E043D2800F4EE83 /* WordPressData.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; 4A0274882C224FB000290D8B /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -1389,6 +1421,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0CECA92E2E043D2800F4EE83 /* WordPressData.framework in Frameworks */, + 0CECA92B2E043CEC00F4EE83 /* XcodeTarget_App in Frameworks */, + 0CECA9332E043D3700F4EE83 /* WordPressAuthenticator.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1396,6 +1431,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0CECA92D2E043D0200F4EE83 /* XcodeTarget_App in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2204,16 +2240,20 @@ 0C3313B32E0439A8000C3760 /* Sources */, 0C3313B42E0439A8000C3760 /* Frameworks */, 0C3313B52E0439A8000C3760 /* Resources */, + 0CECA9322E043D2800F4EE83 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + 0CECA9312E043D2800F4EE83 /* PBXTargetDependency */, + 0CECA9362E043D3700F4EE83 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 0C3313B82E0439A8000C3760 /* Miniature */, ); name = Miniature; packageProductDependencies = ( + 0CECA92A2E043CEC00F4EE83 /* XcodeTarget_App */, ); productName = Miniature; productReference = 0C3313B72E0439A8000C3760 /* Miniature.app */; @@ -2237,6 +2277,7 @@ ); name = MiniatureTests; packageProductDependencies = ( + 0CECA92C2E043D0200F4EE83 /* XcodeTarget_App */, ); productName = MiniatureTests; productReference = 0C3313C32E0439A9000C3760 /* MiniatureTests.xctest */; @@ -2738,7 +2779,6 @@ }; 0C3313C22E0439A9000C3760 = { CreatedOnToolsVersion = 16.3; - TestTargetID = 0C3313B62E0439A8000C3760; }; 0C5A3F8B2D9B1E3700C25301 = { CreatedOnToolsVersion = 16.1; @@ -3794,6 +3834,16 @@ target = 4AD953B32C21451700D0EEFA /* WordPressAuthenticator */; targetProxy = 0C5A8A822D9B22F100C25301 /* PBXContainerItemProxy */; }; + 0CECA9312E043D2800F4EE83 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3F7AE0B42D9B30A100AB4892 /* WordPressData */; + targetProxy = 0CECA9302E043D2800F4EE83 /* PBXContainerItemProxy */; + }; + 0CECA9362E043D3700F4EE83 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4AD953B32C21451700D0EEFA /* WordPressAuthenticator */; + targetProxy = 0CECA9352E043D3700F4EE83 /* PBXContainerItemProxy */; + }; 0CED2AE42D95BB46003015CF /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4AD953B32C21451700D0EEFA /* WordPressAuthenticator */; @@ -4491,7 +4541,6 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -4516,7 +4565,7 @@ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; + IPHONEOS_DEPLOYMENT_TARGET = 16; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -4527,7 +4576,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Miniature.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Miniature"; }; name = Debug; }; @@ -4535,7 +4583,6 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -4557,7 +4604,7 @@ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; + IPHONEOS_DEPLOYMENT_TARGET = 16; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -4567,7 +4614,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Miniature.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Miniature"; }; name = Release; }; @@ -4575,7 +4621,6 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -4597,7 +4642,7 @@ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; + IPHONEOS_DEPLOYMENT_TARGET = 16; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -4607,7 +4652,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Miniature.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Miniature"; }; name = "Release-Alpha"; }; @@ -7791,6 +7835,14 @@ isa = XCSwiftPackageProductDependency; productName = XcodeTarget_WordPressAuthentificator; }; + 0CECA92A2E043CEC00F4EE83 /* XcodeTarget_App */ = { + isa = XCSwiftPackageProductDependency; + productName = XcodeTarget_App; + }; + 0CECA92C2E043D0200F4EE83 /* XcodeTarget_App */ = { + isa = XCSwiftPackageProductDependency; + productName = XcodeTarget_App; + }; 0CFFFECA2C36F5760044709B /* XcodeTarget_WordPressAuthentificatorTests */ = { isa = XCSwiftPackageProductDependency; productName = XcodeTarget_WordPressAuthentificatorTests; From 6f94f9e87e453398434d3ba5cea9cf7ad0021acc Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 08:49:00 -0400 Subject: [PATCH 16/90] Configure xcconfig (just use Reader) --- WordPress/WordPress.xcodeproj/project.pbxproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 76c32fedc624..07ad1743d138 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -4385,6 +4385,7 @@ }; 0C3313D52E0439AA000C3760 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0C5A651A2D9B21CE00C25301 /* Reader.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -4439,6 +4440,7 @@ }; 0C3313D62E0439AA000C3760 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0C5A651B2D9B21CE00C25301 /* Reader.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -4489,6 +4491,7 @@ }; 0C3313D72E0439AA000C3760 /* Release-Alpha */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0C5A651B2D9B21CE00C25301 /* Reader.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; From e220263062d69b33d80298e6d8a847ec0d148e09 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 09:22:58 -0400 Subject: [PATCH 17/90] Add initial ActivityLogDetailsView --- .../Miniature/ActivityLogDetailsView.swift | 578 ++++++++++++++++++ Sources/Miniature/ContentView.swift | 108 +++- 2 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 Sources/Miniature/ActivityLogDetailsView.swift diff --git a/Sources/Miniature/ActivityLogDetailsView.swift b/Sources/Miniature/ActivityLogDetailsView.swift new file mode 100644 index 000000000000..07e5cecfff32 --- /dev/null +++ b/Sources/Miniature/ActivityLogDetailsView.swift @@ -0,0 +1,578 @@ +import SwiftUI +import WordPressKit + +struct ActivityLogDetailsView: View { + let activity: Activity + let rewindStatus: RewindStatus? + + @State private var isShowingRestoreConfirmation = false + @State private var isRestoring = false + @State private var isDownloadingBackup = false + + @Environment(\.dismiss) var dismiss + + var body: some View { + ScrollView { + VStack(spacing: 24) { + ActivityHeaderView(activity: activity) + + if let stats = makeActivityStats() { + ActivityStatsCard(stats: stats) + } + + ActivityDetailsCard(activity: activity) + + if activity.isRewindable { + ActivityActionsCard( + activity: activity, + rewindStatus: rewindStatus, + isRestoring: $isRestoring, + isDownloadingBackup: $isDownloadingBackup, + onRestore: handleRestore, + onDownloadBackup: handleDownloadBackup + ) + } + + if let rewindStatus = rewindStatus, rewindStatus.state == .awaitingCredentials { + WarningCard( + message: "Rewind is not available for multisite installations. Visit Jetpack.com for more information.", + actionTitle: "Learn More", + action: openSupportURL + ) + } + } + .padding() + } + .navigationTitle("Event") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog("Restore Site", isPresented: $isShowingRestoreConfirmation, actions: { + Button(role: .destructive) { + performRestore() + } label: { + Text("Restore to this point") + } + }, message: { + Text("This will restore your site to \(activity.published.formatted(date: .abbreviated, time: .shortened)). Any changes made after this point will be lost.") + }) + } + + // MARK: - Actions + + private func handleRestore() { + isShowingRestoreConfirmation = true + } + + private func performRestore() { + isRestoring = true + // In a real implementation, this would call the restore API + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + isRestoring = false + } + } + + private func handleDownloadBackup() { + isDownloadingBackup = true + // In a real implementation, this would trigger the backup download + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + isDownloadingBackup = false + } + } + + private func openSupportURL() { + if let url = URL(string: "https://jetpack.com/support/backup/") { + UIApplication.shared.open(url) + } + } + + private func makeActivityStats() -> ActivityStats? { + // Extract stats from activity content if available + guard activity.name == "rewind__backup_complete_full" else { return nil } + + // Parse the text to extract backup stats + let components = activity.text.components(separatedBy: ", ") + var stats = ActivityStats() + + for component in components { + if component.contains("plugin") { + stats.plugins = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 + } else if component.contains("theme") { + stats.themes = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 + } else if component.contains("upload") { + stats.uploads = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 + } else if component.contains("post") { + stats.posts = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 + } else if component.contains("page") { + stats.pages = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 + } + } + + return stats + } +} + +// MARK: - Header View + +private struct ActivityHeaderView: View { + let activity: Activity + + var body: some View { + HStack(spacing: 12) { + ActivityIconView(activity: activity) + .frame(width: 48, height: 48) + + VStack(alignment: .leading, spacing: 4) { + Text(activity.actor?.displayName ?? "WordPress") + .font(.headline) + + if let role = activity.actor?.role { + Text(role.capitalized) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(activity.published.formatted(date: .abbreviated, time: .omitted)) + .font(.caption) + .foregroundStyle(.secondary) + + Text(activity.published.formatted(date: .omitted, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +// MARK: - Icon View + +private struct ActivityIconView: View { + let activity: Activity + + var iconName: String { + // Map gridicon names to SF Symbols + switch activity.gridicon { + case "cloud": return "cloud.fill" + case "checkmark": return "checkmark.circle.fill" + case "history": return "clock.arrow.circlepath" + case "user": return "person.circle.fill" + case "lock": return "lock.fill" + case "plugins": return "puzzlepiece.fill" + case "themes": return "paintbrush.fill" + case "posts": return "doc.text.fill" + case "pages": return "doc.fill" + case "trash": return "trash.fill" + case "notice": return "exclamationmark.triangle.fill" + default: return "circle.fill" + } + } + + var backgroundColor: Color { + switch activity.status { + case "success": return .green + case "error": return .red + case "warning": return .orange + default: return .gray + } + } + + var body: some View { + ZStack { + Circle() + .fill(backgroundColor.opacity(0.2)) + + Image(systemName: iconName) + .font(.title2) + .foregroundColor(backgroundColor) + } + } +} + +// MARK: - Stats Card + +private struct ActivityStats { + var plugins: Int = 0 + var themes: Int = 0 + var uploads: Int = 0 + var posts: Int = 0 + var pages: Int = 0 +} + +private struct ActivityStatsCard: View { + let stats: ActivityStats + + var body: some View { + ActivityCard { + HStack { + StatItem( + icon: "puzzlepiece.fill", + title: "Plugins", + value: "\(stats.plugins)" + ) + + Divider() + .frame(height: 40) + + StatItem( + icon: "paintbrush.fill", + title: "Themes", + value: "\(stats.themes)" + ) + + Divider() + .frame(height: 40) + + StatItem( + icon: "photo.fill", + title: "Uploads", + value: "\(stats.uploads)" + ) + } + + HStack { + StatItem( + icon: "doc.text.fill", + title: "Posts", + value: "\(stats.posts)" + ) + + Divider() + .frame(height: 40) + + StatItem( + icon: "doc.fill", + title: "Pages", + value: "\(stats.pages)" + ) + + Spacer() + } + } + } +} + +private struct StatItem: View { + let icon: String + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Image(systemName: icon) + .font(.footnote) + .foregroundStyle(.secondary) + + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + Text(value) + .font(.title2.bold()) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Details Card + +private struct ActivityDetailsCard: View { + let activity: Activity + + var body: some View { + ActivityCard("Activity Details") { + VStack(alignment: .leading, spacing: 16) { + InfoRow("Type", value: formatActivityType(activity.type)) + InfoRow("Name", value: formatActivityName(activity.name)) + InfoRow("Status", value: activity.status.capitalized) + + if !activity.summary.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Summary") + .font(.subheadline.weight(.medium)) + Text(activity.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + if !activity.text.isEmpty && activity.text != activity.summary { + VStack(alignment: .leading, spacing: 4) { + Text("Details") + .font(.subheadline.weight(.medium)) + Text(activity.text) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + if let rewindID = activity.rewindID { + InfoRow("Backup ID", value: String(rewindID.prefix(8)) + "...") + .font(.caption) + } + } + } + } + + private func formatActivityType(_ type: String) -> String { + type.replacingOccurrences(of: "_", with: " ").capitalized + } + + private func formatActivityName(_ name: String) -> String { + name + .replacingOccurrences(of: "__", with: " - ") + .replacingOccurrences(of: "_", with: " ") + .capitalized + } +} + +// MARK: - Actions Card + +private struct ActivityActionsCard: View { + let activity: Activity + let rewindStatus: RewindStatus? + @Binding var isRestoring: Bool + @Binding var isDownloadingBackup: Bool + let onRestore: () -> Void + let onDownloadBackup: () -> Void + + var canRestore: Bool { + rewindStatus?.state == .active + } + + var body: some View { + ActivityCard("Actions") { + VStack(spacing: 12) { + Button(action: onRestore) { + Label { + Text("Restore to this point") + } icon: { + if isRestoring { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + } else { + Image(systemName: "clock.arrow.circlepath") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canRestore || isRestoring) + + Button(action: onDownloadBackup) { + Label { + Text("Download backup") + } icon: { + if isDownloadingBackup { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.down.circle") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(isDownloadingBackup) + + if !canRestore { + Text("Restore is not available for this site") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - Warning Card + +private struct WarningCard: View { + let message: String + let actionTitle: String + let action: () -> Void + + var body: some View { + ActivityCard { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Button(actionTitle, action: action) + .font(.subheadline) + } + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.orange.opacity(0.3), lineWidth: 1) + ) + } +} + +// MARK: - Shared Components + +private struct ActivityCard: View { + let title: String? + @ViewBuilder let content: () -> Content + + init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let title = title { + Text(title.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) + } + + content() + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(.separator), lineWidth: 0.5) + ) + } +} + +private struct InfoRow: View { + let title: String + let value: String + + init(_ title: String, value: String) { + self.title = title + self.value = value + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + Text(value) + .font(.subheadline) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } +} + +// MARK: - Preview + +#Preview("Backup Activity") { + NavigationView { + ActivityLogDetailsView( + activity: mockBackupActivity, + rewindStatus: mockActiveRewindStatus + ) + } +} + +#Preview("Plugin Update") { + NavigationView { + ActivityLogDetailsView( + activity: mockPluginActivity, + rewindStatus: mockInactiveRewindStatus + ) + } +} + +// MARK: - Mock Data + +private let mockBackupActivity: Activity = { + let json = """ + { + "activity_id": "123456", + "summary": "Backup and scan complete", + "content": { + "text": "9 plugins, 2 themes, 45 uploads, 27 posts, 1 page" + }, + "name": "rewind__backup_complete_full", + "type": "backup", + "gridicon": "cloud", + "status": "success", + "is_rewindable": true, + "rewind_id": "abc123def456", + "published": "2025-06-18T17:35:00+00:00", + "actor": { + "name": "Jetpack", + "type": "Application", + "wp_com_user_id": "", + "icon": { + "url": "" + }, + "role": "" + } + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") + } + + return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) +}() + +private let mockPluginActivity: Activity = { + let json = """ + { + "activity_id": "789012", + "summary": "Plugin updated", + "content": { + "text": "Updated Akismet Anti-spam from version 5.2 to 5.3" + }, + "name": "plugin__updated", + "type": "plugin", + "gridicon": "plugins", + "status": "success", + "is_rewindable": false, + "published": "2025-06-18T16:35:00+00:00", + "actor": { + "name": "John Doe", + "type": "Person", + "wp_com_user_id": "12345", + "icon": { + "url": "https://gravatar.com/avatar/12345" + }, + "role": "administrator" + } + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") + } + + return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) +}() + +private let mockActiveRewindStatus = RewindStatus(state: .active) + +private let mockInactiveRewindStatus = RewindStatus(state: .inactive) + diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index 4368e029973e..6004c67259c0 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -6,7 +6,22 @@ import WordPressKit struct ContentView: View { var body: some View { - Text("Hello, world!") + List { + NavigationLink("Activity Log Details (Backup)") { + ActivityLogDetailsView( + activity: createMockBackupActivity(), + rewindStatus: createMockActiveRewindStatus() + ) + } + + NavigationLink("Activity Log Details (Plugin Update)") { + ActivityLogDetailsView( + activity: createMockPluginActivity(), + rewindStatus: createMockInactiveRewindStatus() + ) + } + } + .navigationTitle("Miniature") } } @@ -15,3 +30,94 @@ struct ContentView: View { ContentView() } } + +// MARK: - Mock Data Helpers + +private func createMockBackupActivity() -> Activity { + let json = """ + { + "activity_id": "123456", + "summary": "Backup and scan complete", + "content": { + "text": "9 plugins, 2 themes, 45 uploads, 27 posts, 1 page" + }, + "name": "rewind__backup_complete_full", + "type": "backup", + "gridicon": "cloud", + "status": "success", + "is_rewindable": true, + "rewind_id": "abc123def456", + "published": "2025-06-18T17:35:00+00:00", + "actor": { + "name": "Jetpack", + "type": "Application", + "wp_com_user_id": "", + "icon": { + "url": "" + }, + "role": "" + } + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") + } + + return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) +} + +private func createMockPluginActivity() -> Activity { + let json = """ + { + "activity_id": "789012", + "summary": "Plugin updated", + "content": { + "text": "Updated Akismet Anti-spam from version 5.2 to 5.3" + }, + "name": "plugin__updated", + "type": "plugin", + "gridicon": "plugins", + "status": "success", + "is_rewindable": false, + "published": "2025-06-18T16:35:00+00:00", + "actor": { + "name": "John Doe", + "type": "Person", + "wp_com_user_id": "12345", + "icon": { + "url": "https://gravatar.com/avatar/12345" + }, + "role": "administrator" + } + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") + } + + return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) +} + +private func createMockActiveRewindStatus() -> RewindStatus { + // Using the internal initializer for mocking + RewindStatus(state: .active) +} + +private func createMockInactiveRewindStatus() -> RewindStatus { + // Using the internal initializer for mocking + RewindStatus(state: .inactive) +} From 4526022dc04c9297d7eb31f9dcf639a1bba94870 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 09:26:11 -0400 Subject: [PATCH 18/90] Add ActivityLogDetailsView to Miniature target - Implement modern SwiftUI version of Activity Detail screen - Use card-based design system matching SubscriberDetailsView - Add proper localization with NSLocalizedString and reverse-DNS keys - Use WPStyleGuide for consistent icon and color handling - Include activity stats visualization for backup events - Add restore and download backup actions with confirmation dialogs - Integrate with existing Activity model and RewindStatus - Update ContentView with navigation links for testing - Configure MiniatureApp with proper navigation and theming --- .../Miniature/ActivityLogDetailsView.swift | 270 +++++++++++++----- Sources/Miniature/ContentView.swift | 4 +- Sources/Miniature/MiniatureApp.swift | 6 +- 3 files changed, 210 insertions(+), 70 deletions(-) diff --git a/Sources/Miniature/ActivityLogDetailsView.swift b/Sources/Miniature/ActivityLogDetailsView.swift index 07e5cecfff32..44ded7d1be20 100644 --- a/Sources/Miniature/ActivityLogDetailsView.swift +++ b/Sources/Miniature/ActivityLogDetailsView.swift @@ -1,5 +1,7 @@ import SwiftUI import WordPressKit +import WordPressUI +import Gridicons struct ActivityLogDetailsView: View { let activity: Activity @@ -35,24 +37,24 @@ struct ActivityLogDetailsView: View { if let rewindStatus = rewindStatus, rewindStatus.state == .awaitingCredentials { WarningCard( - message: "Rewind is not available for multisite installations. Visit Jetpack.com for more information.", - actionTitle: "Learn More", + message: Strings.multisiteWarning, + actionTitle: Strings.learnMore, action: openSupportURL ) } } .padding() } - .navigationTitle("Event") + .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) - .confirmationDialog("Restore Site", isPresented: $isShowingRestoreConfirmation, actions: { + .confirmationDialog(Strings.restoreConfirmationTitle, isPresented: $isShowingRestoreConfirmation, actions: { Button(role: .destructive) { performRestore() } label: { - Text("Restore to this point") + Text(Strings.restoreToThisPoint) } }, message: { - Text("This will restore your site to \(activity.published.formatted(date: .abbreviated, time: .shortened)). Any changes made after this point will be lost.") + Text(String(format: Strings.restoreConfirmationMessage, activity.published.formatted(date: .abbreviated, time: .shortened))) }) } @@ -151,41 +153,40 @@ private struct ActivityHeaderView: View { private struct ActivityIconView: View { let activity: Activity - var iconName: String { - // Map gridicon names to SF Symbols - switch activity.gridicon { - case "cloud": return "cloud.fill" - case "checkmark": return "checkmark.circle.fill" - case "history": return "clock.arrow.circlepath" - case "user": return "person.circle.fill" - case "lock": return "lock.fill" - case "plugins": return "puzzlepiece.fill" - case "themes": return "paintbrush.fill" - case "posts": return "doc.text.fill" - case "pages": return "doc.fill" - case "trash": return "trash.fill" - case "notice": return "exclamationmark.triangle.fill" - default: return "circle.fill" - } - } - - var backgroundColor: Color { - switch activity.status { - case "success": return .green - case "error": return .red - case "warning": return .orange - default: return .gray - } - } - var body: some View { - ZStack { - Circle() - .fill(backgroundColor.opacity(0.2)) - - Image(systemName: iconName) - .font(.title2) - .foregroundColor(backgroundColor) + if let icon = WPStyleGuide.ActivityStyleGuide.getIconForActivity(activity) { + ZStack { + Circle() + .fill(Color(WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity))) + + Image(uiImage: icon) + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundColor(.white) + .padding(12) + } + } else if let avatarURL = activity.actor?.avatarURL, !avatarURL.isEmpty, let url = URL(string: avatarURL) { + // User avatar + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Image(uiImage: .gridicon(.user, size: CGSize(width: 24, height: 24))) + .foregroundColor(Color(.secondaryLabel)) + } + .clipShape(Circle()) + .background(Circle().fill(Color(.systemGray5))) + } else { + // Fallback + ZStack { + Circle() + .fill(Color(.systemGray5)) + + Image(uiImage: .gridicon(.pages, size: CGSize(width: 24, height: 24))) + .foregroundColor(Color(.secondaryLabel)) + } } } } @@ -207,8 +208,8 @@ private struct ActivityStatsCard: View { ActivityCard { HStack { StatItem( - icon: "puzzlepiece.fill", - title: "Plugins", + icon: .gridicon(.plugins, size: CGSize(width: 20, height: 20)), + title: Strings.plugins, value: "\(stats.plugins)" ) @@ -216,8 +217,8 @@ private struct ActivityStatsCard: View { .frame(height: 40) StatItem( - icon: "paintbrush.fill", - title: "Themes", + icon: .gridicon(.themes, size: CGSize(width: 20, height: 20)), + title: Strings.themes, value: "\(stats.themes)" ) @@ -225,16 +226,16 @@ private struct ActivityStatsCard: View { .frame(height: 40) StatItem( - icon: "photo.fill", - title: "Uploads", + icon: .gridicon(.image, size: CGSize(width: 20, height: 20)), + title: Strings.uploads, value: "\(stats.uploads)" ) } HStack { StatItem( - icon: "doc.text.fill", - title: "Posts", + icon: .gridicon(.posts, size: CGSize(width: 20, height: 20)), + title: Strings.posts, value: "\(stats.posts)" ) @@ -242,8 +243,8 @@ private struct ActivityStatsCard: View { .frame(height: 40) StatItem( - icon: "doc.fill", - title: "Pages", + icon: .gridicon(.pages, size: CGSize(width: 20, height: 20)), + title: Strings.pages, value: "\(stats.pages)" ) @@ -254,14 +255,14 @@ private struct ActivityStatsCard: View { } private struct StatItem: View { - let icon: String + let icon: UIImage let title: String let value: String var body: some View { VStack(alignment: .leading, spacing: 6) { - Image(systemName: icon) - .font(.footnote) + Image(uiImage: icon) + .renderingMode(.template) .foregroundStyle(.secondary) Text(title) @@ -281,15 +282,15 @@ private struct ActivityDetailsCard: View { let activity: Activity var body: some View { - ActivityCard("Activity Details") { + ActivityCard(Strings.activityDetails) { VStack(alignment: .leading, spacing: 16) { - InfoRow("Type", value: formatActivityType(activity.type)) - InfoRow("Name", value: formatActivityName(activity.name)) - InfoRow("Status", value: activity.status.capitalized) + InfoRow(Strings.type, value: formatActivityType(activity.type)) + InfoRow(Strings.name, value: formatActivityName(activity.name)) + InfoRow(Strings.status, value: activity.status.localizedCapitalized) if !activity.summary.isEmpty { VStack(alignment: .leading, spacing: 4) { - Text("Summary") + Text(Strings.summary) .font(.subheadline.weight(.medium)) Text(activity.summary) .font(.subheadline) @@ -299,7 +300,7 @@ private struct ActivityDetailsCard: View { if !activity.text.isEmpty && activity.text != activity.summary { VStack(alignment: .leading, spacing: 4) { - Text("Details") + Text(Strings.details) .font(.subheadline.weight(.medium)) Text(activity.text) .font(.subheadline) @@ -308,7 +309,7 @@ private struct ActivityDetailsCard: View { } if let rewindID = activity.rewindID { - InfoRow("Backup ID", value: String(rewindID.prefix(8)) + "...") + InfoRow(Strings.backupID, value: String(rewindID.prefix(8)) + "...") .font(.caption) } } @@ -342,18 +343,18 @@ private struct ActivityActionsCard: View { } var body: some View { - ActivityCard("Actions") { + ActivityCard(Strings.actions) { VStack(spacing: 12) { Button(action: onRestore) { Label { - Text("Restore to this point") + Text(Strings.restore) } icon: { if isRestoring { ProgressView() .progressViewStyle(CircularProgressViewStyle()) .scaleEffect(0.8) } else { - Image(systemName: "clock.arrow.circlepath") + Image(uiImage: .gridicon(.history, size: CGSize(width: 20, height: 20))) } } .frame(maxWidth: .infinity) @@ -363,14 +364,14 @@ private struct ActivityActionsCard: View { Button(action: onDownloadBackup) { Label { - Text("Download backup") + Text(Strings.downloadBackup) } icon: { if isDownloadingBackup { ProgressView() .progressViewStyle(CircularProgressViewStyle()) .scaleEffect(0.8) } else { - Image(systemName: "arrow.down.circle") + Image(uiImage: .gridicon(.cloudDownload, size: CGSize(width: 20, height: 20))) } } .frame(maxWidth: .infinity) @@ -379,7 +380,7 @@ private struct ActivityActionsCard: View { .disabled(isDownloadingBackup) if !canRestore { - Text("Restore is not available for this site") + Text(Strings.restoreNotAvailable) .font(.caption) .foregroundStyle(.secondary) } @@ -576,3 +577,140 @@ private let mockActiveRewindStatus = RewindStatus(state: .active) private let mockInactiveRewindStatus = RewindStatus(state: .inactive) +// MARK: - Localized Strings + +private enum Strings { + static let eventTitle = NSLocalizedString( + "activityDetail.title", + value: "Event", + comment: "Title for the activity detail view" + ) + + static let restore = NSLocalizedString( + "activityDetail.restore", + value: "Restore", + comment: "Title for button allowing user to restore their Jetpack site" + ) + + static let downloadBackup = NSLocalizedString( + "activityDetail.downloadBackup", + value: "Download backup", + comment: "Title for button allowing user to backup their Jetpack site" + ) + + static let restoreToThisPoint = NSLocalizedString( + "activityDetail.restoreToThisPoint", + value: "Restore to this point", + comment: "Confirmation button text for restoring site" + ) + + static let restoreConfirmationTitle = NSLocalizedString( + "activityDetail.restoreConfirmationTitle", + value: "Restore Site", + comment: "Title for restore confirmation dialog" + ) + + static let restoreConfirmationMessage = NSLocalizedString( + "activityDetail.restoreConfirmationMessage", + value: "This will restore your site to %@. Any changes made after this point will be lost.", + comment: "Message for restore confirmation dialog. %@ is the date/time." + ) + + static let restoreNotAvailable = NSLocalizedString( + "activityDetail.restoreNotAvailable", + value: "Restore is not available for this site", + comment: "Message shown when restore is not available" + ) + + static let multisiteWarning = NSLocalizedString( + "activityDetail.multisiteWarning", + value: "Rewind is not available for multisite installations. Visit Jetpack.com for more information.", + comment: "Warning message for multisite installations" + ) + + static let learnMore = NSLocalizedString( + "activityDetail.learnMore", + value: "Learn More", + comment: "Button text to learn more about limitations" + ) + + static let activityDetails = NSLocalizedString( + "activityDetail.section.details", + value: "Activity Details", + comment: "Section title for activity details" + ) + + static let actions = NSLocalizedString( + "activityDetail.section.actions", + value: "Actions", + comment: "Section title for available actions" + ) + + static let type = NSLocalizedString( + "activityDetail.field.type", + value: "Type", + comment: "Activity type field label" + ) + + static let name = NSLocalizedString( + "activityDetail.field.name", + value: "Name", + comment: "Activity name field label" + ) + + static let status = NSLocalizedString( + "activityDetail.field.status", + value: "Status", + comment: "Activity status field label" + ) + + static let summary = NSLocalizedString( + "activityDetail.field.summary", + value: "Summary", + comment: "Activity summary field label" + ) + + static let details = NSLocalizedString( + "activityDetail.field.details", + value: "Details", + comment: "Activity details field label" + ) + + static let backupID = NSLocalizedString( + "activityDetail.field.backupID", + value: "Backup ID", + comment: "Backup ID field label" + ) + + // Stats + static let plugins = NSLocalizedString( + "activityDetail.stats.plugins", + value: "Plugins", + comment: "Label for number of plugins in backup" + ) + + static let themes = NSLocalizedString( + "activityDetail.stats.themes", + value: "Themes", + comment: "Label for number of themes in backup" + ) + + static let uploads = NSLocalizedString( + "activityDetail.stats.uploads", + value: "Uploads", + comment: "Label for number of uploads in backup" + ) + + static let posts = NSLocalizedString( + "activityDetail.stats.posts", + value: "Posts", + comment: "Label for number of posts in backup" + ) + + static let pages = NSLocalizedString( + "activityDetail.stats.pages", + value: "Pages", + comment: "Label for number of pages in backup" + ) +} + diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index 6004c67259c0..4a0a059d7f1a 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -26,9 +26,7 @@ struct ContentView: View { } #Preview { - NavigationView { - ContentView() - } + ContentView() } // MARK: - Mock Data Helpers diff --git a/Sources/Miniature/MiniatureApp.swift b/Sources/Miniature/MiniatureApp.swift index 2671a8a01a01..6dc00877c29b 100644 --- a/Sources/Miniature/MiniatureApp.swift +++ b/Sources/Miniature/MiniatureApp.swift @@ -1,10 +1,14 @@ import SwiftUI +import WordPressUI @main struct MiniatureApp: App { var body: some Scene { WindowGroup { - ContentView() + NavigationView { + ContentView() + } + .tint(AppColor.primary) } } } From 6b1ed2439faa107bf9006d35a68507c9d0615542 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 10:22:16 -0400 Subject: [PATCH 19/90] Extract Activity icon methods from WPStyleGuide to Activity extension - Create Activity+Icon.swift with gridiconType, icon, and statusColor properties - Update all references to use new Activity extension methods - Deprecate old WPStyleGuide methods with proper deprecation messages - Remove stringToGridiconTypeMapping from WPStyleGuide - Fix linter warnings for trailing whitespace and shorthand optional binding --- .../Miniature/ActivityLogDetailsView.swift | 4 +- .../ViewRelated/Activity/Activity+Icon.swift | 76 +++++++++++++++++++ .../ActivityDetailViewController.swift | 4 +- .../Activity/ActivityTableViewCell.swift | 4 +- .../List/ActivityLogRowViewModel.swift | 4 +- .../Activity/WPStyleGuide+Activity.swift | 62 ++------------- 6 files changed, 91 insertions(+), 63 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift diff --git a/Sources/Miniature/ActivityLogDetailsView.swift b/Sources/Miniature/ActivityLogDetailsView.swift index 44ded7d1be20..a4c7ed402461 100644 --- a/Sources/Miniature/ActivityLogDetailsView.swift +++ b/Sources/Miniature/ActivityLogDetailsView.swift @@ -154,10 +154,10 @@ private struct ActivityIconView: View { let activity: Activity var body: some View { - if let icon = WPStyleGuide.ActivityStyleGuide.getIconForActivity(activity) { + if let icon = activity.icon { ZStack { Circle() - .fill(Color(WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity))) + .fill(Color(activity.statusColor)) Image(uiImage: icon) .renderingMode(.template) diff --git a/WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift b/WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift new file mode 100644 index 000000000000..ff9e6fd9640b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift @@ -0,0 +1,76 @@ +import UIKit +import Gridicons +import WordPressKit +import WordPressUI + +extension Activity { + + /// Returns the appropriate GridiconType for this activity, if available + var gridiconType: GridiconType? { + Self.stringToGridiconTypeMapping[gridicon] + } + + /// Returns the icon image for this activity + /// - Returns: A white-tinted gridicon image, or nil if no icon is available + var icon: UIImage? { + guard let gridiconType else { + return nil + } + + return UIImage.gridicon(gridiconType).imageWithTintColor(.white) + } + + /// Returns the appropriate color based on the activity's status + var statusColor: UIColor { + switch status { + case ActivityStatus.error: + return UIAppColor.error + case ActivityStatus.success: + return UIAppColor.success + case ActivityStatus.warning: + return UIAppColor.warning + default: + return UIAppColor.neutral(.shade20) + } + } + + // MARK: - Private + + // We will be able to get rid of this disgusting dictionary once we build the + // String->GridiconType mapping into the Gridicon module and we get a server side + // fix to have all the names correctly mapping. + private static let stringToGridiconTypeMapping: [String: GridiconType] = [ + "checkmark": .checkmark, + "cloud": .cloud, + "cog": .cog, + "comment": .comment, + "cross": .cross, + "domains": .domains, + "history": .history, + "image": .image, + "layout": .layout, + "lock": .lock, + "logout": .signOut, + "mail": .mail, + "menu": .menu, + "my-sites": .mySites, + "notice": .notice, + "notice-outline": .noticeOutline, + "pages": .pages, + "plans": .plans, + "plugins": .plugins, + "posts": .posts, + "share": .share, + "shipping": .shipping, + "spam": .spam, + "themes": .themes, + "trash": .trash, + "user": .user, + "video": .video, + "status": .status, + "cart": .cart, + "custom-post-type": .customPostType, + "multiple-users": .multipleUsers, + "audio": .audio + ] +} diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift index 100f03b7d035..ea57c336dcd2 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift @@ -134,9 +134,9 @@ class ActivityDetailViewController: UIViewController, StoryboardLoadable { if let avatar = activity.actor?.avatarURL, let avatarURL = URL(string: avatar) { imageView.backgroundColor = UIAppColor.neutral(.shade20) imageView.downloadImage(from: avatarURL, placeholderImage: .gridicon(.user, size: Constants.gridiconSize)) - } else if let iconType = WPStyleGuide.ActivityStyleGuide.getGridiconTypeForActivity(activity) { + } else if let iconType = activity.gridiconType { imageView.contentMode = .center - imageView.backgroundColor = WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity) + imageView.backgroundColor = activity.statusColor imageView.image = .gridicon(iconType, size: Constants.gridiconSize) } else { imageView.isHidden = true diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift index e40130d9a2dd..9a12e8d2eba7 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift @@ -40,8 +40,8 @@ open class ActivityTableViewCell: UITableViewCell, NibReusable { bulletLabel.textColor = .secondaryLabel contentLabel.textColor = .label - iconBackgroundImageView.backgroundColor = Style.getColorByActivityStatus(activity) - if let iconImage = Style.getIconForActivity(activity) { + iconBackgroundImageView.backgroundColor = activity.statusColor + if let iconImage = activity.icon { iconImageView.image = iconImage.imageFlippedForRightToLeftLayoutDirection() iconImageView.isHidden = false } else { diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift index 8b82267914fb..0e552cec4c1b 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -33,7 +33,7 @@ struct ActivityLogRowViewModel: Identifiable { self.title = activity.text self.subtitle = activity.summary.localizedCapitalized - self.icon = WPStyleGuide.ActivityStyleGuide.getIconForActivity(activity) - self.tintColor = Color(WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity)) + self.icon = activity.icon + self.tintColor = Color(activity.statusColor) } } diff --git a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift b/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift index 48313e0628c6..92f631095e99 100644 --- a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift +++ b/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift @@ -32,30 +32,20 @@ extension WPStyleGuide { public static func backgroundColor() -> UIColor { return .secondarySystemGroupedBackground } - + + @available(*, deprecated, message: "Use activity.gridiconType instead") public static func getGridiconTypeForActivity(_ activity: Activity) -> GridiconType? { - return stringToGridiconTypeMapping[activity.gridicon] + return activity.gridiconType } + @available(*, deprecated, message: "Use activity.icon instead") public static func getIconForActivity(_ activity: Activity) -> UIImage? { - guard let gridiconType = stringToGridiconTypeMapping[activity.gridicon] else { - return nil - } - - return UIImage.gridicon(gridiconType).imageWithTintColor(.white) + return activity.icon } + @available(*, deprecated, message: "Use activity.statusColor instead") public static func getColorByActivityStatus(_ activity: Activity) -> UIColor { - switch activity.status { - case ActivityStatus.error: - return UIAppColor.error - case ActivityStatus.success: - return UIAppColor.success - case ActivityStatus.warning: - return UIAppColor.warning - default: - return UIAppColor.neutral(.shade20) - } + return activity.statusColor } // MARK: - Private Properties @@ -79,43 +69,5 @@ extension WPStyleGuide { private static var contentItalicFont: UIFont { return WPStyleGuide.fontForTextStyle(.body, symbolicTraits: .traitItalic) } - - // We will be able to get rid of this disgusting dictionary once we build the - // String->GridiconType mapping into the Gridicon module and we get a server side - // fix to have all the names correctly mapping. - private static let stringToGridiconTypeMapping: [String: GridiconType] = [ - "checkmark": GridiconType.checkmark, - "cloud": GridiconType.cloud, - "cog": GridiconType.cog, - "comment": GridiconType.comment, - "cross": GridiconType.cross, - "domains": GridiconType.domains, - "history": GridiconType.history, - "image": GridiconType.image, - "layout": GridiconType.layout, - "lock": GridiconType.lock, - "logout": GridiconType.signOut, - "mail": GridiconType.mail, - "menu": GridiconType.menu, - "my-sites": GridiconType.mySites, - "notice": GridiconType.notice, - "notice-outline": GridiconType.noticeOutline, - "pages": GridiconType.pages, - "plans": GridiconType.plans, - "plugins": GridiconType.plugins, - "posts": GridiconType.posts, - "share": GridiconType.share, - "shipping": GridiconType.shipping, - "spam": GridiconType.spam, - "themes": GridiconType.themes, - "trash": GridiconType.trash, - "user": GridiconType.user, - "video": GridiconType.video, - "status": GridiconType.status, - "cart": GridiconType.cart, - "custom-post-type": GridiconType.customPostType, - "multiple-users": GridiconType.multipleUsers, - "audio": GridiconType.audio - ] } } From 8d3e826229f42cc76f819f86dfdfbf847b551a6c Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 10:34:21 -0400 Subject: [PATCH 20/90] Remove rewindStatus parameter from ActivityLogDetailsView - Remove rewindStatus parameter from ActivityLogDetailsView initializer - Remove rewindStatus checks in ActivityActionsCard - Update preview providers to not pass rewindStatus - Update ContentView to not create mock RewindStatus instances - Remove warning card for multisite installations - Simplify restore button enable/disable logic --- Sources/Miniature/Activity+Icon.swift | 76 +++++++++++++++++++ .../Miniature/ActivityLogDetailsView.swift | 37 +-------- Sources/Miniature/ContentView.swift | 20 +---- 3 files changed, 81 insertions(+), 52 deletions(-) create mode 100644 Sources/Miniature/Activity+Icon.swift diff --git a/Sources/Miniature/Activity+Icon.swift b/Sources/Miniature/Activity+Icon.swift new file mode 100644 index 000000000000..ff9e6fd9640b --- /dev/null +++ b/Sources/Miniature/Activity+Icon.swift @@ -0,0 +1,76 @@ +import UIKit +import Gridicons +import WordPressKit +import WordPressUI + +extension Activity { + + /// Returns the appropriate GridiconType for this activity, if available + var gridiconType: GridiconType? { + Self.stringToGridiconTypeMapping[gridicon] + } + + /// Returns the icon image for this activity + /// - Returns: A white-tinted gridicon image, or nil if no icon is available + var icon: UIImage? { + guard let gridiconType else { + return nil + } + + return UIImage.gridicon(gridiconType).imageWithTintColor(.white) + } + + /// Returns the appropriate color based on the activity's status + var statusColor: UIColor { + switch status { + case ActivityStatus.error: + return UIAppColor.error + case ActivityStatus.success: + return UIAppColor.success + case ActivityStatus.warning: + return UIAppColor.warning + default: + return UIAppColor.neutral(.shade20) + } + } + + // MARK: - Private + + // We will be able to get rid of this disgusting dictionary once we build the + // String->GridiconType mapping into the Gridicon module and we get a server side + // fix to have all the names correctly mapping. + private static let stringToGridiconTypeMapping: [String: GridiconType] = [ + "checkmark": .checkmark, + "cloud": .cloud, + "cog": .cog, + "comment": .comment, + "cross": .cross, + "domains": .domains, + "history": .history, + "image": .image, + "layout": .layout, + "lock": .lock, + "logout": .signOut, + "mail": .mail, + "menu": .menu, + "my-sites": .mySites, + "notice": .notice, + "notice-outline": .noticeOutline, + "pages": .pages, + "plans": .plans, + "plugins": .plugins, + "posts": .posts, + "share": .share, + "shipping": .shipping, + "spam": .spam, + "themes": .themes, + "trash": .trash, + "user": .user, + "video": .video, + "status": .status, + "cart": .cart, + "custom-post-type": .customPostType, + "multiple-users": .multipleUsers, + "audio": .audio + ] +} diff --git a/Sources/Miniature/ActivityLogDetailsView.swift b/Sources/Miniature/ActivityLogDetailsView.swift index a4c7ed402461..fd4d4c913c69 100644 --- a/Sources/Miniature/ActivityLogDetailsView.swift +++ b/Sources/Miniature/ActivityLogDetailsView.swift @@ -5,7 +5,6 @@ import Gridicons struct ActivityLogDetailsView: View { let activity: Activity - let rewindStatus: RewindStatus? @State private var isShowingRestoreConfirmation = false @State private var isRestoring = false @@ -27,21 +26,12 @@ struct ActivityLogDetailsView: View { if activity.isRewindable { ActivityActionsCard( activity: activity, - rewindStatus: rewindStatus, isRestoring: $isRestoring, isDownloadingBackup: $isDownloadingBackup, onRestore: handleRestore, onDownloadBackup: handleDownloadBackup ) } - - if let rewindStatus = rewindStatus, rewindStatus.state == .awaitingCredentials { - WarningCard( - message: Strings.multisiteWarning, - actionTitle: Strings.learnMore, - action: openSupportURL - ) - } } .padding() } @@ -332,16 +322,11 @@ private struct ActivityDetailsCard: View { private struct ActivityActionsCard: View { let activity: Activity - let rewindStatus: RewindStatus? @Binding var isRestoring: Bool @Binding var isDownloadingBackup: Bool let onRestore: () -> Void let onDownloadBackup: () -> Void - var canRestore: Bool { - rewindStatus?.state == .active - } - var body: some View { ActivityCard(Strings.actions) { VStack(spacing: 12) { @@ -360,7 +345,7 @@ private struct ActivityActionsCard: View { .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) - .disabled(!canRestore || isRestoring) + .disabled(isRestoring) Button(action: onDownloadBackup) { Label { @@ -378,12 +363,6 @@ private struct ActivityActionsCard: View { } .buttonStyle(.bordered) .disabled(isDownloadingBackup) - - if !canRestore { - Text(Strings.restoreNotAvailable) - .font(.caption) - .foregroundStyle(.secondary) - } } } } @@ -476,19 +455,13 @@ private struct InfoRow: View { #Preview("Backup Activity") { NavigationView { - ActivityLogDetailsView( - activity: mockBackupActivity, - rewindStatus: mockActiveRewindStatus - ) + ActivityLogDetailsView(activity: mockBackupActivity) } } #Preview("Plugin Update") { NavigationView { - ActivityLogDetailsView( - activity: mockPluginActivity, - rewindStatus: mockInactiveRewindStatus - ) + ActivityLogDetailsView(activity: mockPluginActivity) } } @@ -573,10 +546,6 @@ private let mockPluginActivity: Activity = { return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) }() -private let mockActiveRewindStatus = RewindStatus(state: .active) - -private let mockInactiveRewindStatus = RewindStatus(state: .inactive) - // MARK: - Localized Strings private enum Strings { diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index 4a0a059d7f1a..87dd41889e47 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -8,17 +8,11 @@ struct ContentView: View { var body: some View { List { NavigationLink("Activity Log Details (Backup)") { - ActivityLogDetailsView( - activity: createMockBackupActivity(), - rewindStatus: createMockActiveRewindStatus() - ) + ActivityLogDetailsView(activity: createMockBackupActivity()) } NavigationLink("Activity Log Details (Plugin Update)") { - ActivityLogDetailsView( - activity: createMockPluginActivity(), - rewindStatus: createMockInactiveRewindStatus() - ) + ActivityLogDetailsView(activity: createMockPluginActivity()) } } .navigationTitle("Miniature") @@ -109,13 +103,3 @@ private func createMockPluginActivity() -> Activity { return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) } - -private func createMockActiveRewindStatus() -> RewindStatus { - // Using the internal initializer for mocking - RewindStatus(state: .active) -} - -private func createMockInactiveRewindStatus() -> RewindStatus { - // Using the internal initializer for mocking - RewindStatus(state: .inactive) -} From b0be437a2281394d1dd25a3ff9e5dc0abdd10d4a Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 11:23:28 -0400 Subject: [PATCH 21/90] Dev --- .../ActivityLogDetailsView+Mocks.swift | 126 ++++++ .../Miniature/ActivityLogDetailsView.swift | 388 +++++------------- Sources/Miniature/ContentView.swift | 22 +- 3 files changed, 224 insertions(+), 312 deletions(-) create mode 100644 Sources/Miniature/ActivityLogDetailsView+Mocks.swift diff --git a/Sources/Miniature/ActivityLogDetailsView+Mocks.swift b/Sources/Miniature/ActivityLogDetailsView+Mocks.swift new file mode 100644 index 000000000000..297a5905f66c --- /dev/null +++ b/Sources/Miniature/ActivityLogDetailsView+Mocks.swift @@ -0,0 +1,126 @@ +import Foundation +import WordPressKit + +extension ActivityLogDetailsView { + enum Mocks { + static var mockBackupActivity: Activity { + let json = """ + { + "summary": "Backup and scan complete", + "content": { + "text": "9 plugins, 2 themes, 45 uploads, 27 posts, 1 page" + }, + "name": "rewind__backup_complete_full", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "2025-06-18T21:35:29.909+00:00", + "generator": { + "jetpack_version": 14.8, + "blog_id": 123456789 + }, + "is_rewindable": true, + "rewind_id": "1750282529.909", + "base_rewind_id": null, + "rewind_step_count": 0, + "gridicon": "cloud", + "status": "success", + "activity_id": "mock-activity-id-123", + "is_discarded": false + } + """ + return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!) + } + + static var mockPluginActivity: Activity { + let json = """ + { + "activity_id": "789012", + "summary": "Plugin updated", + "content": { + "text": "Updated Akismet Anti-spam from version 5.2 to 5.3" + }, + "name": "plugin__updated", + "type": "plugin", + "gridicon": "plugins", + "status": "success", + "is_rewindable": false, + "published": "2025-06-18T16:35:00.000+00:00", + "actor": { + "name": "John Doe", + "type": "Person", + "wp_com_user_id": "12345", + "icon": { + "url": "https://gravatar.com/avatar/12345" + }, + "role": "administrator" + } + } + """ + return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!) + } + + static var mockLoginActivity: Activity { + let json = """ + { + "summary": "Login succeeded", + "content": { + "text": "JohnDoe successfully logged in from IP Address 192.0.2.1", + "ranges": [ + { + "url": "https://wordpress.com/people/edit/123456789/johndoe", + "indices": [ + 0, + 7 + ], + "id": 12345678, + "parent": null, + "type": "a", + "site_id": 123456789, + "section": "user", + "intent": "edit" + } + ] + }, + "name": "user__login", + "actor": { + "type": "Person", + "name": "JohnDoe", + "external_user_id": 12345678, + "wpcom_user_id": 12345678, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/1234567890abcdef?s=96&d=identicon&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Join", + "published": "2025-06-19T15:04:20.180+00:00", + "generator": { + "jetpack_version": 14.8, + "blog_id": 123456789 + }, + "is_rewindable": false, + "rewind_id": "1750345459.9332", + "base_rewind_id": null, + "rewind_step_count": 0, + "gridicon": "lock", + "status": null, + "activity_id": "mock-login-activity-456", + "object": { + "type": "Person", + "name": "JohnDoe", + "external_user_id": 12345678, + "wpcom_user_id": 12345678 + }, + "is_discarded": false + } + """ + return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!) + } + } +} diff --git a/Sources/Miniature/ActivityLogDetailsView.swift b/Sources/Miniature/ActivityLogDetailsView.swift index fd4d4c913c69..c2a8fbd225e2 100644 --- a/Sources/Miniature/ActivityLogDetailsView.swift +++ b/Sources/Miniature/ActivityLogDetailsView.swift @@ -16,13 +16,11 @@ struct ActivityLogDetailsView: View { ScrollView { VStack(spacing: 24) { ActivityHeaderView(activity: activity) - - if let stats = makeActivityStats() { - ActivityStatsCard(stats: stats) + if let actor = activity.actor { + ActorCard(actor: actor) } - ActivityDetailsCard(activity: activity) - + if activity.isRewindable { ActivityActionsCard( activity: activity, @@ -76,30 +74,6 @@ struct ActivityLogDetailsView: View { } } - private func makeActivityStats() -> ActivityStats? { - // Extract stats from activity content if available - guard activity.name == "rewind__backup_complete_full" else { return nil } - - // Parse the text to extract backup stats - let components = activity.text.components(separatedBy: ", ") - var stats = ActivityStats() - - for component in components { - if component.contains("plugin") { - stats.plugins = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 - } else if component.contains("theme") { - stats.themes = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 - } else if component.contains("upload") { - stats.uploads = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 - } else if component.contains("post") { - stats.posts = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 - } else if component.contains("page") { - stats.pages = Int(component.components(separatedBy: " ").first ?? "0") ?? 0 - } - } - - return stats - } } // MARK: - Header View @@ -108,161 +82,115 @@ private struct ActivityHeaderView: View { let activity: Activity var body: some View { - HStack(spacing: 12) { - ActivityIconView(activity: activity) - .frame(width: 48, height: 48) - + VStack(alignment: .leading, spacing: 16) { + // Activity icon with colored background + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color(activity.statusColor).opacity(0.15)) + .frame(width: 60, height: 60) + + if let icon = activity.icon { + Image(uiImage: icon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .foregroundColor(Color(activity.statusColor)) + } + } + VStack(alignment: .leading, spacing: 4) { - Text(activity.actor?.displayName ?? "WordPress") - .font(.headline) - - if let role = activity.actor?.role { - Text(role.capitalized) + // Activity title/summary + Text(activity.summary.localizedCapitalized) + .font(.title3.weight(.medium)) + .lineLimit(2) + + // Activity details if different from summary + if !activity.text.isEmpty && activity.text != activity.summary { + Text(activity.text) .font(.subheadline) .foregroundStyle(.secondary) + .lineLimit(3) } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text(activity.published.formatted(date: .abbreviated, time: .omitted)) - .font(.caption) - .foregroundStyle(.secondary) - - Text(activity.published.formatted(date: .omitted, time: .shortened)) - .font(.caption) - .foregroundStyle(.secondary) + + // Date and time + HStack(spacing: 8) { + Image(systemName: "calendar") + .foregroundStyle(.tertiary) + Text(activity.published.formatted(date: .abbreviated, time: .standard)) + .foregroundStyle(.secondary) + } + .font(.footnote) + .padding(.top, 4) } } + .frame(maxWidth: .infinity, alignment: .leading) } } -// MARK: - Icon View +// MARK: - Actor Card -private struct ActivityIconView: View { - let activity: Activity +private struct ActorCard: View { + let actor: ActivityActor var body: some View { - if let icon = activity.icon { - ZStack { - Circle() - .fill(Color(activity.statusColor)) + ActivityCard(Strings.user) { + HStack(spacing: 12) { + // Actor avatar + ActorAvatarView(actor: actor) + .frame(width: 40, height: 40) - Image(uiImage: icon) - .renderingMode(.template) - .resizable() - .scaledToFit() - .foregroundColor(.white) - .padding(12) - } - } else if let avatarURL = activity.actor?.avatarURL, !avatarURL.isEmpty, let url = URL(string: avatarURL) { - // User avatar - AsyncImage(url: url) { image in - image - .resizable() - .scaledToFill() - } placeholder: { - Image(uiImage: .gridicon(.user, size: CGSize(width: 24, height: 24))) - .foregroundColor(Color(.secondaryLabel)) - } - .clipShape(Circle()) - .background(Circle().fill(Color(.systemGray5))) - } else { - // Fallback - ZStack { - Circle() - .fill(Color(.systemGray5)) + // Actor info + VStack(alignment: .leading, spacing: 2) { + Text(actor.displayName) + .font(.headline) + + Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) + .font(.subheadline) + .foregroundStyle(.secondary) + } - Image(uiImage: .gridicon(.pages, size: CGSize(width: 24, height: 24))) - .foregroundColor(Color(.secondaryLabel)) + Spacer() } } } } -// MARK: - Stats Card - -private struct ActivityStats { - var plugins: Int = 0 - var themes: Int = 0 - var uploads: Int = 0 - var posts: Int = 0 - var pages: Int = 0 -} +// MARK: - Actor Avatar View -private struct ActivityStatsCard: View { - let stats: ActivityStats +private struct ActorAvatarView: View { + let actor: ActivityActor var body: some View { - ActivityCard { - HStack { - StatItem( - icon: .gridicon(.plugins, size: CGSize(width: 20, height: 20)), - title: Strings.plugins, - value: "\(stats.plugins)" - ) - - Divider() - .frame(height: 40) - - StatItem( - icon: .gridicon(.themes, size: CGSize(width: 20, height: 20)), - title: Strings.themes, - value: "\(stats.themes)" - ) - - Divider() - .frame(height: 40) - - StatItem( - icon: .gridicon(.image, size: CGSize(width: 20, height: 20)), - title: Strings.uploads, - value: "\(stats.uploads)" - ) + if let url = URL(string: actor.avatarURL) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + placeholder } - - HStack { - StatItem( - icon: .gridicon(.posts, size: CGSize(width: 20, height: 20)), - title: Strings.posts, - value: "\(stats.posts)" - ) - - Divider() - .frame(height: 40) - - StatItem( - icon: .gridicon(.pages, size: CGSize(width: 20, height: 20)), - title: Strings.pages, - value: "\(stats.pages)" - ) - - Spacer() + .clipShape(Circle()) + } else if actor.displayName.lowercased() == "jetpack" { + ZStack { + Circle() + .fill(Color(.systemGreen)) + Image(uiImage: .gridicon(.plugins, size: CGSize(width: 18, height: 18))) + .foregroundColor(.white) } + } else { + placeholder } } -} - -private struct StatItem: View { - let icon: UIImage - let title: String - let value: String - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Image(uiImage: icon) - .renderingMode(.template) - .foregroundStyle(.secondary) - - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - - Text(value) - .font(.title2.bold()) - } - .frame(maxWidth: .infinity, alignment: .leading) + private var placeholder: some View { + Circle() + .fill(Color(.secondarySystemFill)) + .overlay( + Text(actor.displayName.prefix(1).uppercased()) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.secondary) + ) } } @@ -274,29 +202,9 @@ private struct ActivityDetailsCard: View { var body: some View { ActivityCard(Strings.activityDetails) { VStack(alignment: .leading, spacing: 16) { - InfoRow(Strings.type, value: formatActivityType(activity.type)) - InfoRow(Strings.name, value: formatActivityName(activity.name)) InfoRow(Strings.status, value: activity.status.localizedCapitalized) - if !activity.summary.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.summary) - .font(.subheadline.weight(.medium)) - Text(activity.summary) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - if !activity.text.isEmpty && activity.text != activity.summary { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.details) - .font(.subheadline.weight(.medium)) - Text(activity.text) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } + InfoRow(Strings.name, value: formatActivityName(activity.name)) if let rewindID = activity.rewindID { InfoRow(Strings.backupID, value: String(rewindID.prefix(8)) + "...") @@ -306,10 +214,6 @@ private struct ActivityDetailsCard: View { } } - private func formatActivityType(_ type: String) -> String { - type.replacingOccurrences(of: "_", with: " ").capitalized - } - private func formatActivityName(_ name: String) -> String { name .replacingOccurrences(of: "__", with: " - ") @@ -455,96 +359,21 @@ private struct InfoRow: View { #Preview("Backup Activity") { NavigationView { - ActivityLogDetailsView(activity: mockBackupActivity) + ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockBackupActivity) } } #Preview("Plugin Update") { NavigationView { - ActivityLogDetailsView(activity: mockPluginActivity) + ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockPluginActivity) } } -// MARK: - Mock Data - -private let mockBackupActivity: Activity = { - let json = """ - { - "activity_id": "123456", - "summary": "Backup and scan complete", - "content": { - "text": "9 plugins, 2 themes, 45 uploads, 27 posts, 1 page" - }, - "name": "rewind__backup_complete_full", - "type": "backup", - "gridicon": "cloud", - "status": "success", - "is_rewindable": true, - "rewind_id": "abc123def456", - "published": "2025-06-18T17:35:00+00:00", - "actor": { - "name": "Jetpack", - "type": "Application", - "wp_com_user_id": "", - "icon": { - "url": "" - }, - "role": "" - } - } - """ - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { - return date - } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") - } - - return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) -}() - -private let mockPluginActivity: Activity = { - let json = """ - { - "activity_id": "789012", - "summary": "Plugin updated", - "content": { - "text": "Updated Akismet Anti-spam from version 5.2 to 5.3" - }, - "name": "plugin__updated", - "type": "plugin", - "gridicon": "plugins", - "status": "success", - "is_rewindable": false, - "published": "2025-06-18T16:35:00+00:00", - "actor": { - "name": "John Doe", - "type": "Person", - "wp_com_user_id": "12345", - "icon": { - "url": "https://gravatar.com/avatar/12345" - }, - "role": "administrator" - } - } - """ - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { - return date - } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") +#Preview("Login Succeeded") { + NavigationView { + ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockLoginActivity) } - - return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) -}() +} // MARK: - Localized Strings @@ -651,35 +480,10 @@ private enum Strings { comment: "Backup ID field label" ) - // Stats - static let plugins = NSLocalizedString( - "activityDetail.stats.plugins", - value: "Plugins", - comment: "Label for number of plugins in backup" - ) - - static let themes = NSLocalizedString( - "activityDetail.stats.themes", - value: "Themes", - comment: "Label for number of themes in backup" - ) - - static let uploads = NSLocalizedString( - "activityDetail.stats.uploads", - value: "Uploads", - comment: "Label for number of uploads in backup" - ) - - static let posts = NSLocalizedString( - "activityDetail.stats.posts", - value: "Posts", - comment: "Label for number of posts in backup" - ) - - static let pages = NSLocalizedString( - "activityDetail.stats.pages", - value: "Pages", - comment: "Label for number of pages in backup" + static let user = NSLocalizedString( + "activityDetail.section.user", + value: "User", + comment: "Section title for user information" ) } diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index 87dd41889e47..9755c20862e7 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -39,7 +39,7 @@ private func createMockBackupActivity() -> Activity { "status": "success", "is_rewindable": true, "rewind_id": "abc123def456", - "published": "2025-06-18T17:35:00+00:00", + "published": "2025-06-18T17:35:00.000+00:00", "actor": { "name": "Jetpack", "type": "Application", @@ -53,15 +53,6 @@ private func createMockBackupActivity() -> Activity { """ let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { - return date - } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") - } - return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) } @@ -78,7 +69,7 @@ private func createMockPluginActivity() -> Activity { "gridicon": "plugins", "status": "success", "is_rewindable": false, - "published": "2025-06-18T16:35:00+00:00", + "published": "2025-06-18T16:35:00.000+00:00", "actor": { "name": "John Doe", "type": "Person", @@ -92,14 +83,5 @@ private func createMockPluginActivity() -> Activity { """ let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - if let date = Date.dateWithISO8601WithMillisecondsString(dateString) { - return date - } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") - } - return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) } From fe6505034885c030c24d11267a8b9911e336ef38 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 12:53:12 -0400 Subject: [PATCH 22/90] Add formattedContent --- .../BuildSettingsEnvironment.swift | 2 +- ...y+Icon.swift => Activity+Extensions.swift} | 51 +++- .../Miniature/ActivityLogDetailsView.swift | 241 ++---------------- Sources/Miniature/ContentView.swift | 75 +----- .../xcshareddata/xcschemes/Miniature.xcscheme | 98 +++++++ 5 files changed, 167 insertions(+), 300 deletions(-) rename Sources/Miniature/{Activity+Icon.swift => Activity+Extensions.swift} (51%) create mode 100644 WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Miniature.xcscheme diff --git a/Modules/Sources/BuildSettingsKit/BuildSettingsEnvironment.swift b/Modules/Sources/BuildSettingsKit/BuildSettingsEnvironment.swift index c1f56a0f9716..256e7c890cb9 100644 --- a/Modules/Sources/BuildSettingsKit/BuildSettingsEnvironment.swift +++ b/Modules/Sources/BuildSettingsKit/BuildSettingsEnvironment.swift @@ -21,7 +21,7 @@ public enum BuildSettingsEnvironment: Sendable { private extension ProcessInfo { var isXcodePreview: Bool { - environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" || environment["WP_USE_PREVIEW_ENVIRONMENT"] == "1" } var isTesting: Bool { diff --git a/Sources/Miniature/Activity+Icon.swift b/Sources/Miniature/Activity+Extensions.swift similarity index 51% rename from Sources/Miniature/Activity+Icon.swift rename to Sources/Miniature/Activity+Extensions.swift index ff9e6fd9640b..436364aea4d2 100644 --- a/Sources/Miniature/Activity+Icon.swift +++ b/Sources/Miniature/Activity+Extensions.swift @@ -1,9 +1,56 @@ -import UIKit +import SwiftUI import Gridicons +import DesignSystem import WordPressKit -import WordPressUI extension Activity { + /// Returns an AttributedString with clickable links based on content ranges + var formattedContent: AttributedString? { + guard let content = content, + let text = content["text"] as? String, + !text.isEmpty else { + return nil + } + + var attributedString = AttributedString(text) + + // Apply links from ranges if available + if let ranges = content["ranges"] as? [[String: Any]] { + for range in ranges { + guard let indices = range["indices"] as? [NSNumber], + indices.count == 2, + let urlString = range["url"] as? String, + let url = URL(string: urlString) else { + continue + } + + let startIndex = indices[0].intValue + let endIndex = indices[1].intValue + + // Convert string indices to AttributedString indices + guard startIndex >= 0, + endIndex <= text.count, + startIndex < endIndex else { + continue + } + + // Convert character indices to AttributedString.Index + let stringStartIndex = text.index(text.startIndex, offsetBy: startIndex) + let stringEndIndex = text.index(text.startIndex, offsetBy: endIndex) + + // Find corresponding indices in AttributedString + guard let attrStartIndex = AttributedString.Index(stringStartIndex, within: attributedString), + let attrEndIndex = AttributedString.Index(stringEndIndex, within: attributedString) else { + continue + } + + // Apply the link attribute to the exact range + attributedString[attrStartIndex.. String { - name - .replacingOccurrences(of: "__", with: " - ") - .replacingOccurrences(of: "_", with: " ") - .capitalized - } -} - -// MARK: - Actions Card - -private struct ActivityActionsCard: View { - let activity: Activity - @Binding var isRestoring: Bool - @Binding var isDownloadingBackup: Bool - let onRestore: () -> Void - let onDownloadBackup: () -> Void - - var body: some View { - ActivityCard(Strings.actions) { - VStack(spacing: 12) { - Button(action: onRestore) { - Label { - Text(Strings.restore) - } icon: { - if isRestoring { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.8) - } else { - Image(uiImage: .gridicon(.history, size: CGSize(width: 20, height: 20))) - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(isRestoring) - - Button(action: onDownloadBackup) { - Label { - Text(Strings.downloadBackup) - } icon: { - if isDownloadingBackup { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.8) - } else { - Image(uiImage: .gridicon(.cloudDownload, size: CGSize(width: 20, height: 20))) - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(isDownloadingBackup) - } - } - } -} - -// MARK: - Warning Card - -private struct WarningCard: View { - let message: String - let actionTitle: String - let action: () -> Void - - var body: some View { - ActivityCard { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Button(actionTitle, action: action) - .font(.subheadline) - } - } - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.orange.opacity(0.3), lineWidth: 1) - ) - } -} - // MARK: - Shared Components private struct ActivityCard: View { @@ -384,66 +231,12 @@ private enum Strings { comment: "Title for the activity detail view" ) - static let restore = NSLocalizedString( - "activityDetail.restore", - value: "Restore", - comment: "Title for button allowing user to restore their Jetpack site" - ) - - static let downloadBackup = NSLocalizedString( - "activityDetail.downloadBackup", - value: "Download backup", - comment: "Title for button allowing user to backup their Jetpack site" - ) - - static let restoreToThisPoint = NSLocalizedString( - "activityDetail.restoreToThisPoint", - value: "Restore to this point", - comment: "Confirmation button text for restoring site" - ) - - static let restoreConfirmationTitle = NSLocalizedString( - "activityDetail.restoreConfirmationTitle", - value: "Restore Site", - comment: "Title for restore confirmation dialog" - ) - - static let restoreConfirmationMessage = NSLocalizedString( - "activityDetail.restoreConfirmationMessage", - value: "This will restore your site to %@. Any changes made after this point will be lost.", - comment: "Message for restore confirmation dialog. %@ is the date/time." - ) - - static let restoreNotAvailable = NSLocalizedString( - "activityDetail.restoreNotAvailable", - value: "Restore is not available for this site", - comment: "Message shown when restore is not available" - ) - - static let multisiteWarning = NSLocalizedString( - "activityDetail.multisiteWarning", - value: "Rewind is not available for multisite installations. Visit Jetpack.com for more information.", - comment: "Warning message for multisite installations" - ) - - static let learnMore = NSLocalizedString( - "activityDetail.learnMore", - value: "Learn More", - comment: "Button text to learn more about limitations" - ) - static let activityDetails = NSLocalizedString( "activityDetail.section.details", value: "Activity Details", comment: "Section title for activity details" ) - static let actions = NSLocalizedString( - "activityDetail.section.actions", - value: "Actions", - comment: "Section title for available actions" - ) - static let type = NSLocalizedString( "activityDetail.field.type", value: "Type", diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index 9755c20862e7..7928c55ba5c7 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -6,82 +6,11 @@ import WordPressKit struct ContentView: View { var body: some View { - List { - NavigationLink("Activity Log Details (Backup)") { - ActivityLogDetailsView(activity: createMockBackupActivity()) - } - - NavigationLink("Activity Log Details (Plugin Update)") { - ActivityLogDetailsView(activity: createMockPluginActivity()) - } - } - .navigationTitle("Miniature") +// Text("Hello, world!") + ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockLoginActivity) } } #Preview { ContentView() } - -// MARK: - Mock Data Helpers - -private func createMockBackupActivity() -> Activity { - let json = """ - { - "activity_id": "123456", - "summary": "Backup and scan complete", - "content": { - "text": "9 plugins, 2 themes, 45 uploads, 27 posts, 1 page" - }, - "name": "rewind__backup_complete_full", - "type": "backup", - "gridicon": "cloud", - "status": "success", - "is_rewindable": true, - "rewind_id": "abc123def456", - "published": "2025-06-18T17:35:00.000+00:00", - "actor": { - "name": "Jetpack", - "type": "Application", - "wp_com_user_id": "", - "icon": { - "url": "" - }, - "role": "" - } - } - """ - - let decoder = JSONDecoder() - return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) -} - -private func createMockPluginActivity() -> Activity { - let json = """ - { - "activity_id": "789012", - "summary": "Plugin updated", - "content": { - "text": "Updated Akismet Anti-spam from version 5.2 to 5.3" - }, - "name": "plugin__updated", - "type": "plugin", - "gridicon": "plugins", - "status": "success", - "is_rewindable": false, - "published": "2025-06-18T16:35:00.000+00:00", - "actor": { - "name": "John Doe", - "type": "Person", - "wp_com_user_id": "12345", - "icon": { - "url": "https://gravatar.com/avatar/12345" - }, - "role": "administrator" - } - } - """ - - let decoder = JSONDecoder() - return try! decoder.decode(Activity.self, from: json.data(using: .utf8)!) -} diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Miniature.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Miniature.xcscheme new file mode 100644 index 000000000000..f89d237bda4d --- /dev/null +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Miniature.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 4567c2686dfff2b8c50ec99962175f8f4cd5cbb6 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 13:29:29 -0400 Subject: [PATCH 23/90] Move the code to the main target --- Sources/Miniature/ContentView.swift | 3 +- .../ViewRelated/Activity/Activity+Icon.swift | 76 ------------------- .../ActivityLogDetailsView+Mocks.swift | 2 +- .../Details}/ActivityLogDetailsView.swift | 47 ++++++------ .../Extensions}/Activity+Extensions.swift | 16 ++-- .../Activity/WPStyleGuide+Activity.swift | 15 ---- 6 files changed, 33 insertions(+), 126 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift rename {Sources/Miniature => WordPress/Classes/ViewRelated/Activity/Details}/ActivityLogDetailsView+Mocks.swift (99%) rename {Sources/Miniature => WordPress/Classes/ViewRelated/Activity/Details}/ActivityLogDetailsView.swift (97%) rename {Sources/Miniature => WordPress/Classes/ViewRelated/Activity/Extensions}/Activity+Extensions.swift (96%) diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index 7928c55ba5c7..ed1675b112f4 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -6,8 +6,7 @@ import WordPressKit struct ContentView: View { var body: some View { -// Text("Hello, world!") - ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockLoginActivity) + Text("Hello, world!") } } diff --git a/WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift b/WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift deleted file mode 100644 index ff9e6fd9640b..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Activity+Icon.swift +++ /dev/null @@ -1,76 +0,0 @@ -import UIKit -import Gridicons -import WordPressKit -import WordPressUI - -extension Activity { - - /// Returns the appropriate GridiconType for this activity, if available - var gridiconType: GridiconType? { - Self.stringToGridiconTypeMapping[gridicon] - } - - /// Returns the icon image for this activity - /// - Returns: A white-tinted gridicon image, or nil if no icon is available - var icon: UIImage? { - guard let gridiconType else { - return nil - } - - return UIImage.gridicon(gridiconType).imageWithTintColor(.white) - } - - /// Returns the appropriate color based on the activity's status - var statusColor: UIColor { - switch status { - case ActivityStatus.error: - return UIAppColor.error - case ActivityStatus.success: - return UIAppColor.success - case ActivityStatus.warning: - return UIAppColor.warning - default: - return UIAppColor.neutral(.shade20) - } - } - - // MARK: - Private - - // We will be able to get rid of this disgusting dictionary once we build the - // String->GridiconType mapping into the Gridicon module and we get a server side - // fix to have all the names correctly mapping. - private static let stringToGridiconTypeMapping: [String: GridiconType] = [ - "checkmark": .checkmark, - "cloud": .cloud, - "cog": .cog, - "comment": .comment, - "cross": .cross, - "domains": .domains, - "history": .history, - "image": .image, - "layout": .layout, - "lock": .lock, - "logout": .signOut, - "mail": .mail, - "menu": .menu, - "my-sites": .mySites, - "notice": .notice, - "notice-outline": .noticeOutline, - "pages": .pages, - "plans": .plans, - "plugins": .plugins, - "posts": .posts, - "share": .share, - "shipping": .shipping, - "spam": .spam, - "themes": .themes, - "trash": .trash, - "user": .user, - "video": .video, - "status": .status, - "cart": .cart, - "custom-post-type": .customPostType, - "multiple-users": .multipleUsers, - "audio": .audio - ] -} diff --git a/Sources/Miniature/ActivityLogDetailsView+Mocks.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Mocks.swift similarity index 99% rename from Sources/Miniature/ActivityLogDetailsView+Mocks.swift rename to WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Mocks.swift index 297a5905f66c..fcb6a186d27d 100644 --- a/Sources/Miniature/ActivityLogDetailsView+Mocks.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Mocks.swift @@ -61,7 +61,7 @@ extension ActivityLogDetailsView { """ return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!) } - + static var mockLoginActivity: Activity { let json = """ { diff --git a/Sources/Miniature/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift similarity index 97% rename from Sources/Miniature/ActivityLogDetailsView.swift rename to WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 8a20d4fca020..6ab227edc1cb 100644 --- a/Sources/Miniature/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -5,9 +5,9 @@ import Gridicons struct ActivityLogDetailsView: View { let activity: Activity - + @Environment(\.dismiss) var dismiss - + var body: some View { ScrollView { VStack(spacing: 24) { @@ -27,7 +27,7 @@ struct ActivityLogDetailsView: View { private struct ActivityHeaderView: View { let activity: Activity - + var body: some View { VStack(alignment: .leading, spacing: 16) { // Activity icon with colored background @@ -87,24 +87,24 @@ private struct ActivityHeaderView: View { private struct ActorCard: View { let actor: ActivityActor - + var body: some View { ActivityCard(Strings.user) { HStack(spacing: 12) { // Actor avatar ActorAvatarView(actor: actor) .frame(width: 40, height: 40) - + // Actor info VStack(alignment: .leading, spacing: 2) { Text(actor.displayName) .font(.headline) - + Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) .font(.subheadline) .foregroundStyle(.secondary) } - + Spacer() } } @@ -115,7 +115,7 @@ private struct ActorCard: View { private struct ActorAvatarView: View { let actor: ActivityActor - + var body: some View { if let url = URL(string: actor.avatarURL) { AsyncImage(url: url) { image in @@ -137,7 +137,7 @@ private struct ActorAvatarView: View { placeholder } } - + private var placeholder: some View { Circle() .fill(Color(.secondarySystemFill)) @@ -154,20 +154,20 @@ private struct ActorAvatarView: View { private struct ActivityCard: View { let title: String? @ViewBuilder let content: () -> Content - + init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { self.title = title self.content = content } - + var body: some View { VStack(alignment: .leading, spacing: 16) { - if let title = title { + if let title { Text(title.uppercased()) .font(.caption) .foregroundStyle(.secondary) } - + content() .frame(maxWidth: .infinity, alignment: .leading) } @@ -184,12 +184,12 @@ private struct ActivityCard: View { private struct InfoRow: View { let title: String let value: String - + init(_ title: String, value: String) { self.title = title self.value = value } - + var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) @@ -230,53 +230,52 @@ private enum Strings { value: "Event", comment: "Title for the activity detail view" ) - + static let activityDetails = NSLocalizedString( "activityDetail.section.details", value: "Activity Details", comment: "Section title for activity details" ) - + static let type = NSLocalizedString( "activityDetail.field.type", value: "Type", comment: "Activity type field label" ) - + static let name = NSLocalizedString( "activityDetail.field.name", value: "Name", comment: "Activity name field label" ) - + static let status = NSLocalizedString( "activityDetail.field.status", value: "Status", comment: "Activity status field label" ) - + static let summary = NSLocalizedString( "activityDetail.field.summary", value: "Summary", comment: "Activity summary field label" ) - + static let details = NSLocalizedString( "activityDetail.field.details", value: "Details", comment: "Activity details field label" ) - + static let backupID = NSLocalizedString( "activityDetail.field.backupID", value: "Backup ID", comment: "Backup ID field label" ) - + static let user = NSLocalizedString( "activityDetail.section.user", value: "User", comment: "Section title for user information" ) } - diff --git a/Sources/Miniature/Activity+Extensions.swift b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift similarity index 96% rename from Sources/Miniature/Activity+Extensions.swift rename to WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift index 436364aea4d2..39359bc00bbe 100644 --- a/Sources/Miniature/Activity+Extensions.swift +++ b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift @@ -6,14 +6,14 @@ import WordPressKit extension Activity { /// Returns an AttributedString with clickable links based on content ranges var formattedContent: AttributedString? { - guard let content = content, + guard let content, let text = content["text"] as? String, !text.isEmpty else { return nil } - + var attributedString = AttributedString(text) - + // Apply links from ranges if available if let ranges = content["ranges"] as? [[String: Any]] { for range in ranges { @@ -23,7 +23,7 @@ extension Activity { let url = URL(string: urlString) else { continue } - + let startIndex = indices[0].intValue let endIndex = indices[1].intValue @@ -33,22 +33,22 @@ extension Activity { startIndex < endIndex else { continue } - + // Convert character indices to AttributedString.Index let stringStartIndex = text.index(text.startIndex, offsetBy: startIndex) let stringEndIndex = text.index(text.startIndex, offsetBy: endIndex) - + // Find corresponding indices in AttributedString guard let attrStartIndex = AttributedString.Index(stringStartIndex, within: attributedString), let attrEndIndex = AttributedString.Index(stringEndIndex, within: attributedString) else { continue } - + // Apply the link attribute to the exact range attributedString[attrStartIndex.. UIColor { return .secondarySystemGroupedBackground } - - @available(*, deprecated, message: "Use activity.gridiconType instead") - public static func getGridiconTypeForActivity(_ activity: Activity) -> GridiconType? { - return activity.gridiconType - } - - @available(*, deprecated, message: "Use activity.icon instead") - public static func getIconForActivity(_ activity: Activity) -> UIImage? { - return activity.icon - } - - @available(*, deprecated, message: "Use activity.statusColor instead") - public static func getColorByActivityStatus(_ activity: Activity) -> UIColor { - return activity.statusColor - } // MARK: - Private Properties From 149a2fe7e4eb6e6cfd03fdd80bee577b7d78a86b Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 13:57:56 -0400 Subject: [PATCH 24/90] Extract reusable ActivityActorAvatarView --- ...t => ActivityLogDetailsView+Preview.swift} | 0 .../Details/ActivityLogDetailsView.swift | 41 +-------- .../Extensions/ActivityActorAvatarView.swift | 86 +++++++++++++++++++ .../Activity/List/ActivityLogRowView.swift | 30 ++----- .../List/ActivityLogRowViewModel.swift | 12 ++- 5 files changed, 97 insertions(+), 72 deletions(-) rename WordPress/Classes/ViewRelated/Activity/Details/{ActivityLogDetailsView+Mocks.swift => ActivityLogDetailsView+Preview.swift} (100%) create mode 100644 WordPress/Classes/ViewRelated/Activity/Extensions/ActivityActorAvatarView.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Mocks.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Mocks.swift rename to WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 6ab227edc1cb..8e8250cfe391 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -92,8 +92,7 @@ private struct ActorCard: View { ActivityCard(Strings.user) { HStack(spacing: 12) { // Actor avatar - ActorAvatarView(actor: actor) - .frame(width: 40, height: 40) + ActivitySimpleAvatarView(avatarURL: actor.avatarURL, displayName: actor.displayName, diameter: 40) // Actor info VStack(alignment: .leading, spacing: 2) { @@ -111,44 +110,6 @@ private struct ActorCard: View { } } -// MARK: - Actor Avatar View - -private struct ActorAvatarView: View { - let actor: ActivityActor - - var body: some View { - if let url = URL(string: actor.avatarURL) { - AsyncImage(url: url) { image in - image - .resizable() - .scaledToFill() - } placeholder: { - placeholder - } - .clipShape(Circle()) - } else if actor.displayName.lowercased() == "jetpack" { - ZStack { - Circle() - .fill(AppColor.primary) - Image(uiImage: .gridicon(.plugins, size: CGSize(width: 18, height: 18))) - .foregroundColor(.white) - } - } else { - placeholder - } - } - - private var placeholder: some View { - Circle() - .fill(Color(.secondarySystemFill)) - .overlay( - Text(actor.displayName.prefix(1).uppercased()) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.secondary) - ) - } -} - // MARK: - Shared Components private struct ActivityCard: View { diff --git a/WordPress/Classes/ViewRelated/Activity/Extensions/ActivityActorAvatarView.swift b/WordPress/Classes/ViewRelated/Activity/Extensions/ActivityActorAvatarView.swift new file mode 100644 index 000000000000..396df83c030f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Extensions/ActivityActorAvatarView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import WordPressKit +import WordPressUI +import Gridicons + +struct ActivityActorAvatarView: View { + let actor: ActivityActor? + let diameter: CGFloat + + init(actor: ActivityActor?, diameter: CGFloat = 40) { + self.actor = actor + self.diameter = diameter + } + + var body: some View { + Group { + if let actor { + if let url = URL(string: actor.avatarURL) { + AvatarView(style: .single(url), diameter: diameter) + } else if actor.type == "Application" { + applicationAvatar + } else { + placeholder(for: actor) + } + } else { + defaultPlaceholder + } + } + .frame(width: diameter, height: diameter) + } + + private func placeholder(for actor: ActivityActor) -> some View { + Circle() + .fill(Color(.secondarySystemFill)) + .overlay( + Text(actor.displayName.prefix(1).uppercased()) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.secondary) + ) + } + + private var defaultPlaceholder: some View { + Circle() + .fill(Color(.secondarySystemFill)) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: iconSize)) + .foregroundStyle(.tertiary) + ) + } + + private var applicationAvatar: some View { + ZStack { + Circle() + .fill(AppColor.primary) + Image(uiImage: .gridicon(.plugins, size: CGSize(width: iconSize, height: iconSize))) + .foregroundColor(.white) + } + } + + private var fontSize: CGFloat { + switch diameter { + case 0..<20: + return 9 + case 20..<30: + return 12 + case 30..<50: + return 16 + default: + return 20 + } + } + + private var iconSize: CGFloat { + switch diameter { + case 0..<20: + return 10 + case 20..<30: + return 14 + case 30..<50: + return 18 + default: + return 24 + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift index 1a31068c70ae..635cd142b763 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift @@ -24,18 +24,18 @@ struct ActivityLogRowView: View { .font(.subheadline) .lineLimit(2) - if let actor = viewModel.actor { + if let actor = viewModel.activity.actor { HStack(spacing: 6) { - avatar + ActivityActorAvatarView(actor: actor, diameter: 16) HStack(spacing: 4) { - Text(actor) + Text(actor.displayName) .font(.footnote) .foregroundColor(.secondary) - if let role = viewModel.actorRole { + if let subtitle = viewModel.actorSubtitle { Text("·") .font(.footnote) .foregroundColor(.secondary) - Text(role) + Text(subtitle) .font(.footnote) .foregroundColor(.secondary) } @@ -47,26 +47,6 @@ struct ActivityLogRowView: View { } } - private var avatar: some View { - Group { - if let avatarURL = viewModel.actorAvatarURL { - AvatarView(style: .single(avatarURL), diameter: 16) - } else if viewModel.actor?.lowercased() == "jetpack" { - Image("icon-jetpack") - .resizable() - } else { - Circle() - .fill(Color(.secondarySystemBackground)) - .overlay( - Text((viewModel.actor ?? "").prefix(1).uppercased()) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(.secondary) - ) - } - } - .frame(width: 16, height: 16) - } - private var icon: some View { ZStack { RoundedRectangle(cornerRadius: 10) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift index 0e552cec4c1b..2e366d2aa6e7 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -7,9 +7,7 @@ import FormattableContentKit struct ActivityLogRowViewModel: Identifiable { let id: String - let actorAvatarURL: URL? - var actor: String? - var actorRole: String? + var actorSubtitle: String? let title: String let subtitle: String let date: Date @@ -21,11 +19,11 @@ struct ActivityLogRowViewModel: Identifiable { init(activity: Activity) { self.activity = activity self.id = activity.activityID - self.actorAvatarURL = activity.actor.flatMap { URL(string: $0.avatarURL) } if let actor = activity.actor { - self.actor = actor.displayName - if !actor.role.isEmpty { - self.actorRole = actor.role.localizedCapitalized + if actor.role.isEmpty { + actorSubtitle = actor.role + } else if !actor.type.isEmpty { + actorSubtitle = actor.type } } self.date = activity.published From a3120229e14dba4b97ac14417cb75be27238d430 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:00:52 -0400 Subject: [PATCH 25/90] Remove unused code and integrate ActivityLogDetailsView --- .../Details/ActivityLogDetailsView.swift | 64 +------------------ .../List/ActivityLogRowViewModel.swift | 4 +- .../Activity/List/ActivityLogsView.swift | 2 +- 3 files changed, 4 insertions(+), 66 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 8e8250cfe391..3ae8c296692f 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -92,7 +92,7 @@ private struct ActorCard: View { ActivityCard(Strings.user) { HStack(spacing: 12) { // Actor avatar - ActivitySimpleAvatarView(avatarURL: actor.avatarURL, displayName: actor.displayName, diameter: 40) + ActivityActorAvatarView(actor: actor, diameter: 40) // Actor info VStack(alignment: .leading, spacing: 2) { @@ -142,27 +142,6 @@ private struct ActivityCard: View { } } -private struct InfoRow: View { - let title: String - let value: String - - init(_ title: String, value: String) { - self.title = title - self.value = value - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.subheadline.weight(.medium)) - Text(value) - .font(.subheadline) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } -} - // MARK: - Preview #Preview("Backup Activity") { @@ -192,47 +171,6 @@ private enum Strings { comment: "Title for the activity detail view" ) - static let activityDetails = NSLocalizedString( - "activityDetail.section.details", - value: "Activity Details", - comment: "Section title for activity details" - ) - - static let type = NSLocalizedString( - "activityDetail.field.type", - value: "Type", - comment: "Activity type field label" - ) - - static let name = NSLocalizedString( - "activityDetail.field.name", - value: "Name", - comment: "Activity name field label" - ) - - static let status = NSLocalizedString( - "activityDetail.field.status", - value: "Status", - comment: "Activity status field label" - ) - - static let summary = NSLocalizedString( - "activityDetail.field.summary", - value: "Summary", - comment: "Activity summary field label" - ) - - static let details = NSLocalizedString( - "activityDetail.field.details", - value: "Details", - comment: "Activity details field label" - ) - - static let backupID = NSLocalizedString( - "activityDetail.field.backupID", - value: "Backup ID", - comment: "Backup ID field label" - ) static let user = NSLocalizedString( "activityDetail.section.user", diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift index 2e366d2aa6e7..0c233a8a9c3c 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -21,9 +21,9 @@ struct ActivityLogRowViewModel: Identifiable { self.id = activity.activityID if let actor = activity.actor { if actor.role.isEmpty { - actorSubtitle = actor.role + actorSubtitle = actor.role.localizedCapitalized } else if !actor.type.isEmpty { - actorSubtitle = actor.type + actorSubtitle = actor.type.localizedCapitalized } } self.date = activity.published diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index 89535025f845..f424e4b9cff2 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -115,7 +115,7 @@ private struct ActivityLogsPaginatedForEach: View { .onAppear { response.onRowAppeared(item) } .background { NavigationLink { - // TODO: Update to show ActivityDetailViewController + ActivityLogDetailsView(activity: item.activity) } label: { EmptyView() }.opacity(0) From c02e30ca4085134adad6f6c2b79cdc593d659c73 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:02:12 -0400 Subject: [PATCH 26/90] Fix SwiftLint warnings --- .../ViewRelated/Activity/Details/ActivityLogDetailsView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 3ae8c296692f..8f7f5ba5e68e 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -171,7 +171,6 @@ private enum Strings { comment: "Title for the activity detail view" ) - static let user = NSLocalizedString( "activityDetail.section.user", value: "User", From 6decdf8f8ea061e09edb5fbaea3eceb3461fae64 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:50:48 -0400 Subject: [PATCH 27/90] Add initial restore/download backup code --- .../Details/ActivityLogDetailsView.swift | 121 ++++- .../Details/DownloadBackupSheet.swift | 417 ++++++++++++++++++ .../Details/DownloadBackupViewModel.swift | 167 +++++++ .../Activity/Details/RestoreBackupSheet.swift | 321 ++++++++++++++ .../Details/RestoreBackupViewModel.swift | 124 ++++++ 5 files changed, 1141 insertions(+), 9 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 8f7f5ba5e68e..db65e0f48052 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -5,21 +5,85 @@ import Gridicons struct ActivityLogDetailsView: View { let activity: Activity + let site: JetpackSiteRef @Environment(\.dismiss) var dismiss + @State private var showingRestoreSheet = false + @State private var showingDownloadSheet = false var body: some View { - ScrollView { - VStack(spacing: 24) { - ActivityHeaderView(activity: activity) - if let actor = activity.actor { - ActorCard(actor: actor) + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 24) { + ActivityHeaderView(activity: activity) + if let actor = activity.actor { + ActorCard(actor: actor) + } } + .padding() + } + + if activity.isRewindable { + actionButtons } - .padding() } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingRestoreSheet) { + RestoreBackupSheet(activity: activity, site: site) + } + .sheet(isPresented: $showingDownloadSheet) { + DownloadBackupSheet(activity: activity, site: site) + } + } + + @ViewBuilder + private var actionButtons: some View { + VStack(spacing: 12) { + Divider() + + HStack(spacing: 12) { + // Restore Backup - Primary Button + Button(action: { + showingRestoreSheet = true + }) { + HStack { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 16, weight: .medium)) + Text(Strings.restoreBackup) + .font(.system(size: 16, weight: .medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + + // Download Backup - Secondary Button + Button(action: { + showingDownloadSheet = true + }) { + HStack { + Image(systemName: "arrow.down.circle") + .font(.system(size: 16, weight: .regular)) + Text(Strings.downloadBackup) + .font(.system(size: 16, weight: .regular)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.clear) + .foregroundColor(.accentColor) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.accentColor, lineWidth: 1) + ) + } + } + .padding(.horizontal) + .padding(.bottom, 12) + } + .background(Color(.systemBackground)) } } @@ -146,19 +210,28 @@ private struct ActivityCard: View { #Preview("Backup Activity") { NavigationView { - ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockBackupActivity) + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockBackupActivity, + site: JetpackSiteRef.mock + ) } } #Preview("Plugin Update") { NavigationView { - ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockPluginActivity) + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockPluginActivity, + site: JetpackSiteRef.mock + ) } } #Preview("Login Succeeded") { NavigationView { - ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockLoginActivity) + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockLoginActivity, + site: JetpackSiteRef.mock + ) } } @@ -176,4 +249,34 @@ private enum Strings { value: "User", comment: "Section title for user information" ) + + static let restoreBackup = NSLocalizedString( + "activityDetail.restoreBackup.button", + value: "Restore Backup", + comment: "Button title for restoring a backup" + ) + + static let downloadBackup = NSLocalizedString( + "activityDetail.downloadBackup.button", + value: "Download Backup", + comment: "Button title for downloading a backup" + ) +} + +// MARK: - Preview Helpers + +#if DEBUG +extension JetpackSiteRef { + static var mock: JetpackSiteRef { + var ref = JetpackSiteRef( + siteID: 123456789, + username: "test", + homeURL: "https://example.wordpress.com", + isSelfHostedWithoutJetpack: false, + xmlRPC: nil + ) + // Use reflection to set private properties for preview + return ref + } } +#endif diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift new file mode 100644 index 000000000000..46650b58df49 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift @@ -0,0 +1,417 @@ +import SwiftUI +import WordPressKit +import WordPressShared + +struct DownloadBackupSheet: View { + let activity: Activity + let site: JetpackSiteRef + + @StateObject private var viewModel: DownloadBackupViewModel + @Environment(\.dismiss) private var dismiss + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, site: site)) + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { + progressView + } else { + downloadOptionsView + } + } + .navigationTitle(Strings.downloadTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if viewModel.state == .idle { + ToolbarItem(placement: .navigationBarLeading) { + Button(Strings.cancel) { + dismiss() + } + } + } + } + .interactiveDismissDisabled(viewModel.state == .loading) + } + .onAppear { + WPAnalytics.track(.backupDownloadOpened, properties: ["source": "activity_detail"]) + } + } + + @ViewBuilder + private var downloadOptionsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Activity Header + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + if let icon = activity.icon { + Image(uiImage: icon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .foregroundColor(Color(activity.statusColor)) + .padding(12) + .background(Color(activity.statusColor).opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(activity.summary) + .font(.headline) + .lineLimit(2) + + Text(formattedDate) + .font(.footnote) + .foregroundColor(.secondary) + } + + Spacer() + } + + if !activity.text.isEmpty { + Text(activity.text) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // Download Options + VStack(alignment: .leading, spacing: 16) { + Text(Strings.optionsTitle) + .font(.headline) + + VStack(spacing: 0) { + DownloadOptionRow( + title: Strings.optionThemes, + isSelected: $viewModel.includeThemes + ) + Divider().padding(.leading, 44) + + DownloadOptionRow( + title: Strings.optionPlugins, + isSelected: $viewModel.includePlugins + ) + Divider().padding(.leading, 44) + + DownloadOptionRow( + title: Strings.optionUploads, + isSelected: $viewModel.includeUploads + ) + Divider().padding(.leading, 44) + + DownloadOptionRow( + title: Strings.optionContent, + subtitle: Strings.optionContentSubtitle, + isSelected: $viewModel.includeContent + ) + } + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + } + + // Info Section + VStack(alignment: .leading, spacing: 8) { + Text(Strings.infoTitle) + .font(.footnote) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .textCase(.uppercase) + + Text(Strings.infoMessage) + .font(.footnote) + .foregroundColor(.secondary) + } + } + .padding() + } + + Spacer() + + // Bottom Action Button + VStack(spacing: 16) { + Divider() + + Button(action: { + viewModel.downloadBackup() + WPAnalytics.track(.backupDownloadConfirmed, properties: ["source": "activity_detail"]) + }) { + Text(Strings.confirmDownload) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(viewModel.hasSelection ? Color.accentColor : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(!viewModel.hasSelection) + .padding(.horizontal) + .padding(.bottom, 8) + } + } + + @ViewBuilder + private var progressView: some View { + VStack(spacing: 32) { + Spacer() + + switch viewModel.state { + case .loading: + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.5) + + VStack(spacing: 8) { + Text(Strings.preparingTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(Strings.preparingMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + case .success: + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.green) + + VStack(spacing: 8) { + Text(Strings.successTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(Strings.successMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if let downloadURL = viewModel.downloadURL { + Link(destination: URL(string: downloadURL)!) { + Text(Strings.downloadLink) + .font(.subheadline) + .fontWeight(.medium) + } + .padding(.top, 8) + } + } + } + + case .failure: + VStack(spacing: 24) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + VStack(spacing: 8) { + Text(Strings.failureTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(viewModel.errorMessage ?? Strings.failureMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + default: + EmptyView() + } + + Spacer() + + if viewModel.state == .success || viewModel.state == .failure { + Button(action: { + dismiss() + }) { + Text(Strings.done) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.bottom, 24) + } + } + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: activity.published) + } +} + +// MARK: - Download Option Row + +private struct DownloadOptionRow: View { + let title: String + var subtitle: String? = nil + @Binding var isSelected: Bool + + var body: some View { + Button(action: { + isSelected.toggle() + }) { + HStack(spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 24)) + .foregroundColor(isSelected ? .accentColor : .secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body) + .foregroundColor(.primary) + + if let subtitle = subtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + } + .padding() + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let downloadTitle = NSLocalizedString( + "download.sheet.title", + value: "Download Backup", + comment: "Title for the download backup sheet" + ) + + static let cancel = NSLocalizedString( + "download.sheet.cancel", + value: "Cancel", + comment: "Cancel button for download sheet" + ) + + static let optionsTitle = NSLocalizedString( + "download.sheet.options.title", + value: "Choose items to download", + comment: "Title for download options section" + ) + + static let optionThemes = NSLocalizedString( + "download.sheet.option.themes", + value: "Themes", + comment: "Option to download themes" + ) + + static let optionPlugins = NSLocalizedString( + "download.sheet.option.plugins", + value: "Plugins", + comment: "Option to download plugins" + ) + + static let optionUploads = NSLocalizedString( + "download.sheet.option.uploads", + value: "Media uploads", + comment: "Option to download media uploads" + ) + + static let optionContent = NSLocalizedString( + "download.sheet.option.content", + value: "Content", + comment: "Option to download content" + ) + + static let optionContentSubtitle = NSLocalizedString( + "download.sheet.option.content.subtitle", + value: "Posts, pages, and comments", + comment: "Subtitle for content option" + ) + + static let infoTitle = NSLocalizedString( + "download.sheet.info.title", + value: "About backup downloads", + comment: "Info section title in download sheet" + ) + + static let infoMessage = NSLocalizedString( + "download.sheet.info.message", + value: "Your backup will be prepared as a downloadable file. You'll receive an email with the download link when it's ready.", + comment: "Information about the download process" + ) + + static let confirmDownload = NSLocalizedString( + "download.sheet.confirm.button", + value: "Create downloadable file", + comment: "Confirm button for download action" + ) + + static let preparingTitle = NSLocalizedString( + "download.sheet.preparing.title", + value: "Preparing Your Backup", + comment: "Title shown while backup is being prepared" + ) + + static let preparingMessage = NSLocalizedString( + "download.sheet.preparing.message", + value: "We're creating a downloadable backup file. This may take a few moments.", + comment: "Message shown while backup is being prepared" + ) + + static let successTitle = NSLocalizedString( + "download.sheet.success.title", + value: "Backup Ready!", + comment: "Title shown when backup is ready" + ) + + static let successMessage = NSLocalizedString( + "download.sheet.success.message", + value: "Your backup has been prepared. You'll receive an email with the download link shortly.", + comment: "Message shown when backup is ready" + ) + + static let downloadLink = NSLocalizedString( + "download.sheet.success.link", + value: "Download now", + comment: "Link to download the backup" + ) + + static let failureTitle = NSLocalizedString( + "download.sheet.failure.title", + value: "Backup Failed", + comment: "Title shown when backup preparation fails" + ) + + static let failureMessage = NSLocalizedString( + "download.sheet.failure.message", + value: "We couldn't prepare your backup. Please try again or contact support if the problem persists.", + comment: "Message shown when backup preparation fails" + ) + + static let done = NSLocalizedString( + "download.sheet.done.button", + value: "Done", + comment: "Done button to dismiss the sheet" + ) +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift new file mode 100644 index 000000000000..f8f443b8d2b2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift @@ -0,0 +1,167 @@ +import Foundation +import WordPressKit +import WordPressShared + +@MainActor +final class DownloadBackupViewModel: ObservableObject { + enum State { + case idle + case loading + case success + case failure + } + + @Published var state: State = .idle + @Published var errorMessage: String? + @Published var downloadURL: String? + + // Download options + @Published var includeThemes = true + @Published var includePlugins = true + @Published var includeUploads = true + @Published var includeContent = true + + var hasSelection: Bool { + includeThemes || includePlugins || includeUploads || includeContent + } + + private let activity: Activity + private let site: JetpackSiteRef + private let backupService: JetpackBackupService + private var downloadID: Int? + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self.backupService = JetpackBackupService(coreDataStack: ContextManager.shared.contextManager.mainContext) + } + + func downloadBackup() { + guard state == .idle, hasSelection else { return } + + state = .loading + errorMessage = nil + downloadURL = nil + + let restoreTypes = buildRestoreTypes() + + backupService.prepareBackup( + for: site, + rewindID: activity.rewindID, + restoreTypes: restoreTypes, + success: { [weak self] backup in + self?.handleBackupPrepared(backup) + }, + failure: { [weak self] error in + self?.handleBackupFailure(error) + } + ) + } + + private func buildRestoreTypes() -> JetpackRestoreTypes { + var types = JetpackRestoreTypes() + types.themes = includeThemes + types.plugins = includePlugins + types.uploads = includeUploads + types.sqls = includeContent + types.roots = includeContent + types.contents = includeContent + return types + } + + private func handleBackupPrepared(_ backup: JetpackBackup) { + downloadID = backup.downloadID + + // Check if backup is already ready + if let url = backup.url, !url.isEmpty { + downloadURL = url + state = .success + WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) + } else { + // Start polling for backup status + pollBackupStatus() + } + } + + private func pollBackupStatus() { + guard let downloadID = downloadID else { + handleBackupFailure(BackupError.missingDownloadID) + return + } + + backupService.getBackupStatus( + for: site, + downloadID: downloadID, + success: { [weak self] backup in + self?.handleBackupStatus(backup) + }, + failure: { [weak self] error in + self?.handleBackupFailure(error) + } + ) + } + + private func handleBackupStatus(_ backup: JetpackBackup) { + if let url = backup.url, !url.isEmpty { + // Backup is ready + downloadURL = url + state = .success + WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) + } else if backup.progress == nil { + // Backup failed + state = .failure + errorMessage = Strings.defaultErrorMessage + WPAnalytics.track(.backupDownloadFailed, properties: ["source": "activity_detail", "error": "no_progress"]) + } else { + // Still in progress, continue polling + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard self?.state == .loading else { return } + self?.pollBackupStatus() + } + } + } + + private func handleBackupFailure(_ error: Error) { + state = .failure + + if let networkError = error as? NSError { + errorMessage = networkError.localizedDescription + } else { + errorMessage = Strings.defaultErrorMessage + } + + WPAnalytics.track(.backupDownloadFailed, properties: [ + "source": "activity_detail", + "error": error.localizedDescription + ]) + } +} + +// MARK: - Errors + +private enum BackupError: LocalizedError { + case missingDownloadID + + var errorDescription: String? { + switch self { + case .missingDownloadID: + return Strings.missingDownloadIDError + } + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let defaultErrorMessage = NSLocalizedString( + "download.viewModel.error.default", + value: "An error occurred while preparing your backup. Please try again.", + comment: "Default error message for backup download failures" + ) + + static let missingDownloadIDError = NSLocalizedString( + "download.viewModel.error.missingID", + value: "Unable to track backup progress. Please try again.", + comment: "Error when download ID is missing" + ) +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift new file mode 100644 index 000000000000..2e44dff9ac04 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift @@ -0,0 +1,321 @@ +import SwiftUI +import WordPressKit +import WordPressShared + +struct RestoreBackupSheet: View { + let activity: Activity + let site: JetpackSiteRef + + @StateObject private var viewModel: RestoreBackupViewModel + @Environment(\.dismiss) private var dismiss + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, site: site)) + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { + progressView + } else { + confirmationView + } + } + .navigationTitle(Strings.restoreTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if viewModel.state == .idle { + ToolbarItem(placement: .navigationBarLeading) { + Button(Strings.cancel) { + dismiss() + } + } + } + } + .interactiveDismissDisabled(viewModel.state == .loading) + } + .onAppear { + WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) + } + } + + @ViewBuilder + private var confirmationView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Activity Header + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + if let icon = activity.icon { + Image(uiImage: icon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .foregroundColor(Color(activity.statusColor)) + .padding(12) + .background(Color(activity.statusColor).opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(activity.summary) + .font(.headline) + .lineLimit(2) + + Text(formattedDate) + .font(.footnote) + .foregroundColor(.secondary) + } + + Spacer() + } + + if !activity.text.isEmpty { + Text(activity.text) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // Warning Section + VStack(alignment: .leading, spacing: 12) { + Label(Strings.warningTitle, systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundColor(.orange) + + Text(Strings.warningMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + + // Info Section + VStack(alignment: .leading, spacing: 8) { + Text(Strings.infoTitle) + .font(.footnote) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .textCase(.uppercase) + + Text(Strings.infoMessage) + .font(.footnote) + .foregroundColor(.secondary) + } + } + .padding() + } + + Spacer() + + // Bottom Action Button + VStack(spacing: 16) { + Divider() + + Button(action: { + viewModel.restore() + WPAnalytics.track(.restoreConfirmed, properties: ["source": "activity_detail"]) + }) { + Text(Strings.confirmRestore) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } + + @ViewBuilder + private var progressView: some View { + VStack(spacing: 32) { + Spacer() + + switch viewModel.state { + case .loading: + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.5) + + VStack(spacing: 8) { + Text(Strings.restoringTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(String(format: Strings.restoringMessage, formattedDate)) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + case .success: + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.green) + + VStack(spacing: 8) { + Text(Strings.successTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(Strings.successMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + case .failure: + VStack(spacing: 24) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + VStack(spacing: 8) { + Text(Strings.failureTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(viewModel.errorMessage ?? Strings.failureMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + default: + EmptyView() + } + + Spacer() + + if viewModel.state == .success || viewModel.state == .failure { + Button(action: { + dismiss() + }) { + Text(Strings.done) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.bottom, 24) + } + } + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: activity.published) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let restoreTitle = NSLocalizedString( + "restore.sheet.title", + value: "Restore Site", + comment: "Title for the restore backup sheet" + ) + + static let cancel = NSLocalizedString( + "restore.sheet.cancel", + value: "Cancel", + comment: "Cancel button for restore sheet" + ) + + static let warningTitle = NSLocalizedString( + "restore.sheet.warning.title", + value: "Warning", + comment: "Warning section title in restore sheet" + ) + + static let warningMessage = NSLocalizedString( + "restore.sheet.warning.message", + value: "Restoring your site will revert all content, settings, and configurations to this backup point. Any changes made after this backup will be lost.", + comment: "Warning message about restore consequences" + ) + + static let infoTitle = NSLocalizedString( + "restore.sheet.info.title", + value: "What happens next", + comment: "Info section title in restore sheet" + ) + + static let infoMessage = NSLocalizedString( + "restore.sheet.info.message", + value: "The restore process typically takes a few minutes. You'll receive a notification when it's complete. Your site may be temporarily unavailable during the restore.", + comment: "Information about the restore process" + ) + + static let confirmRestore = NSLocalizedString( + "restore.sheet.confirm.button", + value: "Restore to This Point", + comment: "Confirm button for restore action" + ) + + static let restoringTitle = NSLocalizedString( + "restore.sheet.restoring.title", + value: "Restoring Your Site", + comment: "Title shown while restore is in progress" + ) + + static let restoringMessage = NSLocalizedString( + "restore.sheet.restoring.message", + value: "We're restoring your site back to %1$@", + comment: "Message shown while restore is in progress. %1$@ is the backup date" + ) + + static let successTitle = NSLocalizedString( + "restore.sheet.success.title", + value: "Restore Complete!", + comment: "Title shown when restore succeeds" + ) + + static let successMessage = NSLocalizedString( + "restore.sheet.success.message", + value: "Your site has been successfully restored. It may take a few moments for all changes to appear.", + comment: "Message shown when restore succeeds" + ) + + static let failureTitle = NSLocalizedString( + "restore.sheet.failure.title", + value: "Restore Failed", + comment: "Title shown when restore fails" + ) + + static let failureMessage = NSLocalizedString( + "restore.sheet.failure.message", + value: "We couldn't restore your site. Please try again or contact support if the problem persists.", + comment: "Message shown when restore fails" + ) + + static let done = NSLocalizedString( + "restore.sheet.done.button", + value: "Done", + comment: "Done button to dismiss the sheet" + ) +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift new file mode 100644 index 000000000000..d3184680ae8d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift @@ -0,0 +1,124 @@ +import Foundation +import WordPressKit +import WordPressShared + +@MainActor +final class RestoreBackupViewModel: ObservableObject { + enum State { + case idle + case loading + case success + case failure + } + + @Published var state: State = .idle + @Published var errorMessage: String? + + private let activity: Activity + private let site: JetpackSiteRef + private let restoreService: JetpackRestoreService + private let activityStore: ActivityStore + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self.restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) + self.activityStore = StoreContainer.shared.activity + } + + func restore() { + guard state == .idle else { return } + + state = .loading + errorMessage = nil + + restoreService.restoreSite( + site, + rewindID: activity.rewindID, + restoreTypes: nil, // nil means restore everything + success: { [weak self] restoreID, jobID in + self?.handleRestoreStarted(restoreID: restoreID, jobID: jobID) + }, + failure: { [weak self] error in + self?.handleRestoreFailure(error) + } + ) + } + + private func handleRestoreStarted(restoreID: String, jobID: Int) { + // Start monitoring the restore status + pollRestoreStatus() + } + + private func pollRestoreStatus() { + restoreService.getRewindStatus( + for: site, + success: { [weak self] rewindStatus in + self?.handleRewindStatus(rewindStatus) + }, + failure: { [weak self] error in + self?.handleRestoreFailure(error) + } + ) + } + + private func handleRewindStatus(_ rewindStatus: RewindStatus) { + guard let restoreStatus = rewindStatus.restore else { + // No active restore, check if we need to keep polling + if state == .loading { + // Continue polling after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard self?.state == .loading else { return } + self?.pollRestoreStatus() + } + } + return + } + + switch restoreStatus.status { + case .finished: + state = .success + WPAnalytics.track(.restoreSucceeded, properties: ["source": "activity_detail"]) + + case .fail: + state = .failure + errorMessage = restoreStatus.message ?? Strings.defaultErrorMessage + WPAnalytics.track(.restoreFailed, properties: ["source": "activity_detail", "error": errorMessage ?? "unknown"]) + + case .running, .queued: + // Continue polling + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard self?.state == .loading else { return } + self?.pollRestoreStatus() + } + + default: + break + } + } + + private func handleRestoreFailure(_ error: Error) { + state = .failure + + if let networkError = error as? NSError { + errorMessage = networkError.localizedDescription + } else { + errorMessage = Strings.defaultErrorMessage + } + + WPAnalytics.track(.restoreFailed, properties: [ + "source": "activity_detail", + "error": error.localizedDescription + ]) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let defaultErrorMessage = NSLocalizedString( + "restore.viewModel.error.default", + value: "An error occurred while restoring your site. Please try again.", + comment: "Default error message for restore failures" + ) +} \ No newline at end of file From cfdd76d38ad93108279e12f5d93d6f0a697565ad Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:52:50 -0400 Subject: [PATCH 28/90] Remove JetpackSiteRef usages --- .../Details/ActivityLogDetailsView.swift | 30 ++++++++----------- .../Details/DownloadBackupSheet.swift | 8 ++--- .../Details/DownloadBackupViewModel.swift | 9 ++++-- .../Activity/Details/RestoreBackupSheet.swift | 8 ++--- .../Details/RestoreBackupViewModel.swift | 9 ++++-- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index db65e0f48052..34404a4bef82 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -5,7 +5,7 @@ import Gridicons struct ActivityLogDetailsView: View { let activity: Activity - let site: JetpackSiteRef + let blog: Blog @Environment(\.dismiss) var dismiss @State private var showingRestoreSheet = false @@ -30,10 +30,10 @@ struct ActivityLogDetailsView: View { .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showingRestoreSheet) { - RestoreBackupSheet(activity: activity, site: site) + RestoreBackupSheet(activity: activity, blog: blog) } .sheet(isPresented: $showingDownloadSheet) { - DownloadBackupSheet(activity: activity, site: site) + DownloadBackupSheet(activity: activity, blog: blog) } } @@ -212,7 +212,7 @@ private struct ActivityCard: View { NavigationView { ActivityLogDetailsView( activity: ActivityLogDetailsView.Mocks.mockBackupActivity, - site: JetpackSiteRef.mock + blog: Blog.mock ) } } @@ -221,7 +221,7 @@ private struct ActivityCard: View { NavigationView { ActivityLogDetailsView( activity: ActivityLogDetailsView.Mocks.mockPluginActivity, - site: JetpackSiteRef.mock + blog: Blog.mock ) } } @@ -230,7 +230,7 @@ private struct ActivityCard: View { NavigationView { ActivityLogDetailsView( activity: ActivityLogDetailsView.Mocks.mockLoginActivity, - site: JetpackSiteRef.mock + blog: Blog.mock ) } } @@ -266,17 +266,13 @@ private enum Strings { // MARK: - Preview Helpers #if DEBUG -extension JetpackSiteRef { - static var mock: JetpackSiteRef { - var ref = JetpackSiteRef( - siteID: 123456789, - username: "test", - homeURL: "https://example.wordpress.com", - isSelfHostedWithoutJetpack: false, - xmlRPC: nil - ) - // Use reflection to set private properties for preview - return ref +extension Blog { + static var mock: Blog { + let blog = Blog() + blog.dotComID = NSNumber(value: 123456789) + blog.url = "https://example.wordpress.com" + blog.xmlrpc = "https://example.wordpress.com/xmlrpc.php" + return blog } } #endif diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift index 46650b58df49..d0f804b582f0 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift @@ -4,15 +4,15 @@ import WordPressShared struct DownloadBackupSheet: View { let activity: Activity - let site: JetpackSiteRef + let blog: Blog @StateObject private var viewModel: DownloadBackupViewModel @Environment(\.dismiss) private var dismiss - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site - self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, site: site)) + self.blog = blog + self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, blog: blog)) } var body: some View { diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift index f8f443b8d2b2..a7b30ab7c11e 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift @@ -26,13 +26,18 @@ final class DownloadBackupViewModel: ObservableObject { } private let activity: Activity + private let blog: Blog private let site: JetpackSiteRef private let backupService: JetpackBackupService private var downloadID: Int? - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site + self.blog = blog + guard let siteRef = JetpackSiteRef(blog: blog) else { + fatalError("Invalid blog for backup download") + } + self.site = siteRef self.backupService = JetpackBackupService(coreDataStack: ContextManager.shared.contextManager.mainContext) } diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift index 2e44dff9ac04..bbea2e886216 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift @@ -4,15 +4,15 @@ import WordPressShared struct RestoreBackupSheet: View { let activity: Activity - let site: JetpackSiteRef + let blog: Blog @StateObject private var viewModel: RestoreBackupViewModel @Environment(\.dismiss) private var dismiss - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site - self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, site: site)) + self.blog = blog + self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, blog: blog)) } var body: some View { diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift index d3184680ae8d..9600a6e1f0e5 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift @@ -15,13 +15,18 @@ final class RestoreBackupViewModel: ObservableObject { @Published var errorMessage: String? private let activity: Activity + private let blog: Blog private let site: JetpackSiteRef private let restoreService: JetpackRestoreService private let activityStore: ActivityStore - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site + self.blog = blog + guard let siteRef = JetpackSiteRef(blog: blog) else { + fatalError("Invalid blog for restore") + } + self.site = siteRef self.restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) self.activityStore = StoreContainer.shared.activity } From b2e3c1cbffa75165657bfbb6388fa5da51e39407 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:54:42 -0400 Subject: [PATCH 29/90] Add multisite handling --- .../Activity/Details/RestoreBackupSheet.swift | 124 +++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift index bbea2e886216..c40b30242e3a 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift @@ -8,6 +8,8 @@ struct RestoreBackupSheet: View { @StateObject private var viewModel: RestoreBackupViewModel @Environment(\.dismiss) private var dismiss + @State private var isMultisite: Bool = false + @State private var isCheckingRewindStatus: Bool = true init(activity: Activity, blog: Blog) { self.activity = activity @@ -18,7 +20,12 @@ struct RestoreBackupSheet: View { var body: some View { NavigationView { VStack(spacing: 0) { - if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { + if isCheckingRewindStatus { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if isMultisite { + multisiteWarningView + } else if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { progressView } else { confirmationView @@ -27,9 +34,9 @@ struct RestoreBackupSheet: View { .navigationTitle(Strings.restoreTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { - if viewModel.state == .idle { + if viewModel.state == .idle || isMultisite { ToolbarItem(placement: .navigationBarLeading) { - Button(Strings.cancel) { + Button(isMultisite ? Strings.done : Strings.cancel) { dismiss() } } @@ -39,6 +46,7 @@ struct RestoreBackupSheet: View { } .onAppear { WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) + checkRewindStatus() } } @@ -230,10 +238,102 @@ struct RestoreBackupSheet: View { formatter.timeStyle = .short return formatter.string(from: activity.published) } + + private func checkRewindStatus() { + guard let siteRef = JetpackSiteRef(blog: blog) else { + isCheckingRewindStatus = false + return + } + + let restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) + restoreService.getRewindStatus( + for: siteRef, + success: { [weak self] rewindStatus in + DispatchQueue.main.async { + self?.isMultisite = rewindStatus.isMultisite() + self?.isCheckingRewindStatus = false + } + }, + failure: { [weak self] _ in + DispatchQueue.main.async { + // On error, assume it's not multisite and proceed + self?.isCheckingRewindStatus = false + } + } + ) + } + + @ViewBuilder + private var multisiteWarningView: some View { + VStack(spacing: 32) { + Spacer() + + VStack(spacing: 24) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + VStack(spacing: 16) { + Text(Strings.multisiteTitle) + .font(.title3) + .fontWeight(.semibold) + + // Create attributed string for the multisite message + Text(multisiteMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + .tint(.accentColor) + } + } + + Spacer() + + VStack(spacing: 16) { + Link(destination: URL(string: Constants.multisiteDocumentationURL)!) { + Text(Strings.learnMore) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + + Text(Strings.multisiteDownloadHint) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .padding(.bottom, 24) + } + } + + private var multisiteMessage: AttributedString { + // Use the localized string from RewindStatus.Strings + let fullString = RewindStatus.Strings.multisiteNotAvailable + let linkSubstring = RewindStatus.Strings.multisiteNotAvailableSubstring + + var attributedString = AttributedString(fullString) + + // Find and style the link portion + if let range = attributedString.range(of: linkSubstring) { + attributedString[range].foregroundColor = .accentColor + attributedString[range].underlineStyle = .single + } + + return attributedString + } } // MARK: - Localized Strings +private enum Constants { + static let multisiteDocumentationURL = "https://jetpack.com/support/backup/restoring-your-site-from-backup/#multisite-restores" +} + private enum Strings { static let restoreTitle = NSLocalizedString( "restore.sheet.title", @@ -318,4 +418,22 @@ private enum Strings { value: "Done", comment: "Done button to dismiss the sheet" ) + + static let multisiteTitle = NSLocalizedString( + "restore.sheet.multisite.title", + value: "Restore Not Available", + comment: "Title for multisite restore limitation" + ) + + static let multisiteDownloadHint = NSLocalizedString( + "restore.sheet.multisite.downloadHint", + value: "You can still download a backup of your site", + comment: "Hint that download is still available for multisite" + ) + + static let learnMore = NSLocalizedString( + "restore.sheet.multisite.learnMore", + value: "Learn More", + comment: "Button to open documentation about multisite limitations" + ) } \ No newline at end of file From e57f31c12be73c3d7787f94b4c17029771bffb31 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:06:46 -0400 Subject: [PATCH 30/90] Remove the new screens and use the existing flows --- CLAUDE.md | 1 + .../ActivityLogDetailsCoordinator.swift | 86 ++++ .../Details/ActivityLogDetailsView.swift | 42 +- .../Details/DownloadBackupSheet.swift | 417 ----------------- .../Details/DownloadBackupViewModel.swift | 172 ------- .../Activity/Details/RestoreBackupSheet.swift | 439 ------------------ .../Details/RestoreBackupViewModel.swift | 129 ----- 7 files changed, 113 insertions(+), 1173 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift diff --git a/CLAUDE.md b/CLAUDE.md index e69c9ec831a3..a3b75932d407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ WordPress-iOS uses a modular architecture with the main app and separate Swift p - Follow Swift API Design Guidelines - Use strict access control modifiers where possible - Use four spaces (not tabs) +- Use semantics text sizes like `.headline` ## Development Workflow - Branch from `trunk` (main branch) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift new file mode 100644 index 000000000000..26950e674b13 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -0,0 +1,86 @@ +import UIKit +import SwiftUI +import WordPressKit + +/// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers +class ActivityLogDetailsCoordinator: UIViewRepresentable { + static weak var shared: ActivityLogDetailsCoordinator? + + let activity: Activity + let blog: Blog + + init(activity: Activity, blog: Blog) { + self.activity = activity + self.blog = blog + ActivityLogDetailsCoordinator.shared = self + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.isHidden = true + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + // No updates needed + } + + func presentRestore() { + guard let viewController = topViewController(), + let siteRef = JetpackSiteRef(blog: blog), + activity.isRewindable, + activity.rewindID != nil else { + return + } + + // Check if the store has the credentials status cached + let store = StoreContainer.shared.activity + let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) + + let restoreViewController = JetpackRestoreOptionsViewController( + site: siteRef, + activity: activity, + isAwaitingCredentials: isAwaitingCredentials + ) + + restoreViewController.presentedFrom = "activity_detail" + + let navigationController = UINavigationController(rootViewController: restoreViewController) + navigationController.modalPresentationStyle = .formSheet + + viewController.present(navigationController, animated: true) + } + + func presentBackup() { + guard let viewController = topViewController(), + let siteRef = JetpackSiteRef(blog: blog) else { + return + } + + let backupViewController = JetpackBackupOptionsViewController( + site: siteRef, + activity: activity + ) + + backupViewController.presentedFrom = "activity_detail" + + let navigationController = UINavigationController(rootViewController: backupViewController) + navigationController.modalPresentationStyle = .formSheet + + viewController.present(navigationController, animated: true) + } + + private func topViewController() -> UIViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + return nil + } + + var topController = window.rootViewController + while let presentedViewController = topController?.presentedViewController { + topController = presentedViewController + } + + return topController + } +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 34404a4bef82..22364392a553 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -8,8 +8,6 @@ struct ActivityLogDetailsView: View { let blog: Blog @Environment(\.dismiss) var dismiss - @State private var showingRestoreSheet = false - @State private var showingDownloadSheet = false var body: some View { VStack(spacing: 0) { @@ -23,18 +21,32 @@ struct ActivityLogDetailsView: View { .padding() } - if activity.isRewindable { + if shouldShowBackupActions { actionButtons } } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingRestoreSheet) { - RestoreBackupSheet(activity: activity, blog: blog) - } - .sheet(isPresented: $showingDownloadSheet) { - DownloadBackupSheet(activity: activity, blog: blog) - } + .background( + ActivityLogDetailsCoordinator( + activity: activity, + blog: blog + ) + ) + } + + private var shouldShowBackupActions: Bool { + // Show buttons for rewindable activities that are backup-related + guard activity.isRewindable else { return false } + + // Check if this is a backup activity based on the activity name + let backupActivityNames = [ + "rewind__backup_complete_full", + "rewind__backup_complete", + "rewind__backup_error" + ] + + return backupActivityNames.contains(activity.name) } @ViewBuilder @@ -45,7 +57,7 @@ struct ActivityLogDetailsView: View { HStack(spacing: 12) { // Restore Backup - Primary Button Button(action: { - showingRestoreSheet = true + ActivityLogDetailsCoordinator.shared?.presentRestore() }) { HStack { Image(systemName: "arrow.counterclockwise") @@ -62,7 +74,7 @@ struct ActivityLogDetailsView: View { // Download Backup - Secondary Button Button(action: { - showingDownloadSheet = true + ActivityLogDetailsCoordinator.shared?.presentBackup() }) { HStack { Image(systemName: "arrow.down.circle") @@ -268,11 +280,9 @@ private enum Strings { #if DEBUG extension Blog { static var mock: Blog { - let blog = Blog() - blog.dotComID = NSNumber(value: 123456789) - blog.url = "https://example.wordpress.com" - blog.xmlrpc = "https://example.wordpress.com/xmlrpc.php" - return blog + // For previews, we'll return a dummy blog object + // In real previews, this should be provided by the parent view + return Blog() } } #endif diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift deleted file mode 100644 index d0f804b582f0..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift +++ /dev/null @@ -1,417 +0,0 @@ -import SwiftUI -import WordPressKit -import WordPressShared - -struct DownloadBackupSheet: View { - let activity: Activity - let blog: Blog - - @StateObject private var viewModel: DownloadBackupViewModel - @Environment(\.dismiss) private var dismiss - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, blog: blog)) - } - - var body: some View { - NavigationView { - VStack(spacing: 0) { - if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { - progressView - } else { - downloadOptionsView - } - } - .navigationTitle(Strings.downloadTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if viewModel.state == .idle { - ToolbarItem(placement: .navigationBarLeading) { - Button(Strings.cancel) { - dismiss() - } - } - } - } - .interactiveDismissDisabled(viewModel.state == .loading) - } - .onAppear { - WPAnalytics.track(.backupDownloadOpened, properties: ["source": "activity_detail"]) - } - } - - @ViewBuilder - private var downloadOptionsView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Activity Header - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - if let icon = activity.icon { - Image(uiImage: icon) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .foregroundColor(Color(activity.statusColor)) - .padding(12) - .background(Color(activity.statusColor).opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - - VStack(alignment: .leading, spacing: 4) { - Text(activity.summary) - .font(.headline) - .lineLimit(2) - - Text(formattedDate) - .font(.footnote) - .foregroundColor(.secondary) - } - - Spacer() - } - - if !activity.text.isEmpty { - Text(activity.text) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - - // Download Options - VStack(alignment: .leading, spacing: 16) { - Text(Strings.optionsTitle) - .font(.headline) - - VStack(spacing: 0) { - DownloadOptionRow( - title: Strings.optionThemes, - isSelected: $viewModel.includeThemes - ) - Divider().padding(.leading, 44) - - DownloadOptionRow( - title: Strings.optionPlugins, - isSelected: $viewModel.includePlugins - ) - Divider().padding(.leading, 44) - - DownloadOptionRow( - title: Strings.optionUploads, - isSelected: $viewModel.includeUploads - ) - Divider().padding(.leading, 44) - - DownloadOptionRow( - title: Strings.optionContent, - subtitle: Strings.optionContentSubtitle, - isSelected: $viewModel.includeContent - ) - } - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - } - - // Info Section - VStack(alignment: .leading, spacing: 8) { - Text(Strings.infoTitle) - .font(.footnote) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .textCase(.uppercase) - - Text(Strings.infoMessage) - .font(.footnote) - .foregroundColor(.secondary) - } - } - .padding() - } - - Spacer() - - // Bottom Action Button - VStack(spacing: 16) { - Divider() - - Button(action: { - viewModel.downloadBackup() - WPAnalytics.track(.backupDownloadConfirmed, properties: ["source": "activity_detail"]) - }) { - Text(Strings.confirmDownload) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(viewModel.hasSelection ? Color.accentColor : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(!viewModel.hasSelection) - .padding(.horizontal) - .padding(.bottom, 8) - } - } - - @ViewBuilder - private var progressView: some View { - VStack(spacing: 32) { - Spacer() - - switch viewModel.state { - case .loading: - VStack(spacing: 24) { - ProgressView() - .scaleEffect(1.5) - - VStack(spacing: 8) { - Text(Strings.preparingTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(Strings.preparingMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - case .success: - VStack(spacing: 24) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.green) - - VStack(spacing: 8) { - Text(Strings.successTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(Strings.successMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - if let downloadURL = viewModel.downloadURL { - Link(destination: URL(string: downloadURL)!) { - Text(Strings.downloadLink) - .font(.subheadline) - .fontWeight(.medium) - } - .padding(.top, 8) - } - } - } - - case .failure: - VStack(spacing: 24) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.red) - - VStack(spacing: 8) { - Text(Strings.failureTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(viewModel.errorMessage ?? Strings.failureMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - default: - EmptyView() - } - - Spacer() - - if viewModel.state == .success || viewModel.state == .failure { - Button(action: { - dismiss() - }) { - Text(Strings.done) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.bottom, 24) - } - } - } - - private var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: activity.published) - } -} - -// MARK: - Download Option Row - -private struct DownloadOptionRow: View { - let title: String - var subtitle: String? = nil - @Binding var isSelected: Bool - - var body: some View { - Button(action: { - isSelected.toggle() - }) { - HStack(spacing: 12) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 24)) - .foregroundColor(isSelected ? .accentColor : .secondary) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.body) - .foregroundColor(.primary) - - if let subtitle = subtitle { - Text(subtitle) - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - } - .padding() - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } -} - -// MARK: - Localized Strings - -private enum Strings { - static let downloadTitle = NSLocalizedString( - "download.sheet.title", - value: "Download Backup", - comment: "Title for the download backup sheet" - ) - - static let cancel = NSLocalizedString( - "download.sheet.cancel", - value: "Cancel", - comment: "Cancel button for download sheet" - ) - - static let optionsTitle = NSLocalizedString( - "download.sheet.options.title", - value: "Choose items to download", - comment: "Title for download options section" - ) - - static let optionThemes = NSLocalizedString( - "download.sheet.option.themes", - value: "Themes", - comment: "Option to download themes" - ) - - static let optionPlugins = NSLocalizedString( - "download.sheet.option.plugins", - value: "Plugins", - comment: "Option to download plugins" - ) - - static let optionUploads = NSLocalizedString( - "download.sheet.option.uploads", - value: "Media uploads", - comment: "Option to download media uploads" - ) - - static let optionContent = NSLocalizedString( - "download.sheet.option.content", - value: "Content", - comment: "Option to download content" - ) - - static let optionContentSubtitle = NSLocalizedString( - "download.sheet.option.content.subtitle", - value: "Posts, pages, and comments", - comment: "Subtitle for content option" - ) - - static let infoTitle = NSLocalizedString( - "download.sheet.info.title", - value: "About backup downloads", - comment: "Info section title in download sheet" - ) - - static let infoMessage = NSLocalizedString( - "download.sheet.info.message", - value: "Your backup will be prepared as a downloadable file. You'll receive an email with the download link when it's ready.", - comment: "Information about the download process" - ) - - static let confirmDownload = NSLocalizedString( - "download.sheet.confirm.button", - value: "Create downloadable file", - comment: "Confirm button for download action" - ) - - static let preparingTitle = NSLocalizedString( - "download.sheet.preparing.title", - value: "Preparing Your Backup", - comment: "Title shown while backup is being prepared" - ) - - static let preparingMessage = NSLocalizedString( - "download.sheet.preparing.message", - value: "We're creating a downloadable backup file. This may take a few moments.", - comment: "Message shown while backup is being prepared" - ) - - static let successTitle = NSLocalizedString( - "download.sheet.success.title", - value: "Backup Ready!", - comment: "Title shown when backup is ready" - ) - - static let successMessage = NSLocalizedString( - "download.sheet.success.message", - value: "Your backup has been prepared. You'll receive an email with the download link shortly.", - comment: "Message shown when backup is ready" - ) - - static let downloadLink = NSLocalizedString( - "download.sheet.success.link", - value: "Download now", - comment: "Link to download the backup" - ) - - static let failureTitle = NSLocalizedString( - "download.sheet.failure.title", - value: "Backup Failed", - comment: "Title shown when backup preparation fails" - ) - - static let failureMessage = NSLocalizedString( - "download.sheet.failure.message", - value: "We couldn't prepare your backup. Please try again or contact support if the problem persists.", - comment: "Message shown when backup preparation fails" - ) - - static let done = NSLocalizedString( - "download.sheet.done.button", - value: "Done", - comment: "Done button to dismiss the sheet" - ) -} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift deleted file mode 100644 index a7b30ab7c11e..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift +++ /dev/null @@ -1,172 +0,0 @@ -import Foundation -import WordPressKit -import WordPressShared - -@MainActor -final class DownloadBackupViewModel: ObservableObject { - enum State { - case idle - case loading - case success - case failure - } - - @Published var state: State = .idle - @Published var errorMessage: String? - @Published var downloadURL: String? - - // Download options - @Published var includeThemes = true - @Published var includePlugins = true - @Published var includeUploads = true - @Published var includeContent = true - - var hasSelection: Bool { - includeThemes || includePlugins || includeUploads || includeContent - } - - private let activity: Activity - private let blog: Blog - private let site: JetpackSiteRef - private let backupService: JetpackBackupService - private var downloadID: Int? - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - guard let siteRef = JetpackSiteRef(blog: blog) else { - fatalError("Invalid blog for backup download") - } - self.site = siteRef - self.backupService = JetpackBackupService(coreDataStack: ContextManager.shared.contextManager.mainContext) - } - - func downloadBackup() { - guard state == .idle, hasSelection else { return } - - state = .loading - errorMessage = nil - downloadURL = nil - - let restoreTypes = buildRestoreTypes() - - backupService.prepareBackup( - for: site, - rewindID: activity.rewindID, - restoreTypes: restoreTypes, - success: { [weak self] backup in - self?.handleBackupPrepared(backup) - }, - failure: { [weak self] error in - self?.handleBackupFailure(error) - } - ) - } - - private func buildRestoreTypes() -> JetpackRestoreTypes { - var types = JetpackRestoreTypes() - types.themes = includeThemes - types.plugins = includePlugins - types.uploads = includeUploads - types.sqls = includeContent - types.roots = includeContent - types.contents = includeContent - return types - } - - private func handleBackupPrepared(_ backup: JetpackBackup) { - downloadID = backup.downloadID - - // Check if backup is already ready - if let url = backup.url, !url.isEmpty { - downloadURL = url - state = .success - WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) - } else { - // Start polling for backup status - pollBackupStatus() - } - } - - private func pollBackupStatus() { - guard let downloadID = downloadID else { - handleBackupFailure(BackupError.missingDownloadID) - return - } - - backupService.getBackupStatus( - for: site, - downloadID: downloadID, - success: { [weak self] backup in - self?.handleBackupStatus(backup) - }, - failure: { [weak self] error in - self?.handleBackupFailure(error) - } - ) - } - - private func handleBackupStatus(_ backup: JetpackBackup) { - if let url = backup.url, !url.isEmpty { - // Backup is ready - downloadURL = url - state = .success - WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) - } else if backup.progress == nil { - // Backup failed - state = .failure - errorMessage = Strings.defaultErrorMessage - WPAnalytics.track(.backupDownloadFailed, properties: ["source": "activity_detail", "error": "no_progress"]) - } else { - // Still in progress, continue polling - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard self?.state == .loading else { return } - self?.pollBackupStatus() - } - } - } - - private func handleBackupFailure(_ error: Error) { - state = .failure - - if let networkError = error as? NSError { - errorMessage = networkError.localizedDescription - } else { - errorMessage = Strings.defaultErrorMessage - } - - WPAnalytics.track(.backupDownloadFailed, properties: [ - "source": "activity_detail", - "error": error.localizedDescription - ]) - } -} - -// MARK: - Errors - -private enum BackupError: LocalizedError { - case missingDownloadID - - var errorDescription: String? { - switch self { - case .missingDownloadID: - return Strings.missingDownloadIDError - } - } -} - -// MARK: - Localized Strings - -private enum Strings { - static let defaultErrorMessage = NSLocalizedString( - "download.viewModel.error.default", - value: "An error occurred while preparing your backup. Please try again.", - comment: "Default error message for backup download failures" - ) - - static let missingDownloadIDError = NSLocalizedString( - "download.viewModel.error.missingID", - value: "Unable to track backup progress. Please try again.", - comment: "Error when download ID is missing" - ) -} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift deleted file mode 100644 index c40b30242e3a..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift +++ /dev/null @@ -1,439 +0,0 @@ -import SwiftUI -import WordPressKit -import WordPressShared - -struct RestoreBackupSheet: View { - let activity: Activity - let blog: Blog - - @StateObject private var viewModel: RestoreBackupViewModel - @Environment(\.dismiss) private var dismiss - @State private var isMultisite: Bool = false - @State private var isCheckingRewindStatus: Bool = true - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, blog: blog)) - } - - var body: some View { - NavigationView { - VStack(spacing: 0) { - if isCheckingRewindStatus { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if isMultisite { - multisiteWarningView - } else if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { - progressView - } else { - confirmationView - } - } - .navigationTitle(Strings.restoreTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if viewModel.state == .idle || isMultisite { - ToolbarItem(placement: .navigationBarLeading) { - Button(isMultisite ? Strings.done : Strings.cancel) { - dismiss() - } - } - } - } - .interactiveDismissDisabled(viewModel.state == .loading) - } - .onAppear { - WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) - checkRewindStatus() - } - } - - @ViewBuilder - private var confirmationView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Activity Header - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - if let icon = activity.icon { - Image(uiImage: icon) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .foregroundColor(Color(activity.statusColor)) - .padding(12) - .background(Color(activity.statusColor).opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - - VStack(alignment: .leading, spacing: 4) { - Text(activity.summary) - .font(.headline) - .lineLimit(2) - - Text(formattedDate) - .font(.footnote) - .foregroundColor(.secondary) - } - - Spacer() - } - - if !activity.text.isEmpty { - Text(activity.text) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - - // Warning Section - VStack(alignment: .leading, spacing: 12) { - Label(Strings.warningTitle, systemImage: "exclamationmark.triangle.fill") - .font(.headline) - .foregroundColor(.orange) - - Text(Strings.warningMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding() - .background(Color.orange.opacity(0.1)) - .cornerRadius(12) - - // Info Section - VStack(alignment: .leading, spacing: 8) { - Text(Strings.infoTitle) - .font(.footnote) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .textCase(.uppercase) - - Text(Strings.infoMessage) - .font(.footnote) - .foregroundColor(.secondary) - } - } - .padding() - } - - Spacer() - - // Bottom Action Button - VStack(spacing: 16) { - Divider() - - Button(action: { - viewModel.restore() - WPAnalytics.track(.restoreConfirmed, properties: ["source": "activity_detail"]) - }) { - Text(Strings.confirmRestore) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.bottom, 8) - } - } - - @ViewBuilder - private var progressView: some View { - VStack(spacing: 32) { - Spacer() - - switch viewModel.state { - case .loading: - VStack(spacing: 24) { - ProgressView() - .scaleEffect(1.5) - - VStack(spacing: 8) { - Text(Strings.restoringTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(String(format: Strings.restoringMessage, formattedDate)) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - case .success: - VStack(spacing: 24) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.green) - - VStack(spacing: 8) { - Text(Strings.successTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(Strings.successMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - case .failure: - VStack(spacing: 24) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.red) - - VStack(spacing: 8) { - Text(Strings.failureTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(viewModel.errorMessage ?? Strings.failureMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - default: - EmptyView() - } - - Spacer() - - if viewModel.state == .success || viewModel.state == .failure { - Button(action: { - dismiss() - }) { - Text(Strings.done) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.bottom, 24) - } - } - } - - private var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: activity.published) - } - - private func checkRewindStatus() { - guard let siteRef = JetpackSiteRef(blog: blog) else { - isCheckingRewindStatus = false - return - } - - let restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) - restoreService.getRewindStatus( - for: siteRef, - success: { [weak self] rewindStatus in - DispatchQueue.main.async { - self?.isMultisite = rewindStatus.isMultisite() - self?.isCheckingRewindStatus = false - } - }, - failure: { [weak self] _ in - DispatchQueue.main.async { - // On error, assume it's not multisite and proceed - self?.isCheckingRewindStatus = false - } - } - ) - } - - @ViewBuilder - private var multisiteWarningView: some View { - VStack(spacing: 32) { - Spacer() - - VStack(spacing: 24) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 60)) - .foregroundColor(.orange) - - VStack(spacing: 16) { - Text(Strings.multisiteTitle) - .font(.title3) - .fontWeight(.semibold) - - // Create attributed string for the multisite message - Text(multisiteMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - .tint(.accentColor) - } - } - - Spacer() - - VStack(spacing: 16) { - Link(destination: URL(string: Constants.multisiteDocumentationURL)!) { - Text(Strings.learnMore) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - - Text(Strings.multisiteDownloadHint) - .font(.footnote) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - .padding(.bottom, 24) - } - } - - private var multisiteMessage: AttributedString { - // Use the localized string from RewindStatus.Strings - let fullString = RewindStatus.Strings.multisiteNotAvailable - let linkSubstring = RewindStatus.Strings.multisiteNotAvailableSubstring - - var attributedString = AttributedString(fullString) - - // Find and style the link portion - if let range = attributedString.range(of: linkSubstring) { - attributedString[range].foregroundColor = .accentColor - attributedString[range].underlineStyle = .single - } - - return attributedString - } -} - -// MARK: - Localized Strings - -private enum Constants { - static let multisiteDocumentationURL = "https://jetpack.com/support/backup/restoring-your-site-from-backup/#multisite-restores" -} - -private enum Strings { - static let restoreTitle = NSLocalizedString( - "restore.sheet.title", - value: "Restore Site", - comment: "Title for the restore backup sheet" - ) - - static let cancel = NSLocalizedString( - "restore.sheet.cancel", - value: "Cancel", - comment: "Cancel button for restore sheet" - ) - - static let warningTitle = NSLocalizedString( - "restore.sheet.warning.title", - value: "Warning", - comment: "Warning section title in restore sheet" - ) - - static let warningMessage = NSLocalizedString( - "restore.sheet.warning.message", - value: "Restoring your site will revert all content, settings, and configurations to this backup point. Any changes made after this backup will be lost.", - comment: "Warning message about restore consequences" - ) - - static let infoTitle = NSLocalizedString( - "restore.sheet.info.title", - value: "What happens next", - comment: "Info section title in restore sheet" - ) - - static let infoMessage = NSLocalizedString( - "restore.sheet.info.message", - value: "The restore process typically takes a few minutes. You'll receive a notification when it's complete. Your site may be temporarily unavailable during the restore.", - comment: "Information about the restore process" - ) - - static let confirmRestore = NSLocalizedString( - "restore.sheet.confirm.button", - value: "Restore to This Point", - comment: "Confirm button for restore action" - ) - - static let restoringTitle = NSLocalizedString( - "restore.sheet.restoring.title", - value: "Restoring Your Site", - comment: "Title shown while restore is in progress" - ) - - static let restoringMessage = NSLocalizedString( - "restore.sheet.restoring.message", - value: "We're restoring your site back to %1$@", - comment: "Message shown while restore is in progress. %1$@ is the backup date" - ) - - static let successTitle = NSLocalizedString( - "restore.sheet.success.title", - value: "Restore Complete!", - comment: "Title shown when restore succeeds" - ) - - static let successMessage = NSLocalizedString( - "restore.sheet.success.message", - value: "Your site has been successfully restored. It may take a few moments for all changes to appear.", - comment: "Message shown when restore succeeds" - ) - - static let failureTitle = NSLocalizedString( - "restore.sheet.failure.title", - value: "Restore Failed", - comment: "Title shown when restore fails" - ) - - static let failureMessage = NSLocalizedString( - "restore.sheet.failure.message", - value: "We couldn't restore your site. Please try again or contact support if the problem persists.", - comment: "Message shown when restore fails" - ) - - static let done = NSLocalizedString( - "restore.sheet.done.button", - value: "Done", - comment: "Done button to dismiss the sheet" - ) - - static let multisiteTitle = NSLocalizedString( - "restore.sheet.multisite.title", - value: "Restore Not Available", - comment: "Title for multisite restore limitation" - ) - - static let multisiteDownloadHint = NSLocalizedString( - "restore.sheet.multisite.downloadHint", - value: "You can still download a backup of your site", - comment: "Hint that download is still available for multisite" - ) - - static let learnMore = NSLocalizedString( - "restore.sheet.multisite.learnMore", - value: "Learn More", - comment: "Button to open documentation about multisite limitations" - ) -} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift deleted file mode 100644 index 9600a6e1f0e5..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -import WordPressKit -import WordPressShared - -@MainActor -final class RestoreBackupViewModel: ObservableObject { - enum State { - case idle - case loading - case success - case failure - } - - @Published var state: State = .idle - @Published var errorMessage: String? - - private let activity: Activity - private let blog: Blog - private let site: JetpackSiteRef - private let restoreService: JetpackRestoreService - private let activityStore: ActivityStore - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - guard let siteRef = JetpackSiteRef(blog: blog) else { - fatalError("Invalid blog for restore") - } - self.site = siteRef - self.restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) - self.activityStore = StoreContainer.shared.activity - } - - func restore() { - guard state == .idle else { return } - - state = .loading - errorMessage = nil - - restoreService.restoreSite( - site, - rewindID: activity.rewindID, - restoreTypes: nil, // nil means restore everything - success: { [weak self] restoreID, jobID in - self?.handleRestoreStarted(restoreID: restoreID, jobID: jobID) - }, - failure: { [weak self] error in - self?.handleRestoreFailure(error) - } - ) - } - - private func handleRestoreStarted(restoreID: String, jobID: Int) { - // Start monitoring the restore status - pollRestoreStatus() - } - - private func pollRestoreStatus() { - restoreService.getRewindStatus( - for: site, - success: { [weak self] rewindStatus in - self?.handleRewindStatus(rewindStatus) - }, - failure: { [weak self] error in - self?.handleRestoreFailure(error) - } - ) - } - - private func handleRewindStatus(_ rewindStatus: RewindStatus) { - guard let restoreStatus = rewindStatus.restore else { - // No active restore, check if we need to keep polling - if state == .loading { - // Continue polling after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard self?.state == .loading else { return } - self?.pollRestoreStatus() - } - } - return - } - - switch restoreStatus.status { - case .finished: - state = .success - WPAnalytics.track(.restoreSucceeded, properties: ["source": "activity_detail"]) - - case .fail: - state = .failure - errorMessage = restoreStatus.message ?? Strings.defaultErrorMessage - WPAnalytics.track(.restoreFailed, properties: ["source": "activity_detail", "error": errorMessage ?? "unknown"]) - - case .running, .queued: - // Continue polling - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard self?.state == .loading else { return } - self?.pollRestoreStatus() - } - - default: - break - } - } - - private func handleRestoreFailure(_ error: Error) { - state = .failure - - if let networkError = error as? NSError { - errorMessage = networkError.localizedDescription - } else { - errorMessage = Strings.defaultErrorMessage - } - - WPAnalytics.track(.restoreFailed, properties: [ - "source": "activity_detail", - "error": error.localizedDescription - ]) - } -} - -// MARK: - Localized Strings - -private enum Strings { - static let defaultErrorMessage = NSLocalizedString( - "restore.viewModel.error.default", - value: "An error occurred while restoring your site. Please try again.", - comment: "Default error message for restore failures" - ) -} \ No newline at end of file From f6624ce39daea4446621d452a6b839bf879b6ba5 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:10:28 -0400 Subject: [PATCH 31/90] Add analytics --- .../ActivityLogDetailsCoordinator.swift | 68 +++++-------------- .../Details/ActivityLogDetailsView.swift | 56 ++++++++++----- 2 files changed, 56 insertions(+), 68 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift index 26950e674b13..b3b0320118ef 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -3,84 +3,50 @@ import SwiftUI import WordPressKit /// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers -class ActivityLogDetailsCoordinator: UIViewRepresentable { - static weak var shared: ActivityLogDetailsCoordinator? - - let activity: Activity - let blog: Blog - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - ActivityLogDetailsCoordinator.shared = self - } - - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.isHidden = true - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - // No updates needed - } - - func presentRestore() { - guard let viewController = topViewController(), +enum ActivityLogDetailsCoordinator { + + static func presentRestore(activity: Activity, blog: Blog) { + guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog), activity.isRewindable, activity.rewindID != nil else { return } - + // Check if the store has the credentials status cached let store = StoreContainer.shared.activity let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) - + let restoreViewController = JetpackRestoreOptionsViewController( site: siteRef, activity: activity, isAwaitingCredentials: isAwaitingCredentials ) - + restoreViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: restoreViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - - func presentBackup() { - guard let viewController = topViewController(), + + static func presentBackup(activity: Activity, blog: Blog) { + guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog) else { return } - + let backupViewController = JetpackBackupOptionsViewController( site: siteRef, activity: activity ) - + backupViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: backupViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - - private func topViewController() -> UIViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { - return nil - } - - var topController = window.rootViewController - while let presentedViewController = topController?.presentedViewController { - topController = presentedViewController - } - - return topController - } -} \ No newline at end of file +} diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 22364392a553..a435d2f3ede5 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -1,6 +1,7 @@ import SwiftUI import WordPressKit import WordPressUI +import WordPressShared import Gridicons struct ActivityLogDetailsView: View { @@ -20,44 +21,42 @@ struct ActivityLogDetailsView: View { } .padding() } - + if shouldShowBackupActions { actionButtons } } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) - .background( - ActivityLogDetailsCoordinator( - activity: activity, - blog: blog - ) - ) + .onAppear { + trackDetailViewed() + } } - + private var shouldShowBackupActions: Bool { // Show buttons for rewindable activities that are backup-related guard activity.isRewindable else { return false } - + // Check if this is a backup activity based on the activity name let backupActivityNames = [ "rewind__backup_complete_full", "rewind__backup_complete", "rewind__backup_error" ] - + return backupActivityNames.contains(activity.name) } - + @ViewBuilder private var actionButtons: some View { VStack(spacing: 12) { Divider() - + HStack(spacing: 12) { // Restore Backup - Primary Button Button(action: { - ActivityLogDetailsCoordinator.shared?.presentRestore() + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) }) { HStack { Image(systemName: "arrow.counterclockwise") @@ -71,10 +70,11 @@ struct ActivityLogDetailsView: View { .foregroundColor(.white) .cornerRadius(8) } - + // Download Backup - Secondary Button Button(action: { - ActivityLogDetailsCoordinator.shared?.presentBackup() + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) }) { HStack { Image(systemName: "arrow.down.circle") @@ -261,13 +261,13 @@ private enum Strings { value: "User", comment: "Section title for user information" ) - + static let restoreBackup = NSLocalizedString( "activityDetail.restoreBackup.button", value: "Restore Backup", comment: "Button title for restoring a backup" ) - + static let downloadBackup = NSLocalizedString( "activityDetail.downloadBackup.button", value: "Download Backup", @@ -275,6 +275,28 @@ private enum Strings { ) } +// MARK: - Analytics + +private extension ActivityLogDetailsView { + func trackDetailViewed() { + WPAnalytics.track(.activityLogDetailViewed, withProperties: ["source": presentedFrom()]) + } + + func trackRestoreTapped() { + WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) + } + + func trackBackupTapped() { + WPAnalytics.track(.backupDownloadOpened, properties: ["source": "activity_detail"]) + } + + func presentedFrom() -> String { + // Since we're in SwiftUI, we'll default to "activity_log" + // In the future, this could be passed as a parameter + return "activity_log" + } +} + // MARK: - Preview Helpers #if DEBUG From cbf1b14b46e706fe7c122d208a05d5427e418417 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:14:46 -0400 Subject: [PATCH 32/90] Cleanup --- .../ActivityLogDetailsCoordinator.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift index b3b0320118ef..be6a5693e606 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -4,7 +4,7 @@ import WordPressKit /// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers enum ActivityLogDetailsCoordinator { - + static func presentRestore(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog), @@ -12,41 +12,41 @@ enum ActivityLogDetailsCoordinator { activity.rewindID != nil else { return } - + // Check if the store has the credentials status cached let store = StoreContainer.shared.activity let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) - + let restoreViewController = JetpackRestoreOptionsViewController( site: siteRef, activity: activity, isAwaitingCredentials: isAwaitingCredentials ) - + restoreViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: restoreViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - + static func presentBackup(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog) else { return } - + let backupViewController = JetpackBackupOptionsViewController( site: siteRef, activity: activity ) - + backupViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: backupViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } } From d3d4bb6be0f6c37c8926fc7903741e2a6ef75ab3 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:26:50 -0400 Subject: [PATCH 33/90] Add missing analytics --- .../Details/ActivityLogDetailsView.swift | 5 ----- .../List/ActivityLogRowViewModel.swift | 2 +- .../Activity/List/ActivityLogsMenu.swift | 15 +++++++++++-- .../Activity/List/ActivityLogsView.swift | 7 +++--- .../Activity/List/ActivityLogsViewModel.swift | 22 +++++++++++++++++++ 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index a435d2f3ede5..33996a3c5102 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -41,7 +41,6 @@ struct ActivityLogDetailsView: View { let backupActivityNames = [ "rewind__backup_complete_full", "rewind__backup_complete", - "rewind__backup_error" ] return backupActivityNames.contains(activity.name) @@ -60,9 +59,7 @@ struct ActivityLogDetailsView: View { }) { HStack { Image(systemName: "arrow.counterclockwise") - .font(.system(size: 16, weight: .medium)) Text(Strings.restoreBackup) - .font(.system(size: 16, weight: .medium)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) @@ -78,9 +75,7 @@ struct ActivityLogDetailsView: View { }) { HStack { Image(systemName: "arrow.down.circle") - .font(.system(size: 16, weight: .regular)) Text(Strings.downloadBackup) - .font(.system(size: 16, weight: .regular)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift index 0c233a8a9c3c..d7f040c79404 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -20,7 +20,7 @@ struct ActivityLogRowViewModel: Identifiable { self.activity = activity self.id = activity.activityID if let actor = activity.actor { - if actor.role.isEmpty { + if !actor.role.isEmpty { actorSubtitle = actor.role.localizedCapitalized } else if !actor.type.isEmpty { actorSubtitle = actor.type.localizedCapitalized diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift index 7b7d26780941..b059c5816844 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift @@ -1,5 +1,6 @@ import SwiftUI import WordPressKit +import WordPressShared struct ActivityLogsMenu: View { @ObservedObject var viewModel: ActivityLogsViewModel @@ -29,14 +30,16 @@ struct ActivityLogsMenu: View { DatePickerSheet( title: Strings.startDate, selection: $viewModel.parameters.startDate, - isPresented: $isShowingStartDatePicker + isPresented: $isShowingStartDatePicker, + viewModel: viewModel ) } .sheet(isPresented: $isShowingEndDatePicker) { DatePickerSheet( title: Strings.endDate, selection: $viewModel.parameters.endDate, - isPresented: $isShowingEndDatePicker + isPresented: $isShowingEndDatePicker, + viewModel: viewModel ) } } @@ -45,6 +48,8 @@ struct ActivityLogsMenu: View { Group { // Start Date Button { + // Track analytics for date filter tap + WPAnalytics.track(.activitylogFilterbarRangeButtonTapped) isShowingStartDatePicker = true } label: { Text(Strings.startDate) @@ -56,6 +61,8 @@ struct ActivityLogsMenu: View { // End Date Button { + // Track analytics for date filter tap + WPAnalytics.track(.activitylogFilterbarRangeButtonTapped) isShowingEndDatePicker = true } label: { Text(Strings.endDate) @@ -69,6 +76,7 @@ struct ActivityLogsMenu: View { private var activityTypeFilter: some View { Button { + WPAnalytics.track(.activitylogFilterbarTypeButtonTapped) isShowingActivityTypePicker = true } label: { Text(Strings.activityTypes) @@ -81,6 +89,8 @@ struct ActivityLogsMenu: View { private var resetFiltersButton: some View { Button(role: .destructive) { + WPAnalytics.track(.activitylogFilterbarResetRange) + WPAnalytics.track(.activitylogFilterbarResetType) viewModel.parameters = GetActivityLogsParameters() } label: { Label(Strings.resetFilters, systemImage: "arrow.counterclockwise") @@ -92,6 +102,7 @@ private struct DatePickerSheet: View { let title: String @Binding var selection: Date? @Binding var isPresented: Bool + var viewModel: ActivityLogsViewModel? @State private var date = Date() diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index f424e4b9cff2..c4b7f4615531 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -23,7 +23,7 @@ private struct ActivityLogsListView: View { var body: some View { List { if let response = viewModel.response { - ActivityLogsPaginatedForEach(response: response) + ActivityLogsPaginatedForEach(response: response, blog: viewModel.blog) if viewModel.isFreePlan { Text(Strings.freePlanNotice) @@ -70,13 +70,14 @@ private struct ActivityLogsSearchView: View { searchText: viewModel.searchText, search: viewModel.search ) { response in - ActivityLogsPaginatedForEach(response: response) + ActivityLogsPaginatedForEach(response: response, blog: viewModel.blog) } } } private struct ActivityLogsPaginatedForEach: View { @ObservedObject var response: ActivityLogsPaginatedResponse + let blog: Blog struct ActivityGroup: Identifiable { var id: Date { date } @@ -115,7 +116,7 @@ private struct ActivityLogsPaginatedForEach: View { .onAppear { response.onRowAppeared(item) } .background { NavigationLink { - ActivityLogDetailsView(activity: item.activity) + ActivityLogDetailsView(activity: item.activity, blog: blog) } label: { EmptyView() }.opacity(0) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift index 06fd2acd9095..a66b1547cc1a 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -1,6 +1,7 @@ import Foundation import WordPressKit import WordPressUI +import WordPressShared typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse @@ -11,6 +12,7 @@ final class ActivityLogsViewModel: ObservableObject { @Published var searchText = "" @Published var parameters = GetActivityLogsParameters() { didSet { + trackParameterChanges(oldValue: oldValue, newValue: parameters) response = nil onRefreshNeeded() } @@ -103,6 +105,26 @@ final class ActivityLogsViewModel: ObservableObject { ) } } + + // MARK: - Analytics + + private func trackParameterChanges(oldValue: GetActivityLogsParameters, newValue: GetActivityLogsParameters) { + // Track date range changes + if oldValue.startDate != newValue.startDate || oldValue.endDate != newValue.endDate { + if newValue.startDate != nil || newValue.endDate != nil { + WPAnalytics.track(.activitylogFilterbarSelectRange) + } + } + + // Track activity type changes + if oldValue.activityTypes != newValue.activityTypes { + if newValue.activityTypes.isEmpty { + WPAnalytics.track(.activitylogFilterbarResetType) + } else { + WPAnalytics.track(.activitylogFilterbarSelectType, properties: ["count": newValue.activityTypes.count]) + } + } + } } private func makeViewModels(for activities: [Activity]) async -> [ActivityLogRowViewModel] { From 053cd420321bf8c3ef21976c8d0fec1cae8d2bae Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:30:43 -0400 Subject: [PATCH 34/90] Cleanup --- .gitignore | 3 + .../Details/ActivityLogDetailsView.swift | 96 ++++++++----------- 2 files changed, 41 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 9924e15ba219..0a0b9d4bf719 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ DerivedData *.hmap *.xcscmblueprint +# Claude +.claude/settings.local.json + # Windows Thumbs.db ehthumbs.db diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 33996a3c5102..8442d046a3ab 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -11,20 +11,19 @@ struct ActivityLogDetailsView: View { @Environment(\.dismiss) var dismiss var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 24) { + ScrollView { + VStack(spacing: 24) { + VStack(spacing: 16) { ActivityHeaderView(activity: activity) - if let actor = activity.actor { - ActorCard(actor: actor) + if shouldShowBackupActions { + backupActionButtons } } - .padding() - } - - if shouldShowBackupActions { - actionButtons + if let actor = activity.actor { + ActorCard(actor: actor) + } } + .padding() } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) @@ -47,51 +46,32 @@ struct ActivityLogDetailsView: View { } @ViewBuilder - private var actionButtons: some View { - VStack(spacing: 12) { - Divider() - - HStack(spacing: 12) { - // Restore Backup - Primary Button - Button(action: { - trackRestoreTapped() - ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) - }) { - HStack { - Image(systemName: "arrow.counterclockwise") - Text(Strings.restoreBackup) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - - // Download Backup - Secondary Button - Button(action: { - trackBackupTapped() - ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) - }) { - HStack { - Image(systemName: "arrow.down.circle") - Text(Strings.downloadBackup) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(Color.clear) - .foregroundColor(.accentColor) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.accentColor, lineWidth: 1) - ) - } + private var backupActionButtons: some View { + HStack(spacing: 12) { + Button(action: { + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) + }) { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + + Button(action: { + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) + }) { + Label(Strings.download, systemImage: "arrow.down.circle") + .fontWeight(.medium) } - .padding(.horizontal) - .padding(.bottom, 12) + .buttonStyle(.bordered) + .controlSize(.regular) + .tint(.accentColor) } - .background(Color(.systemBackground)) + .frame(maxWidth: .infinity, alignment: .leading) } + } // MARK: - Header View @@ -257,15 +237,15 @@ private enum Strings { comment: "Section title for user information" ) - static let restoreBackup = NSLocalizedString( - "activityDetail.restoreBackup.button", - value: "Restore Backup", + static let restore = NSLocalizedString( + "activityDetail.restore.button", + value: "Restore", comment: "Button title for restoring a backup" ) - static let downloadBackup = NSLocalizedString( - "activityDetail.downloadBackup.button", - value: "Download Backup", + static let download = NSLocalizedString( + "activityDetail.download.button", + value: "Download", comment: "Button title for downloading a backup" ) } From 16c4c6ed4c7bffc8d2593b053c43e8fe1c5ed740 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:58:04 -0400 Subject: [PATCH 35/90] Fix an issue with not all restorable acitivies shown in backups --- .../ActivityLogDetailsCoordinator.swift | 22 +++++++++---------- .../Details/ActivityLogDetailsView.swift | 17 ++------------ .../Activity/List/ActivityLogsViewModel.swift | 6 ++--- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift index be6a5693e606..b3b0320118ef 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -4,7 +4,7 @@ import WordPressKit /// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers enum ActivityLogDetailsCoordinator { - + static func presentRestore(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog), @@ -12,41 +12,41 @@ enum ActivityLogDetailsCoordinator { activity.rewindID != nil else { return } - + // Check if the store has the credentials status cached let store = StoreContainer.shared.activity let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) - + let restoreViewController = JetpackRestoreOptionsViewController( site: siteRef, activity: activity, isAwaitingCredentials: isAwaitingCredentials ) - + restoreViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: restoreViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - + static func presentBackup(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog) else { return } - + let backupViewController = JetpackBackupOptionsViewController( site: siteRef, activity: activity ) - + backupViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: backupViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 8442d046a3ab..d4e2a50f8a26 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -15,7 +15,7 @@ struct ActivityLogDetailsView: View { VStack(spacing: 24) { VStack(spacing: 16) { ActivityHeaderView(activity: activity) - if shouldShowBackupActions { + if activity.isRewindable { backupActionButtons } } @@ -32,19 +32,6 @@ struct ActivityLogDetailsView: View { } } - private var shouldShowBackupActions: Bool { - // Show buttons for rewindable activities that are backup-related - guard activity.isRewindable else { return false } - - // Check if this is a backup activity based on the activity name - let backupActivityNames = [ - "rewind__backup_complete_full", - "rewind__backup_complete", - ] - - return backupActivityNames.contains(activity.name) - } - @ViewBuilder private var backupActionButtons: some View { HStack(spacing: 12) { @@ -57,7 +44,7 @@ struct ActivityLogDetailsView: View { } .buttonStyle(.borderedProminent) .controlSize(.regular) - + Button(action: { trackBackupTapped() ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift index a66b1547cc1a..15058bd494fd 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -105,9 +105,9 @@ final class ActivityLogsViewModel: ObservableObject { ) } } - + // MARK: - Analytics - + private func trackParameterChanges(oldValue: GetActivityLogsParameters, newValue: GetActivityLogsParameters) { // Track date range changes if oldValue.startDate != newValue.startDate || oldValue.endDate != newValue.endDate { @@ -115,7 +115,7 @@ final class ActivityLogsViewModel: ObservableObject { WPAnalytics.track(.activitylogFilterbarSelectRange) } } - + // Track activity type changes if oldValue.activityTypes != newValue.activityTypes { if newValue.activityTypes.isEmpty { From d7bdf750237a341a2de68af3ef16153c4689ec48 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:06:18 -0400 Subject: [PATCH 36/90] Fix an issue with restore/download flow layout --- RELEASE-NOTES.txt | 2 ++ .../Restore Status/BaseRestoreStatusViewController.swift | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 77b5c4ab1c71..5903c9b2c2d6 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,8 @@ * [**] Add new “Subscribers” screen that shows both your email and Reader subscribers [#24513] * [*] Fix an issue with “Stats / Subscribers” sometimes not showing the latest email subscribers [#24513] * [*] Fix an issue with "Stats" / "Subscribers" / "Emails" showing html encoded characters [#24513] +* [*] Add search to “Jetpack Activity List” and display actors and dates [#24597] +* [*] Fix an issue with content in "Restore" and "Download Backup" flows covering the navigation bar [#24597] 25.9 ----- diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift index 7e41f7ea14f3..f03e97184f04 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressUI import WordPressShared struct JetpackRestoreStatusConfiguration { @@ -92,7 +93,7 @@ class BaseRestoreStatusViewController: UIViewController { statusView.update(progress: 0, progressTitle: configuration.placeholderProgressTitle, progressDescription: nil) view.addSubview(statusView) - view.pinSubviewToAllEdges(statusView) + statusView.pinEdges(to: view.safeAreaLayoutGuide) } @objc private func doneTapped() { From 2ece5e946950e26f18bd94dc0e7268a51c26fc68 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:19:23 -0400 Subject: [PATCH 37/90] Show restore checkpoint in section --- .../Details/ActivityLogDetailsView.swift | 99 ++++++++++++------- .../BaseRestoreCompleteViewController.swift | 3 +- .../JetpackRestoreWarningViewController.swift | 3 +- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index d4e2a50f8a26..5fa24885976e 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -13,11 +13,15 @@ struct ActivityLogDetailsView: View { var body: some View { ScrollView { VStack(spacing: 24) { - VStack(spacing: 16) { - ActivityHeaderView(activity: activity) - if activity.isRewindable { - backupActionButtons - } + ActivityHeaderView(activity: activity) + if activity.isRewindable { + RestoreSiteCard(activity: activity, onRestoreTapped: { + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) + }, onBackupTapped: { + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) + }) } if let actor = activity.actor { ActorCard(actor: actor) @@ -31,34 +35,6 @@ struct ActivityLogDetailsView: View { trackDetailViewed() } } - - @ViewBuilder - private var backupActionButtons: some View { - HStack(spacing: 12) { - Button(action: { - trackRestoreTapped() - ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) - }) { - Label(Strings.restore, systemImage: "arrow.counterclockwise") - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - - Button(action: { - trackBackupTapped() - ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) - }) { - Label(Strings.download, systemImage: "arrow.down.circle") - .fontWeight(.medium) - } - .buttonStyle(.bordered) - .controlSize(.regular) - .tint(.accentColor) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } // MARK: - Header View @@ -148,6 +124,51 @@ private struct ActorCard: View { } } +// MARK: - Restore Site Card + +private struct RestoreSiteCard: View { + let activity: Activity + let onRestoreTapped: () -> Void + let onBackupTapped: () -> Void + + var body: some View { + ActivityCard(Strings.restoreSite) { + + VStack(spacing: 16) { + // Checkpoint date info row + HStack { + Text(Strings.checkpointDate) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Text(activity.published.formatted(date: .abbreviated, time: .standard)) + .font(.subheadline) + .foregroundStyle(.primary) + } + + // Action buttons + HStack(spacing: 12) { + Button(action: onRestoreTapped) { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + + Button(action: onBackupTapped) { + Label(Strings.download, systemImage: "arrow.down.circle") + .fontWeight(.medium) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .tint(.accentColor) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + // MARK: - Shared Components private struct ActivityCard: View { @@ -224,6 +245,18 @@ private enum Strings { comment: "Section title for user information" ) + static let restoreSite = NSLocalizedString( + "activityDetail.section.restoreSite", + value: "Restore Site", + comment: "Section title for restore site actions" + ) + + static let checkpointDate = NSLocalizedString( + "activityDetail.checkpointDate", + value: "Checkpoint Date", + comment: "Label for the backup checkpoint date" + ) + static let restore = NSLocalizedString( "activityDetail.restore.button", value: "Restore", diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift index 40f7488b9482..cab7f11339c8 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift @@ -1,5 +1,6 @@ import Foundation import WordPressShared +import WordPressUI struct JetpackRestoreCompleteConfiguration { let title: String @@ -103,7 +104,7 @@ class BaseRestoreCompleteViewController: UIViewController { } view.addSubview(completeView) - view.pinSubviewToAllEdges(completeView) + completeView.pinEdges(to: view.safeAreaLayoutGuide) } @objc private func doneTapped() { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift index ad9eeeea5523..e08e32727e18 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift @@ -1,6 +1,7 @@ import UIKit import WordPressFlux import WordPressShared +import WordPressUI class JetpackRestoreWarningViewController: UIViewController { @@ -80,7 +81,7 @@ class JetpackRestoreWarningViewController: UIViewController { warningView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(warningView) - view.pinSubviewToAllEdges(warningView) + warningView.pinEdges(to: view.safeAreaLayoutGuide) } } From 4af44a5c1d9e8ec0618ad9d60f36ca8a07ab218a Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:28:22 -0400 Subject: [PATCH 38/90] Extract reusable CardView and InfoRow components from SubscribersDetailsVIew --- .../Sources/WordPressUI/Views/CardView.swift | 56 ++++++++++++ .../Sources/WordPressUI/Views/InfoRow.swift | 88 ++++++++++++++++++ .../Details/ActivityLogDetailsView.swift | 44 +-------- .../Details/SubscriberDetailsView.swift | 91 ++++--------------- 4 files changed, 163 insertions(+), 116 deletions(-) create mode 100644 Modules/Sources/WordPressUI/Views/CardView.swift create mode 100644 Modules/Sources/WordPressUI/Views/InfoRow.swift diff --git a/Modules/Sources/WordPressUI/Views/CardView.swift b/Modules/Sources/WordPressUI/Views/CardView.swift new file mode 100644 index 000000000000..21b519c7dd11 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/CardView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// A reusable card view component that provides a consistent container style +/// with optional title and customizable content. +public struct CardView: View { + let title: String? + @ViewBuilder let content: () -> Content + + public init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.content = content + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + Group { + if let title { + Text(title.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(.separator), lineWidth: 0.5) + ) + } +} + +#Preview("With Title") { + CardView("Section Title") { + VStack(alignment: .leading, spacing: 12) { + Text("Card Content") + Text("More content here") + .foregroundStyle(.secondary) + } + } + .padding() +} + +#Preview("Without Title") { + CardView { + HStack { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + Text("Featured Item") + Spacer() + } + } + .padding() +} \ No newline at end of file diff --git a/Modules/Sources/WordPressUI/Views/InfoRow.swift b/Modules/Sources/WordPressUI/Views/InfoRow.swift new file mode 100644 index 000000000000..7ba80648c6f0 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/InfoRow.swift @@ -0,0 +1,88 @@ +import SwiftUI + +/// A reusable info row component that displays a title and customizable content. +/// Commonly used within cards or forms to display labeled information. +public struct InfoRow: View { + let title: String + @ViewBuilder let content: () -> Content + + public init(_ title: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.content = content + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + content() + .font(.subheadline.weight(.regular)) + .lineLimit(1) + .textSelection(.enabled) + } + } +} + +// MARK: - Convenience Initializer + +extension InfoRow where Content == Text { + /// Convenience initializer for displaying a simple text value. + /// If the value is nil, displays a dash placeholder. + public init(_ title: String, value: String?) { + self.init(title) { + Text(value ?? "–") + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Previews + +#Preview("Text Value") { + VStack(spacing: 16) { + InfoRow("Email", value: "user@example.com") + InfoRow("Country", value: "United States") + InfoRow("Phone", value: nil) + } + .padding() +} + +#Preview("Custom Content") { + VStack(spacing: 16) { + InfoRow("Status") { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + Text("Active") + .foregroundStyle(.green) + } + } + + InfoRow("Website") { + Link("example.com", destination: URL(string: "https://example.com")!) + } + + InfoRow("Tags") { + HStack(spacing: 4) { + Text("Swift") + Image(systemName: "chevron.forward") + .font(.caption2) + } + .foregroundStyle(.tint) + } + } + .padding() +} + +#Preview("In Card") { + CardView("User Details") { + VStack(spacing: 16) { + InfoRow("Name", value: "John Appleseed") + InfoRow("Email", value: "john@example.com") + InfoRow("Member Since", value: "January 2024") + } + } + .padding() +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 5fa24885976e..986f9294bc90 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -103,7 +103,7 @@ private struct ActorCard: View { let actor: ActivityActor var body: some View { - ActivityCard(Strings.user) { + CardView(Strings.user) { HStack(spacing: 12) { // Actor avatar ActivityActorAvatarView(actor: actor, diameter: 40) @@ -132,18 +132,11 @@ private struct RestoreSiteCard: View { let onBackupTapped: () -> Void var body: some View { - ActivityCard(Strings.restoreSite) { - + CardView(Strings.restoreSite) { VStack(spacing: 16) { // Checkpoint date info row - HStack { - Text(Strings.checkpointDate) - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() + InfoRow(Strings.checkpointDate) { Text(activity.published.formatted(date: .abbreviated, time: .standard)) - .font(.subheadline) - .foregroundStyle(.primary) } // Action buttons @@ -169,37 +162,6 @@ private struct RestoreSiteCard: View { } } -// MARK: - Shared Components - -private struct ActivityCard: View { - let title: String? - @ViewBuilder let content: () -> Content - - init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - if let title { - Text(title.uppercased()) - .font(.caption) - .foregroundStyle(.secondary) - } - - content() - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding() - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color(.separator), lineWidth: 0.5) - ) - } -} // MARK: - Preview diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift index d9a5a41bb6fe..9e1af2deffe1 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift @@ -51,7 +51,7 @@ struct SubscriberDetailsView: View { SubscriberDetailsHeaderView(subscriber: info) } if let detailsError { - SubscriberDetailsCardView { + CardView { EmptyStateView.failure(error: detailsError) { Task { await refresh() } } @@ -123,8 +123,8 @@ struct SubscriberDetailsView: View { // MARK: Views private func makeNewsletterSubscriptionSection(for details: SubscribersServiceRemote.GetSubscriberDetailsResponse) -> some View { - SubscriberDetailsCardView(Strings.sectionNewsletterSubscription) { - SubscriberInfoRow(Strings.fieldSubscriptionDate, value: viewModel.formattedDateSubscribed(details.dateSubscribed)) + CardView(Strings.sectionNewsletterSubscription) { + InfoRow(Strings.fieldSubscriptionDate, value: viewModel.formattedDateSubscribed(details.dateSubscribed)) let plans = details.plans ?? [] if let plan = plans.first { NavigationLink { @@ -137,7 +137,7 @@ struct SubscriberDetailsView: View { .navigationTitle(Strings.fieldPlan) .navigationBarTitleDisplayMode(.inline) } label: { - SubscriberInfoRow(Strings.fieldPlan) { + InfoRow(Strings.fieldPlan) { HStack(spacing: 4) { Text(plan.title) Image(systemName: "chevron.forward") @@ -148,13 +148,13 @@ struct SubscriberDetailsView: View { } .buttonStyle(.plain) } else { - SubscriberInfoRow(Strings.fieldPlan, value: Strings.free) + InfoRow(Strings.fieldPlan, value: Strings.free) } } } private func makePlanView(for plan: SubscribersServiceRemote.GetSubscriberDetailsResponse.Plan) -> some View { - SubscriberDetailsCardView { + CardView { HStack { Text(plan.title) .font(.headline) @@ -166,25 +166,25 @@ struct SubscriberDetailsView: View { .foregroundStyle(.secondary) } } - SubscriberInfoRow(Strings.fieldPlanStatus, value: plan.status) + InfoRow(Strings.fieldPlanStatus, value: plan.status) if plan.renewInterval != "one-time" { - SubscriberInfoRow(Strings.fieldRenewalInterval, value: plan.renewInterval) - SubscriberInfoRow(Strings.fieldRenewalPrice, value: { + InfoRow(Strings.fieldRenewalInterval, value: plan.renewInterval) + InfoRow(Strings.fieldRenewalPrice, value: { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = plan.currency return formatter.string(from: plan.renewalPrice as NSNumber) }()) } - SubscriberInfoRow(Strings.fieldPlanStartDate, value: plan.startDate.formatted(date: .abbreviated, time: .shortened)) - SubscriberInfoRow(Strings.fieldPlanEndDate, value: plan.endDate.formatted(date: .abbreviated, time: .shortened)) + InfoRow(Strings.fieldPlanStartDate, value: plan.startDate.formatted(date: .abbreviated, time: .shortened)) + InfoRow(Strings.fieldPlanEndDate, value: plan.endDate.formatted(date: .abbreviated, time: .shortened)) } } @ViewBuilder private func makeSubscriberDetailsSections(for details: SubscribersServiceRemote.GetSubscriberDetailsResponse) -> some View { - SubscriberDetailsCardView(Strings.sectionSubscriberDetails) { - SubscriberInfoRow(Strings.fieldEmail) { + CardView(Strings.sectionSubscriberDetails) { + InfoRow(Strings.fieldEmail) { if let email = details.emailAddress, let url = URL(string: "mailto://\(email)") { Link(email, destination: url) } else { @@ -192,9 +192,9 @@ struct SubscriberDetailsView: View { .foregroundStyle(.secondary) } } - SubscriberInfoRow(Strings.fieldCountry, value: details.country?.name) + InfoRow(Strings.fieldCountry, value: details.country?.name) if let site = details.siteURL { - SubscriberInfoRow(Strings.fieldSite) { + InfoRow(Strings.fieldSite) { if let siteURL = URL(string: site) { Link(site, destination: siteURL) } else { @@ -235,7 +235,7 @@ private struct SubscriberStatsView: View { let stats: SubscribersServiceRemote.GetSubscriberStatsResponse var body: some View { - SubscriberDetailsCardView { + CardView { HStack { SubsciberStatsRow( systemImage: "envelope", @@ -263,36 +263,6 @@ private struct SubscriberStatsView: View { } } -private struct SubscriberInfoRow: View { - let title: String - @ViewBuilder let content: () -> Content - - init(_ title: String, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - content() - .font(.subheadline.weight(.regular)) - .lineLimit(1) - .textSelection(.enabled) - } - } -} - -extension SubscriberInfoRow where Content == Text { - init(_ title: String, value: String?) { - self.init(title) { - Text(value ?? "–") - .foregroundColor(AppColor.secondary) - } - } -} private struct SubsciberStatsRow: View { let systemImage: String @@ -314,35 +284,6 @@ private struct SubsciberStatsRow: View { } } -private struct SubscriberDetailsCardView: View { - let title: String? - @ViewBuilder let content: () -> Content - - init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Group { - if let title { - Text(title.uppercased()) - .font(.caption) - .foregroundStyle(.secondary) - } - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding() - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color(.separator), lineWidth: 0.5) - ) - } -} private extension SubscribersServiceRemote.GetSubscriberStatsResponse { var formattedEmailsCount: String { From ea903d777d784e48d7ffcc16f72221d343578a31 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:38:00 -0400 Subject: [PATCH 39/90] Cleanup --- .../Sources/WordPressUI/Views/CardView.swift | 6 +- .../Sources/WordPressUI/Views/InfoRow.swift | 13 +- .../Details/ActivityLogDetailsView.swift | 122 ++++++++---------- .../Details/SubscriberDetailsView.swift | 2 - 4 files changed, 66 insertions(+), 77 deletions(-) diff --git a/Modules/Sources/WordPressUI/Views/CardView.swift b/Modules/Sources/WordPressUI/Views/CardView.swift index 21b519c7dd11..34e09bf6168b 100644 --- a/Modules/Sources/WordPressUI/Views/CardView.swift +++ b/Modules/Sources/WordPressUI/Views/CardView.swift @@ -5,12 +5,12 @@ import SwiftUI public struct CardView: View { let title: String? @ViewBuilder let content: () -> Content - + public init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { self.title = title self.content = content } - + public var body: some View { VStack(alignment: .leading, spacing: 16) { Group { @@ -53,4 +53,4 @@ public struct CardView: View { } } .padding() -} \ No newline at end of file +} diff --git a/Modules/Sources/WordPressUI/Views/InfoRow.swift b/Modules/Sources/WordPressUI/Views/InfoRow.swift index 7ba80648c6f0..a8d389e4c366 100644 --- a/Modules/Sources/WordPressUI/Views/InfoRow.swift +++ b/Modules/Sources/WordPressUI/Views/InfoRow.swift @@ -1,16 +1,17 @@ import SwiftUI +import DesignSystem /// A reusable info row component that displays a title and customizable content. /// Commonly used within cards or forms to display labeled information. public struct InfoRow: View { let title: String @ViewBuilder let content: () -> Content - + public init(_ title: String, @ViewBuilder content: @escaping () -> Content) { self.title = title self.content = content } - + public var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) @@ -32,7 +33,7 @@ extension InfoRow where Content == Text { public init(_ title: String, value: String?) { self.init(title) { Text(value ?? "–") - .foregroundStyle(.secondary) + .foregroundColor(AppColor.secondary) } } } @@ -59,11 +60,11 @@ extension InfoRow where Content == Text { .foregroundStyle(.green) } } - + InfoRow("Website") { Link("example.com", destination: URL(string: "https://example.com")!) } - + InfoRow("Tags") { HStack(spacing: 4) { Text("Swift") @@ -85,4 +86,4 @@ extension InfoRow where Content == Text { } } .padding() -} \ No newline at end of file +} diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 986f9294bc90..80bf3d6e1d88 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -14,17 +14,11 @@ struct ActivityLogDetailsView: View { ScrollView { VStack(spacing: 24) { ActivityHeaderView(activity: activity) - if activity.isRewindable { - RestoreSiteCard(activity: activity, onRestoreTapped: { - trackRestoreTapped() - ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) - }, onBackupTapped: { - trackBackupTapped() - ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) - }) - } if let actor = activity.actor { - ActorCard(actor: actor) + makeActorCard(for: actor) + } + if activity.isRewindable { + restoreSiteCard } } .padding() @@ -35,6 +29,58 @@ struct ActivityLogDetailsView: View { trackDetailViewed() } } + + private func makeActorCard(for actor: ActivityActor) -> some View { + CardView(Strings.user) { + HStack(spacing: 12) { + // Actor avatar + ActivityActorAvatarView(actor: actor, diameter: 40) + + // Actor info + VStack(alignment: .leading, spacing: 2) { + Text(actor.displayName) + .font(.headline) + + Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + } + } + } + + private var restoreSiteCard: some View { + CardView(Strings.restoreSite) { + // Checkpoint date info row + InfoRow(Strings.checkpointDate) { + Text(activity.published.formatted(date: .abbreviated, time: .standard)) + } + + // Action buttons + HStack(spacing: 12) { + Button(action: { + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) + }) { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + + Button(action: { + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) + }) { + Label(Strings.download, systemImage: "arrow.down.circle") + .fontWeight(.medium) + } + .buttonStyle(.bordered) + .tint(.accentColor) + } + } + } } // MARK: - Header View @@ -103,66 +149,10 @@ private struct ActorCard: View { let actor: ActivityActor var body: some View { - CardView(Strings.user) { - HStack(spacing: 12) { - // Actor avatar - ActivityActorAvatarView(actor: actor, diameter: 40) - - // Actor info - VStack(alignment: .leading, spacing: 2) { - Text(actor.displayName) - .font(.headline) - - Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - } - } - } -} - -// MARK: - Restore Site Card - -private struct RestoreSiteCard: View { - let activity: Activity - let onRestoreTapped: () -> Void - let onBackupTapped: () -> Void - var body: some View { - CardView(Strings.restoreSite) { - VStack(spacing: 16) { - // Checkpoint date info row - InfoRow(Strings.checkpointDate) { - Text(activity.published.formatted(date: .abbreviated, time: .standard)) - } - - // Action buttons - HStack(spacing: 12) { - Button(action: onRestoreTapped) { - Label(Strings.restore, systemImage: "arrow.counterclockwise") - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - - Button(action: onBackupTapped) { - Label(Strings.download, systemImage: "arrow.down.circle") - .fontWeight(.medium) - } - .buttonStyle(.bordered) - .controlSize(.regular) - .tint(.accentColor) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } } } - // MARK: - Preview #Preview("Backup Activity") { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift index 9e1af2deffe1..5a3e836e02c7 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift @@ -263,7 +263,6 @@ private struct SubscriberStatsView: View { } } - private struct SubsciberStatsRow: View { let systemImage: String let title: String @@ -284,7 +283,6 @@ private struct SubsciberStatsRow: View { } } - private extension SubscribersServiceRemote.GetSubscriberStatsResponse { var formattedEmailsCount: String { emailsSent.formatted(.number.notation(.compactName)) From 5e60653dac96e6eca7cc9fec270a2e65440eb8fe Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 17:29:39 -0400 Subject: [PATCH 40/90] Add rewindable --- Modules/Package.resolved | 4 +-- Modules/Package.swift | 2 +- .../Activity/List/ActivityLogsView.swift | 26 +++++++++++++++---- .../List/ActivityLogsViewController.swift | 9 ++++--- .../Activity/List/ActivityLogsViewModel.swift | 16 ++++++++---- .../BlogDetailsViewController+Swift.swift | 17 +++++++++--- 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 22d66276f8bd..a4b19f453615 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1aecad5b79a89459675bbe1705ca2f091cedc0a3874c3ce3e07aef3d60f2b061", + "originHash" : "fa035de2741e39c23eaba17409869db3ebef935eb88585d1f08d5dc2497e6d7d", "pins" : [ { "identity" : "alamofire", @@ -390,7 +390,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "30dadcab01a980eb16976340c1e9e8a9527ddc05" + "revision" : "ae3961ce89ac0c43a90e88d4963a04aa92008443" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index c5ef1152b7a9..7e40bab002a0 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "30dadcab01a980eb16976340c1e9e8a9527ddc05" // see wpios-edition branch + revision: "ae3961ce89ac0c43a90e88d4963a04aa92008443" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index c4b7f4615531..d40a4305f949 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -6,14 +6,20 @@ struct ActivityLogsView: View { @ObservedObject var viewModel: ActivityLogsViewModel var body: some View { - Group { + let content = Group { if !viewModel.searchText.isEmpty { ActivityLogsSearchView(viewModel: viewModel) } else { ActivityLogsListView(viewModel: viewModel) } } - .searchable(text: $viewModel.searchText) + .navigationTitle(viewModel.isBackupMode ? Strings.backupsTitle : Strings.activityTitle) + + if viewModel.isBackupMode { + content + } else { + content.searchable(text: $viewModel.searchText) + } } } @@ -38,7 +44,11 @@ private struct ActivityLogsListView: View { .overlay { if let response = viewModel.response { if response.isEmpty { - EmptyStateView(Strings.empty, systemImage: "archivebox") + EmptyStateView( + viewModel.isBackupMode ? Strings.emptyBackups : Strings.empty, + systemImage: "archivebox", + description: viewModel.isBackupMode ? Strings.emptyBackupsSubtitle : nil + ) } } else if viewModel.isLoading { ProgressView() @@ -55,8 +65,10 @@ private struct ActivityLogsListView: View { await viewModel.refresh() } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - ActivityLogsMenu(viewModel: viewModel) + if !viewModel.isBackupMode { + ToolbarItem(placement: .navigationBarTrailing) { + ActivityLogsMenu(viewModel: viewModel) + } } } } @@ -127,4 +139,8 @@ private struct ActivityLogsPaginatedForEach: View { private enum Strings { static let empty = NSLocalizedString("activityLogs.empty", value: "No Activity", comment: "Empty state message for activity logs") static let freePlanNotice = NSLocalizedString("activityLogs.freePlan.notice", value: "Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Notice shown to free plan users about limited activity log events") + static let activityTitle = NSLocalizedString("activityLogs.title", value: "Activity", comment: "Title for activity logs screen") + static let backupsTitle = NSLocalizedString("backups.title", value: "Backups", comment: "Title for backups screen") + static let emptyBackups = NSLocalizedString("backups.empty.title", value: "Your first backup will be ready soon", comment: "Title for the view when there aren't any Backups to display") + static let emptyBackupsSubtitle = NSLocalizedString("backups.empty.subtitle", value: "Your first backup will appear here within 24 hours and you will receive a notification once the backup has been completed", comment: "Text displayed in the view when there aren't any Backups to display") } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift index 6b5905cda685..7356e25aab23 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift @@ -6,10 +6,10 @@ import WordPressKit final class ActivityLogsViewController: UIHostingController { private let viewModel: ActivityLogsViewModel - init(blog: Blog) { - self.viewModel = ActivityLogsViewModel(blog: blog) + init(blog: Blog, isBackupMode: Bool = false) { + self.viewModel = ActivityLogsViewModel(blog: blog, isBackupMode: isBackupMode) super.init(rootView: AnyView(ActivityLogsView(viewModel: viewModel))) - self.title = Strings.title + self.title = isBackupMode ? Strings.backupsTitle : Strings.activityTitle } required dynamic init?(coder aDecoder: NSCoder) { @@ -18,5 +18,6 @@ final class ActivityLogsViewController: UIHostingController { } private enum Strings { - static let title = NSLocalizedString("activity.logs.title", value: "Activity", comment: "Title for the activity logs screen") + static let activityTitle = NSLocalizedString("activity.logs.title", value: "Activity", comment: "Title for the activity logs screen") + static let backupsTitle = NSLocalizedString("backups.title", value: "Backups", comment: "Title for the backups screen") } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift index 15058bd494fd..593b9f4ee742 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -8,6 +8,7 @@ typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse ActivityLogsPaginatedResponse { - try await ActivityLogsPaginatedResponse { [blog] offset in + try await ActivityLogsPaginatedResponse { [blog, isBackupMode] offset in guard let siteID = blog.dotComID?.intValue, let api = blog.wordPressComRestApi else { throw NSError(domain: "ActivityLogs", code: 0, userInfo: [NSLocalizedDescriptionKey: SharedStrings.Error.generic]) } let service = ActivityServiceRemote(wordPressComRestApi: api) let offset = offset ?? 0 + let pageSize = 32 + let (activities, hasMore) = try await service.getActivities( siteID: siteID, offset: offset, - pageSize: 32, + pageSize: pageSize, searchText: searchText, - parameters: parameters + parameters: parameters, + rewindable: isBackupMode ? true : nil ) let viewModels = await makeViewModels(for: activities) return ActivityLogsPaginatedResponse.Page( @@ -142,7 +147,7 @@ struct GetActivityLogsParameters: Hashable { } private extension ActivityServiceRemote { - func getActivities(siteID: Int, offset: Int, pageSize: Int, searchText: String? = nil, parameters: GetActivityLogsParameters = .init()) async throws -> ([Activity], hasMore: Bool) { + func getActivities(siteID: Int, offset: Int, pageSize: Int, searchText: String? = nil, parameters: GetActivityLogsParameters = .init(), rewindable: Bool? = nil) async throws -> ([Activity], hasMore: Bool) { return try await withCheckedThrowingContinuation { continuation in getActivityForSite( siteID, @@ -151,6 +156,7 @@ private extension ActivityServiceRemote { after: parameters.startDate, before: parameters.endDate, group: Array(parameters.activityTypes), + rewindable: rewindable, searchText: searchText ) { activities, hasMore in continuation.resume(returning: (activities, hasMore)) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 321c90ee284d..88687d8a0329 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -182,10 +182,21 @@ extension BlogDetailsViewController { } @objc public func showBackup() { - guard let backupListVC = BackupListViewController.withJPBannerForBlog(blog) else { - return wpAssertionFailure("failed to instantiate") + let controller: UIViewController + + if FeatureFlag.dataViews.enabled { + controller = ActivityLogsViewController(blog: blog, isBackupMode: true) + controller.navigationItem.largeTitleDisplayMode = .never + } else { + guard let backupListVC = BackupListViewController.withJPBannerForBlog(blog) else { + return wpAssertionFailure("failed to instantiate") + } + controller = backupListVC } - presentationDelegate?.presentBlogDetailsViewController(backupListVC) + + presentationDelegate?.presentBlogDetailsViewController(controller) + + WPAnalytics.track(.backupListOpened) } @objc public func showThemes() { From 31f862dc4af00f52032b6c713042962c47e51bcd Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 17:32:33 -0400 Subject: [PATCH 41/90] Move restore buttons higher --- .../Activity/Details/ActivityLogDetailsView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 80bf3d6e1d88..c6c7d28f1295 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -14,12 +14,12 @@ struct ActivityLogDetailsView: View { ScrollView { VStack(spacing: 24) { ActivityHeaderView(activity: activity) - if let actor = activity.actor { - makeActorCard(for: actor) - } if activity.isRewindable { restoreSiteCard } + if let actor = activity.actor { + makeActorCard(for: actor) + } } .padding() } From d764d47a727ebca1271ef0ff0fbca372e29ae487 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 17:41:38 -0400 Subject: [PATCH 42/90] Add date filtering back --- .../ViewRelated/Activity/List/ActivityLogsMenu.swift | 4 +++- .../ViewRelated/Activity/List/ActivityLogsView.swift | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift index b059c5816844..e1ea7a8ec0e5 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift @@ -13,7 +13,9 @@ struct ActivityLogsMenu: View { Menu { Section { dateFilters - activityTypeFilter + if !viewModel.isBackupMode { + activityTypeFilter + } if !viewModel.parameters.isEmpty { resetFiltersButton } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index d40a4305f949..517c68e7429f 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -65,10 +65,8 @@ private struct ActivityLogsListView: View { await viewModel.refresh() } .toolbar { - if !viewModel.isBackupMode { - ToolbarItem(placement: .navigationBarTrailing) { - ActivityLogsMenu(viewModel: viewModel) - } + ToolbarItem(placement: .navigationBarTrailing) { + ActivityLogsMenu(viewModel: viewModel) } } } From 54d3c39e9bae36fa7fb35dd5d8ba182921a6f60e Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 21:41:09 -0400 Subject: [PATCH 43/90] Add Backup tracking --- .../Activity/List/ActivityLogsView.swift | 39 +++++ .../Activity/List/ActivityLogsViewModel.swift | 11 ++ .../List/BackupDownloadHeaderView.swift | 76 +++++++++ .../Activity/List/BackupDownloadTracker.swift | 145 ++++++++++++++++++ .../Activity/List/BackupInProgressView.swift | 47 ++++++ 5 files changed, 318 insertions(+) create mode 100644 WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index 517c68e7429f..cdfd81684291 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -1,6 +1,7 @@ import SwiftUI import WordPressUI import WordPressKit +import WordPressShared struct ActivityLogsView: View { @ObservedObject var viewModel: ActivityLogsViewModel @@ -28,6 +29,10 @@ private struct ActivityLogsListView: View { var body: some View { List { + if let backupTracker = viewModel.backupTracker { + BackupDownloadSection(backupTracker: backupTracker) + } + if let response = viewModel.response { ActivityLogsPaginatedForEach(response: response, blog: viewModel.blog) @@ -61,6 +66,9 @@ private struct ActivityLogsListView: View { .onAppear { viewModel.onAppear() } + .onDisappear { + viewModel.onDisappear() + } .refreshable { await viewModel.refresh() } @@ -134,6 +142,37 @@ private struct ActivityLogsPaginatedForEach: View { } } +private struct BackupDownloadSection: View { + @ObservedObject var backupTracker: BackupDownloadTracker + + var body: some View { + if let backupStatus = backupTracker.backupStatus { + Group { + if backupTracker.isBackupInProgress, + let progress = backupStatus.progress { + BackupInProgressView(progress: progress) + } else if let url = backupTracker.downloadURL() { + BackupDownloadHeaderView( + backupStatus: backupStatus, + onDownload: { + WPAnalytics.track(.backupFileDownloadTapped) + UIApplication.shared.open(url) + }, + onDismiss: { + Task { + await backupTracker.dismissBackupNotice() + } + } + ) + } + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } +} + private enum Strings { static let empty = NSLocalizedString("activityLogs.empty", value: "No Activity", comment: "Empty state message for activity logs") static let freePlanNotice = NSLocalizedString("activityLogs.freePlan.notice", value: "Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Notice shown to free plan users about limited activity log events") diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift index 593b9f4ee742..327a9918fe1d 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -9,6 +9,7 @@ typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse [WordPressKit.ActivityGroup] { guard let siteID = blog.dotComID?.intValue, let api = blog.wordPressComRestApi else { diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift new file mode 100644 index 000000000000..11db4072dc97 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import WordPressUI + +struct BackupDownloadHeaderView: View { + let backupStatus: JetpackBackup + let onDownload: () -> Void + let onDismiss: () -> Void + + private var formattedDate: String { + let backupPoint = backupStatus.backupPoint + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy 'at' h:mm a") + return formatter.string(from: backupPoint) + } + + var body: some View { + CardView { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.successTitle) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.primary) + + Text(String(format: Strings.message, formattedDate)) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + + HStack(spacing: 12) { + Button(action: onDownload) { + Label(Strings.download, systemImage: "arrow.down.circle") + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + + Spacer() + } + } + } + .padding(.horizontal) + .padding(.top, 8) + } +} + +private enum Strings { + static let successTitle = NSLocalizedString( + "backup.download.header.title", + value: "Backup Ready", + comment: "Title shown when a backup is ready to download" + ) + + static let message = NSLocalizedString( + "backup.download.header.message", + value: "We successfully created a backup of your site as of %@", + comment: "Message displayed when a backup has finished. %@ is the date and time." + ) + + static let download = NSLocalizedString( + "backup.download.header.download", + value: "Download", + comment: "Download button title" + ) +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift new file mode 100644 index 000000000000..899dddd5444a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift @@ -0,0 +1,145 @@ +import Foundation +import WordPressKit +import WordPressShared + +/// Tracks backup download status for a WordPress site. +/// Automatically polls for updates while a backup is in progress or until a download becomes available. +@MainActor +final class BackupDownloadTracker: ObservableObject { + @Published var backupStatus: JetpackBackup? + @Published var isLoading = false + @Published var error: Error? + + private let blog: Blog + private var refreshTask: Task? + + /// Returns the download URL if a valid backup is available. + /// - Returns: URL for downloading the backup, or nil if unavailable. + func downloadURL() -> URL? { + guard let backupStatus, + let validUntil = backupStatus.validUntil, + Date() < validUntil, + backupStatus.backupPoint != nil, + let urlString = backupStatus.url, + backupStatus.downloadID != nil else { + return nil + } + return URL(string: urlString) + } + + /// Indicates whether a backup is currently being created. + var isBackupInProgress: Bool { + guard let backupStatus, + let progress = backupStatus.progress, + progress > 0 && progress < 100 else { + return false + } + return true + } + + init(blog: Blog) { + self.blog = blog + } + + /// Starts tracking backup status. Refreshes immediately and polls as needed. + func startTracking() { + refreshBackupStatus() + } + + /// Stops tracking and cancels any pending refresh operations. + func stopTracking() { + refreshTask?.cancel() + refreshTask = nil + } + + /// Refreshes backup status and starts polling if a backup is in progress or no download is available. + func refreshBackupStatus() { + guard let siteRef = JetpackSiteRef(blog: blog), + siteRef.hasBackup else { + return + } + + refreshTask?.cancel() + refreshTask = Task { + // Fetch status immediately + await fetchBackupStatus(siteRef: siteRef) + + // Continue polling if needed (backup in progress or no download available) + while !Task.isCancelled && (isBackupInProgress || downloadURL() == nil) { + try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds + + guard !Task.isCancelled else { break } + + await fetchBackupStatus(siteRef: siteRef) + + // Stop polling if download is now available and no backup in progress + if downloadURL() != nil && !isBackupInProgress { + break + } + } + } + } + + private func fetchBackupStatus(siteRef: JetpackSiteRef) async { + isLoading = true + error = nil + + do { + let backupService = JetpackBackupService(coreDataStack: ContextManager.shared) + let statuses = try await backupService.getAllBackupStatus(for: siteRef) + + guard !Task.isCancelled else { return } + + // Get the first valid backup status + self.backupStatus = statuses.first { status in + if let validUntil = status.validUntil { + return Date() < validUntil + } + return false + } + self.isLoading = false + } catch { + guard !Task.isCancelled else { return } + self.isLoading = false + self.error = error + } + } + + /// Dismisses the current backup notice, clearing it from the UI and notifying the server. + func dismissBackupNotice() async { + guard let siteRef = JetpackSiteRef(blog: blog), + let downloadID = backupStatus?.downloadID else { + return + } + + // Clear local state immediately for better UX + backupStatus = nil + + // Dismiss on the server (fire and forget) + Task { + let backupService = JetpackBackupService(coreDataStack: ContextManager.shared) + await backupService.dismissBackupNotice(site: siteRef, downloadID: downloadID) + } + } +} + +// MARK: - JetpackBackupService Async Extensions + +private extension JetpackBackupService { + func getAllBackupStatus(for siteRef: JetpackSiteRef) async throws -> [JetpackBackup] { + try await withCheckedThrowingContinuation { continuation in + getAllBackupStatus(for: siteRef) { statuses in + continuation.resume(returning: statuses) + } failure: { error in + continuation.resume(throwing: error) + } + } + } + + func dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) async { + await withCheckedContinuation { continuation in + dismissBackupNotice(site: site, downloadID: downloadID) + continuation.resume() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift new file mode 100644 index 000000000000..2907114dab17 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import WordPressUI + +struct BackupInProgressView: View { + let progress: Int + + private var progressFloat: Float { + max(Float(progress) / 100, 0.05) // Show at least 5% for UX + } + + var body: some View { + CardView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.primary) + + Text(Strings.message) + .font(.footnote) + .foregroundStyle(.secondary) + } + + ProgressView(value: progressFloat) + .progressViewStyle(.linear) + .tint(.accentColor) + } + } + .padding(.horizontal) + .padding(.top, 8) + } +} + +private enum Strings { + static let title = NSLocalizedString( + "backup.inProgress.title", + value: "Backing up site", + comment: "Title shown when a backup is in progress" + ) + + static let message = NSLocalizedString( + "backup.inProgress.message", + value: "Creating downloadable backup", + comment: "Message shown when a backup is in progress" + ) +} From 7df96bd0d3ddee65a6066ed795706e76726b568e Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 22:07:56 -0400 Subject: [PATCH 44/90] Fix clear background in restore flows --- .../RegisterDomainDetailsViewModelTests.swift | 2 +- WordPress/Classes/Models/JetpackSiteRef.swift | 8 ++-- WordPress/Classes/Stores/ActivityStore.swift | 2 +- .../Activity/List/ActivityLogsView.swift | 6 +-- .../List/BackupDownloadHeaderView.swift | 2 +- .../Activity/List/BackupDownloadTracker.swift | 46 ++++++++----------- .../BaseRestoreCompleteViewController.swift | 1 + .../BaseRestoreStatusViewController.swift | 1 + .../JetpackRestoreWarningViewController.swift | 2 +- 9 files changed, 33 insertions(+), 37 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift index 02a8265161e1..8ae44f3d2414 100644 --- a/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift @@ -24,7 +24,7 @@ extension JetpackSiteRef { "siteID": \(siteID), "username": "\(username)", "homeURL": "url", - "hasBackup": true, + "isBackupFeatureAvailable": true, "hasPaidPlan": true, "isSelfHostedWithoutJetpack": false, "xmlRPC": null, diff --git a/WordPress/Classes/Models/JetpackSiteRef.swift b/WordPress/Classes/Models/JetpackSiteRef.swift index edb48768cd53..f42b112ac001 100644 --- a/WordPress/Classes/Models/JetpackSiteRef.swift +++ b/WordPress/Classes/Models/JetpackSiteRef.swift @@ -16,7 +16,7 @@ struct JetpackSiteRef: Hashable, Codable { /// The homeURL string for a site. let homeURL: String - private(set) var hasBackup = false + private(set) var isBackupFeatureAvailable = false private var hasPaidPlan = false // Self Hosted Non Jetpack Support @@ -61,7 +61,7 @@ struct JetpackSiteRef: Hashable, Codable { self.siteID = siteID self.username = username self.homeURL = homeURL - self.hasBackup = blog.isBackupsAllowed() + self.isBackupFeatureAvailable = blog.isBackupsAllowed() self.hasPaidPlan = blog.hasPaidPlan } } @@ -74,12 +74,12 @@ struct JetpackSiteRef: Hashable, Codable { return lhs.siteID == rhs.siteID && lhs.username == rhs.username && lhs.homeURL == rhs.homeURL - && lhs.hasBackup == rhs.hasBackup + && lhs.isBackupFeatureAvailable == rhs.isBackupFeatureAvailable && lhs.hasPaidPlan == rhs.hasPaidPlan } func shouldShowActivityLogFilter() -> Bool { - hasBackup || hasPaidPlan + isBackupFeatureAvailable || hasPaidPlan } struct Constants { diff --git a/WordPress/Classes/Stores/ActivityStore.swift b/WordPress/Classes/Stores/ActivityStore.swift index 58fc86e6bb32..e08f6780de73 100644 --- a/WordPress/Classes/Stores/ActivityStore.swift +++ b/WordPress/Classes/Stores/ActivityStore.swift @@ -299,7 +299,7 @@ extension ActivityStore { } func fetchBackupStatus(site: JetpackSiteRef) { - guard site.hasBackup else { + guard site.isBackupFeatureAvailable else { return } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index cdfd81684291..7323c07885b7 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -151,7 +151,7 @@ private struct BackupDownloadSection: View { if backupTracker.isBackupInProgress, let progress = backupStatus.progress { BackupInProgressView(progress: progress) - } else if let url = backupTracker.downloadURL() { + } else if let url = backupTracker.downloadURL { BackupDownloadHeaderView( backupStatus: backupStatus, onDownload: { @@ -159,8 +159,8 @@ private struct BackupDownloadSection: View { UIApplication.shared.open(url) }, onDismiss: { - Task { - await backupTracker.dismissBackupNotice() + withAnimation { + backupTracker.dismissBackupNotice() } } ) diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift index 11db4072dc97..d0abe6eb37c2 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift @@ -41,7 +41,7 @@ struct BackupDownloadHeaderView: View { HStack(spacing: 12) { Button(action: onDownload) { - Label(Strings.download, systemImage: "arrow.down.circle") + Text(Strings.download) .fontWeight(.medium) } .buttonStyle(.borderedProminent) diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift index 899dddd5444a..d6cfcaa52ed4 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift @@ -7,24 +7,19 @@ import WordPressShared @MainActor final class BackupDownloadTracker: ObservableObject { @Published var backupStatus: JetpackBackup? - @Published var isLoading = false - @Published var error: Error? private let blog: Blog private var refreshTask: Task? /// Returns the download URL if a valid backup is available. - /// - Returns: URL for downloading the backup, or nil if unavailable. - func downloadURL() -> URL? { + var downloadURL: URL? { guard let backupStatus, let validUntil = backupStatus.validUntil, Date() < validUntil, - backupStatus.backupPoint != nil, - let urlString = backupStatus.url, - backupStatus.downloadID != nil else { + let url = backupStatus.url.flatMap(URL.init) else { return nil } - return URL(string: urlString) + return url } /// Indicates whether a backup is currently being created. @@ -37,6 +32,11 @@ final class BackupDownloadTracker: ObservableObject { return true } + /// Convenience property to check if a download is available. + var isDownloadAvailable: Bool { + downloadURL != nil + } + init(blog: Blog) { self.blog = blog } @@ -54,8 +54,7 @@ final class BackupDownloadTracker: ObservableObject { /// Refreshes backup status and starts polling if a backup is in progress or no download is available. func refreshBackupStatus() { - guard let siteRef = JetpackSiteRef(blog: blog), - siteRef.hasBackup else { + guard let siteRef = JetpackSiteRef(blog: blog), siteRef.isBackupFeatureAvailable else { return } @@ -63,17 +62,17 @@ final class BackupDownloadTracker: ObservableObject { refreshTask = Task { // Fetch status immediately await fetchBackupStatus(siteRef: siteRef) - - // Continue polling if needed (backup in progress or no download available) - while !Task.isCancelled && (isBackupInProgress || downloadURL() == nil) { + + // Continue polling if needed (backup in progress) + while !Task.isCancelled && isBackupInProgress { try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds - + guard !Task.isCancelled else { break } - + await fetchBackupStatus(siteRef: siteRef) - + // Stop polling if download is now available and no backup in progress - if downloadURL() != nil && !isBackupInProgress { + if isDownloadAvailable && !isBackupInProgress { break } } @@ -81,13 +80,10 @@ final class BackupDownloadTracker: ObservableObject { } private func fetchBackupStatus(siteRef: JetpackSiteRef) async { - isLoading = true - error = nil - do { let backupService = JetpackBackupService(coreDataStack: ContextManager.shared) let statuses = try await backupService.getAllBackupStatus(for: siteRef) - + guard !Task.isCancelled else { return } // Get the first valid backup status @@ -97,16 +93,14 @@ final class BackupDownloadTracker: ObservableObject { } return false } - self.isLoading = false } catch { guard !Task.isCancelled else { return } - self.isLoading = false - self.error = error + // Silently fail - backup status remains unchanged } } /// Dismisses the current backup notice, clearing it from the UI and notifying the server. - func dismissBackupNotice() async { + func dismissBackupNotice() { guard let siteRef = JetpackSiteRef(blog: blog), let downloadID = backupStatus?.downloadID else { return @@ -135,7 +129,7 @@ private extension JetpackBackupService { } } } - + func dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) async { await withCheckedContinuation { continuation in dismissBackupNotice(site: site, downloadID: downloadID) diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift index cab7f11339c8..4fb409ebff1a 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift @@ -103,6 +103,7 @@ class BaseRestoreCompleteViewController: UIViewController { self?.secondaryButtonTapped(from: sender) } + view.backgroundColor = .systemBackground view.addSubview(completeView) completeView.pinEdges(to: view.safeAreaLayoutGuide) } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift index f03e97184f04..84c64db5cc48 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift @@ -92,6 +92,7 @@ class BaseRestoreStatusViewController: UIViewController { statusView.update(progress: 0, progressTitle: configuration.placeholderProgressTitle, progressDescription: nil) + view.backgroundColor = .systemBackground view.addSubview(statusView) statusView.pinEdges(to: view.safeAreaLayoutGuide) } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift index e08e32727e18..0f086cd2b62f 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift @@ -79,7 +79,7 @@ class JetpackRestoreWarningViewController: UIViewController { self?.dismiss(animated: true) } - warningView.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemBackground view.addSubview(warningView) warningView.pinEdges(to: view.safeAreaLayoutGuide) } From 6b72d765119d49a2050cc5fd3b7b03d8843fc345 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 08:54:52 -0400 Subject: [PATCH 45/90] Rework how we do polling --- .../Activity/List/ActivityLogsView.swift | 33 +--- .../Activity/List/ActivityLogsViewModel.swift | 4 +- .../List/BackupDownloadHeaderView.swift | 76 --------- .../Activity/List/BackupInProgressView.swift | 47 ------ .../List/DownloadableBackupSection.swift | 159 ++++++++++++++++++ ....swift => DownloadableBackupTracker.swift} | 39 +++-- 6 files changed, 187 insertions(+), 171 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/List/BackupDownloadHeaderView.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift rename WordPress/Classes/ViewRelated/Activity/List/{BackupDownloadTracker.swift => DownloadableBackupTracker.swift} (77%) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index 7323c07885b7..47752788a173 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -30,7 +30,7 @@ private struct ActivityLogsListView: View { var body: some View { List { if let backupTracker = viewModel.backupTracker { - BackupDownloadSection(backupTracker: backupTracker) + DownloadableBackupSection(backupTracker: backupTracker) } if let response = viewModel.response { @@ -142,37 +142,6 @@ private struct ActivityLogsPaginatedForEach: View { } } -private struct BackupDownloadSection: View { - @ObservedObject var backupTracker: BackupDownloadTracker - - var body: some View { - if let backupStatus = backupTracker.backupStatus { - Group { - if backupTracker.isBackupInProgress, - let progress = backupStatus.progress { - BackupInProgressView(progress: progress) - } else if let url = backupTracker.downloadURL { - BackupDownloadHeaderView( - backupStatus: backupStatus, - onDownload: { - WPAnalytics.track(.backupFileDownloadTapped) - UIApplication.shared.open(url) - }, - onDismiss: { - withAnimation { - backupTracker.dismissBackupNotice() - } - } - ) - } - } - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - } -} - private enum Strings { static let empty = NSLocalizedString("activityLogs.empty", value: "No Activity", comment: "Empty state message for activity logs") static let freePlanNotice = NSLocalizedString("activityLogs.freePlan.notice", value: "Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Notice shown to free plan users about limited activity log events") diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift index 327a9918fe1d..4a32bad4a7a5 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -9,7 +9,7 @@ typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse Void - let onDismiss: () -> Void - - private var formattedDate: String { - let backupPoint = backupStatus.backupPoint - let formatter = DateFormatter() - formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy 'at' h:mm a") - return formatter.string(from: backupPoint) - } - - var body: some View { - CardView { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.successTitle) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(.primary) - - Text(String(format: Strings.message, formattedDate)) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.caption) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - - HStack(spacing: 12) { - Button(action: onDownload) { - Text(Strings.download) - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - - Spacer() - } - } - } - .padding(.horizontal) - .padding(.top, 8) - } -} - -private enum Strings { - static let successTitle = NSLocalizedString( - "backup.download.header.title", - value: "Backup Ready", - comment: "Title shown when a backup is ready to download" - ) - - static let message = NSLocalizedString( - "backup.download.header.message", - value: "We successfully created a backup of your site as of %@", - comment: "Message displayed when a backup has finished. %@ is the date and time." - ) - - static let download = NSLocalizedString( - "backup.download.header.download", - value: "Download", - comment: "Download button title" - ) -} diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift deleted file mode 100644 index 2907114dab17..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/List/BackupInProgressView.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI -import WordPressUI - -struct BackupInProgressView: View { - let progress: Int - - private var progressFloat: Float { - max(Float(progress) / 100, 0.05) // Show at least 5% for UX - } - - var body: some View { - CardView { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.title) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(.primary) - - Text(Strings.message) - .font(.footnote) - .foregroundStyle(.secondary) - } - - ProgressView(value: progressFloat) - .progressViewStyle(.linear) - .tint(.accentColor) - } - } - .padding(.horizontal) - .padding(.top, 8) - } -} - -private enum Strings { - static let title = NSLocalizedString( - "backup.inProgress.title", - value: "Backing up site", - comment: "Title shown when a backup is in progress" - ) - - static let message = NSLocalizedString( - "backup.inProgress.message", - value: "Creating downloadable backup", - comment: "Message shown when a backup is in progress" - ) -} diff --git a/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift new file mode 100644 index 000000000000..23b1f3389446 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift @@ -0,0 +1,159 @@ +import SwiftUI +import WordPressUI +import WordPressKit + +struct DownloadableBackupSection: View { + @ObservedObject var backupTracker: DownloadableBackupTracker + + var body: some View { + if let backupStatus = backupTracker.backupStatus { + Group { + if backupTracker.isBackupInProgress, + let progress = backupStatus.progress { + BackupInProgressView(progress: progress) + } else if let url = backupTracker.downloadURL { + BackupDownloadHeaderView( + backupStatus: backupStatus, + onDownload: { + WPAnalytics.track(.backupFileDownloadTapped) + UIApplication.shared.open(url) + }, + onDismiss: { + withAnimation { + backupTracker.dismissBackupNotice() + } + } + ) + } + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } +} + +// MARK: - Private Views + +private struct BackupInProgressView: View { + let progress: Int + + private var progressFloat: Float { + max(Float(progress) / 100, 0.05) // Show at least 5% for UX + } + + var body: some View { + CardView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.InProgress.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.primary) + + Text(Strings.InProgress.message) + .font(.footnote) + .foregroundStyle(.secondary) + } + + ProgressView(value: progressFloat) + .progressViewStyle(.linear) + .tint(.accentColor) + } + } + .padding(.horizontal) + .padding(.top, 8) + } +} + +private struct BackupDownloadHeaderView: View { + let backupStatus: JetpackBackup + let onDownload: () -> Void + let onDismiss: () -> Void + + private var formattedDate: String { + let backupPoint = backupStatus.backupPoint + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy 'at' h:mm a") + return formatter.string(from: backupPoint) + } + + var body: some View { + CardView { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.Download.successTitle) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.primary) + + Text(String(format: Strings.Download.message, formattedDate)) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + + HStack(spacing: 12) { + Button(action: onDownload) { + Text(Strings.Download.download) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + + Spacer() + } + } + } + .padding(.horizontal) + .padding(.top, 8) + } +} + +// MARK: - Strings + +private enum Strings { + enum InProgress { + static let title = NSLocalizedString( + "backup.inProgress.title", + value: "Backing up site", + comment: "Title shown when a backup is in progress" + ) + + static let message = NSLocalizedString( + "backup.inProgress.message", + value: "Creating downloadable backup", + comment: "Message shown when a backup is in progress" + ) + } + + enum Download { + static let successTitle = NSLocalizedString( + "backup.download.header.title", + value: "Backup Ready", + comment: "Title shown when a backup is ready to download" + ) + + static let message = NSLocalizedString( + "backup.download.header.message", + value: "We successfully created a backup of your site as of %@", + comment: "Message displayed when a backup has finished. %@ is the date and time." + ) + + static let download = NSLocalizedString( + "backup.download.header.download", + value: "Download", + comment: "Download button title" + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift similarity index 77% rename from WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift rename to WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift index d6cfcaa52ed4..8f502542f3bb 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/BackupDownloadTracker.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift @@ -5,7 +5,7 @@ import WordPressShared /// Tracks backup download status for a WordPress site. /// Automatically polls for updates while a backup is in progress or until a download becomes available. @MainActor -final class BackupDownloadTracker: ObservableObject { +final class DownloadableBackupTracker: ObservableObject { @Published var backupStatus: JetpackBackup? private let blog: Blog @@ -52,7 +52,7 @@ final class BackupDownloadTracker: ObservableObject { refreshTask = nil } - /// Refreshes backup status and starts polling if a backup is in progress or no download is available. + /// Refreshes backup status and starts continuous polling with adaptive delays. func refreshBackupStatus() { guard let siteRef = JetpackSiteRef(blog: blog), siteRef.isBackupFeatureAvailable else { return @@ -60,20 +60,34 @@ final class BackupDownloadTracker: ObservableObject { refreshTask?.cancel() refreshTask = Task { + var pollCount = 0 + // Fetch status immediately await fetchBackupStatus(siteRef: siteRef) - // Continue polling if needed (backup in progress) - while !Task.isCancelled && isBackupInProgress { - try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds + // Continue polling while on the screen + while !Task.isCancelled { + let delay: UInt64 + + if isBackupInProgress { + // Poll frequently (every 5 seconds) when backup is in progress + delay = 5_000_000_000 + } else { + // Progressive delay: 10s * (attemptCount + 1), max 60s + let seconds = min(10 * (pollCount + 1), 60) + delay = UInt64(seconds) * 1_000_000_000 + pollCount += 1 + } + + try? await Task.sleep(nanoseconds: delay) guard !Task.isCancelled else { break } await fetchBackupStatus(siteRef: siteRef) - // Stop polling if download is now available and no backup in progress - if isDownloadAvailable && !isBackupInProgress { - break + // Reset poll count if backup starts + if isBackupInProgress { + pollCount = 0 } } } @@ -86,12 +100,9 @@ final class BackupDownloadTracker: ObservableObject { guard !Task.isCancelled else { return } - // Get the first valid backup status - self.backupStatus = statuses.first { status in - if let validUntil = status.validUntil { - return Date() < validUntil - } - return false + // Get the most recently started backup + self.backupStatus = statuses.max { lhs, rhs in + (lhs.startedAt ?? .distantPast) < (rhs.startedAt ?? .distantPast) } } catch { guard !Task.isCancelled else { return } From 76c354a9214802ba096e38b6bcb33f176dae1cf6 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 09:10:49 -0400 Subject: [PATCH 46/90] Rework how we manage backup statuss --- .../List/DownloadableBackupSection.swift | 215 +++++++++++------- .../List/DownloadableBackupTracker.swift | 68 +++--- 2 files changed, 164 insertions(+), 119 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift index 23b1f3389446..57b98a73a532 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift @@ -2,30 +2,57 @@ import SwiftUI import WordPressUI import WordPressKit +/// Represents the current status of a downloadable backup. +enum DownloadableBackupStatus { + /// Backup creation is in progress or processing + case inProgress(backup: JetpackBackup, progress: Int) + + /// Backup is ready for download + case readyToDownload(backup: JetpackBackup, url: URL, validUntil: Date) + + init?(backup: JetpackBackup?) { + guard let backup else { + return nil + } + + // Determine the status based on the backup properties + if let urlString = backup.url, + let url = URL(string: urlString), + let validUntil = backup.validUntil, + Date() < validUntil { + // Download is ready and valid + self = .readyToDownload(backup: backup, url: url, validUntil: validUntil) + } else if let progress = backup.progress, progress > 0 { + // Backup is being created or processing + self = .inProgress(backup: backup, progress: progress) + } else { + // Backup exists but in an unknown state + return nil + } + } +} + struct DownloadableBackupSection: View { @ObservedObject var backupTracker: DownloadableBackupTracker var body: some View { - if let backupStatus = backupTracker.backupStatus { - Group { - if backupTracker.isBackupInProgress, - let progress = backupStatus.progress { - BackupInProgressView(progress: progress) - } else if let url = backupTracker.downloadURL { + if let status = DownloadableBackupStatus(backup: backupTracker.backup) { + CardView { + switch status { + case .inProgress(let backup, let progress): + BackupInProgressView(backup: backup, progress: progress) + + case .readyToDownload(let backup, let url, let validUntil): BackupDownloadHeaderView( - backupStatus: backupStatus, - onDownload: { - WPAnalytics.track(.backupFileDownloadTapped) - UIApplication.shared.open(url) - }, - onDismiss: { - withAnimation { - backupTracker.dismissBackupNotice() - } - } + backup: backup, + url: url, + validUntil: validUntil, + backupTracker: backupTracker ) } } + .padding(.horizontal) + .padding(.top, 8) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) .listRowBackground(Color.clear) @@ -36,6 +63,7 @@ struct DownloadableBackupSection: View { // MARK: - Private Views private struct BackupInProgressView: View { + let backup: JetpackBackup let progress: Int private var progressFloat: Float { @@ -43,80 +71,104 @@ private struct BackupInProgressView: View { } var body: some View { - CardView { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.InProgress.title) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(.primary) - - Text(Strings.InProgress.message) - .font(.footnote) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.InProgress.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.primary) + + Text(Strings.InProgress.message) + .font(.footnote) + .foregroundStyle(.secondary) + } + HStack { ProgressView(value: progressFloat) .progressViewStyle(.linear) .tint(.accentColor) + + Text("\(progress)%") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() } } - .padding(.horizontal) - .padding(.top, 8) } } private struct BackupDownloadHeaderView: View { - let backupStatus: JetpackBackup - let onDownload: () -> Void - let onDismiss: () -> Void - - private var formattedDate: String { - let backupPoint = backupStatus.backupPoint - let formatter = DateFormatter() - formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy 'at' h:mm a") - return formatter.string(from: backupPoint) + let backup: JetpackBackup + let url: URL + let validUntil: Date + let backupTracker: DownloadableBackupTracker + + private var formattedBackupDate: String { + backup.backupPoint.formatted(date: .abbreviated, time: .shortened) + } + + private var formattedExpiryDate: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: validUntil, relativeTo: Date()) } var body: some View { - CardView { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.Download.successTitle) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(.primary) - - Text(String(format: Strings.Download.message, formattedDate)) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.caption) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } + VStack(alignment: .leading, spacing: 12) { + headerView + downloadButton + } + } + + private var headerView: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.Download.successTitle) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.primary) + + Text(String(format: Strings.Download.message, formattedBackupDate)) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(format: Strings.Download.expiresIn, formattedExpiryDate)) + .font(.caption) + .foregroundStyle(.tertiary) + } - HStack(spacing: 12) { - Button(action: onDownload) { - Text(Strings.Download.download) - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) + Spacer() - Spacer() + Button(action: { + withAnimation { + backupTracker.dismissBackupNotice() } + }) { + Image(systemName: "xmark") + .font(.caption) + .foregroundColor(.secondary) } + .buttonStyle(.plain) + } + } + + private var downloadButton: some View { + HStack(spacing: 12) { + Button(action: { + WPAnalytics.track(.backupFileDownloadTapped) + UIApplication.shared.open(url) + }) { + HStack(spacing: 4) { + Image(systemName: "arrow.down.circle") + Text(Strings.Download.download) + } + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + + Spacer() } - .padding(.horizontal) - .padding(.top, 8) } } @@ -126,34 +178,41 @@ private enum Strings { enum InProgress { static let title = NSLocalizedString( "backup.inProgress.title", - value: "Backing up site", - comment: "Title shown when a backup is in progress" + value: "Creating downloadable backup", + comment: "Title shown when a downloadable backup is being created" ) static let message = NSLocalizedString( "backup.inProgress.message", - value: "Creating downloadable backup", - comment: "Message shown when a backup is in progress" + value: "Preparing your site backup for download", + comment: "Message shown when a downloadable backup is in progress" ) } enum Download { static let successTitle = NSLocalizedString( "backup.download.header.title", - value: "Backup Ready", + value: "Backup ready to download", comment: "Title shown when a backup is ready to download" ) static let message = NSLocalizedString( "backup.download.header.message", - value: "We successfully created a backup of your site as of %@", + value: "Your backup from %@ is ready", comment: "Message displayed when a backup has finished. %@ is the date and time." ) + static let expiresIn = NSLocalizedString( + "backup.download.header.expiresIn", + value: "Expires %@", + comment: "Shows when the download link will expire. %@ is the relative time (e.g., 'in 2 hours')" + ) + static let download = NSLocalizedString( "backup.download.header.download", value: "Download", comment: "Download button title" ) } + } diff --git a/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift index 8f502542f3bb..f3b19f86c494 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift @@ -1,53 +1,30 @@ import Foundation import WordPressKit import WordPressShared +import CocoaLumberjack /// Tracks backup download status for a WordPress site. /// Automatically polls for updates while a backup is in progress or until a download becomes available. @MainActor final class DownloadableBackupTracker: ObservableObject { - @Published var backupStatus: JetpackBackup? + @Published var backup: JetpackBackup? private let blog: Blog private var refreshTask: Task? - /// Returns the download URL if a valid backup is available. - var downloadURL: URL? { - guard let backupStatus, - let validUntil = backupStatus.validUntil, - Date() < validUntil, - let url = backupStatus.url.flatMap(URL.init) else { - return nil - } - return url - } - - /// Indicates whether a backup is currently being created. - var isBackupInProgress: Bool { - guard let backupStatus, - let progress = backupStatus.progress, - progress > 0 && progress < 100 else { - return false - } - return true - } - - /// Convenience property to check if a download is available. - var isDownloadAvailable: Bool { - downloadURL != nil - } - init(blog: Blog) { self.blog = blog } /// Starts tracking backup status. Refreshes immediately and polls as needed. func startTracking() { + DDLogInfo("[DownloadableBackup] Starting backup tracking for site") refreshBackupStatus() } /// Stops tracking and cancels any pending refresh operations. func stopTracking() { + DDLogInfo("[DownloadableBackup] Stopping backup tracking") refreshTask?.cancel() refreshTask = nil } @@ -69,9 +46,14 @@ final class DownloadableBackupTracker: ObservableObject { while !Task.isCancelled { let delay: UInt64 - if isBackupInProgress { - // Poll frequently (every 5 seconds) when backup is in progress - delay = 5_000_000_000 + // Check if backup is in progress or processing + let isActive = backup?.progress.map { $0 > 0 && $0 < 100 } ?? false || + (backup?.progress == 100 && backup?.url == nil) + + if isActive { + // Poll frequently (every 5 seconds) when backup is active + delay = 2_000_000_000 + pollCount = 0 } else { // Progressive delay: 10s * (attemptCount + 1), max 60s let seconds = min(10 * (pollCount + 1), 60) @@ -84,11 +66,6 @@ final class DownloadableBackupTracker: ObservableObject { guard !Task.isCancelled else { break } await fetchBackupStatus(siteRef: siteRef) - - // Reset poll count if backup starts - if isBackupInProgress { - pollCount = 0 - } } } } @@ -101,24 +78,33 @@ final class DownloadableBackupTracker: ObservableObject { guard !Task.isCancelled else { return } // Get the most recently started backup - self.backupStatus = statuses.max { lhs, rhs in - (lhs.startedAt ?? .distantPast) < (rhs.startedAt ?? .distantPast) + self.backup = statuses.max { lhs, rhs in + lhs.startedAt < rhs.startedAt + } + + if let backup { + let statusInfo = "progress: \(backup.progress ?? 0)%, downloadID: \(backup.downloadID), url: \(String(describing: backup.url))" + DDLogInfo("[DownloadableBackup] Status updated: \(statusInfo)") + } else { + DDLogInfo("[DownloadableBackup] No active downloadable backups found") } } catch { guard !Task.isCancelled else { return } - // Silently fail - backup status remains unchanged + DDLogError("[DownloadableBackup] Failed to fetch backup status: \(error)") } } /// Dismisses the current backup notice, clearing it from the UI and notifying the server. func dismissBackupNotice() { - guard let siteRef = JetpackSiteRef(blog: blog), - let downloadID = backupStatus?.downloadID else { + guard let siteRef = JetpackSiteRef(blog: blog), let backup else { return } + let downloadID = backup.downloadID + // Clear local state immediately for better UX - backupStatus = nil + self.backup = nil + DDLogInfo("[DownloadableBackup] Dismissing backup notice for download ID: \(downloadID)") // Dismiss on the server (fire and forget) Task { From 64d1585665ccd0ddecca34f0083d4d982d277abb Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 09:33:27 -0400 Subject: [PATCH 47/90] Revert hasBackup change --- .../Domains/RegisterDomainDetailsViewModelTests.swift | 2 +- WordPress/Classes/Models/JetpackSiteRef.swift | 8 ++++---- WordPress/Classes/Stores/ActivityStore.swift | 2 +- .../Activity/List/DownloadableBackupTracker.swift | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift index 8ae44f3d2414..02a8265161e1 100644 --- a/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Domains/RegisterDomainDetailsViewModelTests.swift @@ -24,7 +24,7 @@ extension JetpackSiteRef { "siteID": \(siteID), "username": "\(username)", "homeURL": "url", - "isBackupFeatureAvailable": true, + "hasBackup": true, "hasPaidPlan": true, "isSelfHostedWithoutJetpack": false, "xmlRPC": null, diff --git a/WordPress/Classes/Models/JetpackSiteRef.swift b/WordPress/Classes/Models/JetpackSiteRef.swift index f42b112ac001..edb48768cd53 100644 --- a/WordPress/Classes/Models/JetpackSiteRef.swift +++ b/WordPress/Classes/Models/JetpackSiteRef.swift @@ -16,7 +16,7 @@ struct JetpackSiteRef: Hashable, Codable { /// The homeURL string for a site. let homeURL: String - private(set) var isBackupFeatureAvailable = false + private(set) var hasBackup = false private var hasPaidPlan = false // Self Hosted Non Jetpack Support @@ -61,7 +61,7 @@ struct JetpackSiteRef: Hashable, Codable { self.siteID = siteID self.username = username self.homeURL = homeURL - self.isBackupFeatureAvailable = blog.isBackupsAllowed() + self.hasBackup = blog.isBackupsAllowed() self.hasPaidPlan = blog.hasPaidPlan } } @@ -74,12 +74,12 @@ struct JetpackSiteRef: Hashable, Codable { return lhs.siteID == rhs.siteID && lhs.username == rhs.username && lhs.homeURL == rhs.homeURL - && lhs.isBackupFeatureAvailable == rhs.isBackupFeatureAvailable + && lhs.hasBackup == rhs.hasBackup && lhs.hasPaidPlan == rhs.hasPaidPlan } func shouldShowActivityLogFilter() -> Bool { - isBackupFeatureAvailable || hasPaidPlan + hasBackup || hasPaidPlan } struct Constants { diff --git a/WordPress/Classes/Stores/ActivityStore.swift b/WordPress/Classes/Stores/ActivityStore.swift index e08f6780de73..58fc86e6bb32 100644 --- a/WordPress/Classes/Stores/ActivityStore.swift +++ b/WordPress/Classes/Stores/ActivityStore.swift @@ -299,7 +299,7 @@ extension ActivityStore { } func fetchBackupStatus(site: JetpackSiteRef) { - guard site.isBackupFeatureAvailable else { + guard site.hasBackup else { return } diff --git a/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift index f3b19f86c494..809d81a4534d 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift @@ -31,7 +31,7 @@ final class DownloadableBackupTracker: ObservableObject { /// Refreshes backup status and starts continuous polling with adaptive delays. func refreshBackupStatus() { - guard let siteRef = JetpackSiteRef(blog: blog), siteRef.isBackupFeatureAvailable else { + guard let siteRef = JetpackSiteRef(blog: blog), siteRef.hasBackup else { return } From 5d41e39501864f8cc0f40ec56116573bcd5fbc5a Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 09:38:49 -0400 Subject: [PATCH 48/90] Update tests --- .../KeystoneTests/Tests/Stores/ActivityStoreTests.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift b/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift index 6c3cb341d1ab..41597c123ef0 100644 --- a/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift +++ b/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift @@ -204,14 +204,7 @@ class ActivityServiceRemoteMock: ActivityServiceRemote { var groupsToReturn: [ActivityGroup]? - override func getActivityForSite(_ siteID: Int, - offset: Int = 0, - count: Int, - after: Date? = nil, - before: Date? = nil, - group: [String] = [], - success: @escaping (_ activities: [Activity], _ hasMore: Bool) -> Void, - failure: @escaping (Error) -> Void) { + override func getActivityForSite(_ siteID: Int, offset: Int = 0, count: Int, after: Date? = nil, before: Date? = nil, group: [String] = [], rewindable: Bool? = nil, searchText: String? = nil, success: @escaping (_ activities: [WordPressKit.Activity], _ hasMore: Bool) -> Void, failure: @escaping (any Error) -> Void) { getActivityForSiteCalledWithSiteID = siteID getActivityForSiteCalledWithCount = count getActivityForSiteCalledWithOffset = offset From 90d741b2d871698396988808f3da3150e2442a2a Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:02:25 -0400 Subject: [PATCH 49/90] Update release notes --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 5903c9b2c2d6..830c338bea02 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -5,6 +5,7 @@ * [*] Fix an issue with "Stats" / "Subscribers" / "Emails" showing html encoded characters [#24513] * [*] Add search to “Jetpack Activity List” and display actors and dates [#24597] * [*] Fix an issue with content in "Restore" and "Download Backup" flows covering the navigation bar [#24597] +* [*] Show when the downloadable backup expires in Backup List [#24597] 25.9 ----- From 0e89ba32293a59a4860650c89940a97ef3ed4fcd Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:12:20 -0400 Subject: [PATCH 50/90] Remove unused code --- ...ctivityLogCardCell+ActivityPresenter.swift | 83 +------------- .../DashboardActivityLogCardCell.swift | 107 ++++++++---------- .../DashboardActivityLogListView.swift | 27 +++++ 3 files changed, 73 insertions(+), 144 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogListView.swift diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift index 1f59040765ff..8edfff915efb 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift @@ -1,84 +1,3 @@ import Foundation -import WordPressShared -// MARK: - ActivityPresenter - -extension DashboardActivityLogCardCell: ActivityPresenter { - - func presentDetailsFor(activity: FormattableActivity) { - guard - let blog, - let site = JetpackSiteRef(blog: blog), - let presentingViewController else { - return - } - - WPAnalytics.track(.dashboardCardItemTapped, - properties: ["type": DashboardCard.activityLog.rawValue], - blog: blog) - - let detailVC = ActivityDetailViewController.loadFromStoryboard() - detailVC.site = site - detailVC.rewindStatus = store.state.rewindStatus[site] - detailVC.formattableActivity = activity - detailVC.presenter = self - - presentingViewController.navigationController?.pushViewController(detailVC, animated: true) - } - - func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) { - // Do nothing - this action isn't available for the activity log dashboard card - } - - func presentBackupFor(activity: Activity, from: String?) { - guard - let blog, - let site = JetpackSiteRef(blog: blog), - let presentingViewController else { - return - } - - let backupOptionsVC = JetpackBackupOptionsViewController(site: site, activity: activity) - backupOptionsVC.presentedFrom = from ?? Constants.sourceIdentifier - let navigationVC = UINavigationController(rootViewController: backupOptionsVC) - presentingViewController.present(navigationVC, animated: true) - } - - func presentRestoreFor(activity: Activity, from: String?) { - guard activity.isRewindable, activity.rewindID != nil else { - return - } - - guard - let blog, - let site = JetpackSiteRef(blog: blog), - let presentingViewController else { - return - } - - let restoreOptionsVC = JetpackRestoreOptionsViewController(site: site, - activity: activity, - isAwaitingCredentials: store.isAwaitingCredentials(site: site)) - - restoreOptionsVC.restoreStatusDelegate = self - restoreOptionsVC.presentedFrom = from ?? Constants.sourceIdentifier - let navigationVC = UINavigationController(rootViewController: restoreOptionsVC) - presentingViewController.present(navigationVC, animated: true) - } -} - -// MARK: - JetpackRestoreStatusViewControllerDelegate - -extension DashboardActivityLogCardCell: JetpackRestoreStatusViewControllerDelegate { - - func didFinishViewing(_ controller: JetpackRestoreStatusViewController) { - controller.dismiss(animated: true) - } -} - -extension DashboardActivityLogCardCell { - - private enum Constants { - static let sourceIdentifier = "dashboard" - } -} +// This file is no longer needed as we're using the SwiftUI ActivityLogDetailsView directly diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift index b6bc1f58fbd9..c05c7bc9cd13 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift @@ -1,19 +1,13 @@ import UIKit +import SwiftUI import WordPressShared final class DashboardActivityLogCardCell: DashboardCollectionViewCell { - enum ActivityLogSection: CaseIterable { - case activities - } - - typealias DataSource = UITableViewDiffableDataSource - typealias Snapshot = NSDiffableDataSourceSnapshot - private(set) var blog: Blog? private(set) weak var presentingViewController: BlogDashboardViewController? - private(set) lazy var dataSource = createDataSource() private var viewModel: DashboardActivityLogViewModel? + private var hostingController: UIHostingController? let store = StoreContainer.shared.activity @@ -27,17 +21,6 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { return frameView }() - lazy var tableView: UITableView = { - let tableView = DashboardCardTableView() - tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.isScrollEnabled = false - tableView.backgroundColor = nil - let activityCellNib = ActivityTableViewCell.defaultNib - tableView.register(activityCellNib, forCellReuseIdentifier: ActivityTableViewCell.defaultReuseID) - tableView.separatorStyle = .none - return tableView - }() - // MARK: - Initializers override init(frame: CGRect) { @@ -58,7 +41,9 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { override func prepareForReuse() { super.prepareForReuse() - tableView.dataSource = nil + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil } // MARK: - View setup @@ -66,9 +51,6 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { private func setupView() { contentView.addSubview(cardFrameView) contentView.pinSubviewToAllEdges(cardFrameView, priority: .defaultHigh) - - cardFrameView.add(subview: tableView) - tableView.delegate = self } // MARK: - BlogDashboardCardConfigurable @@ -82,8 +64,8 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { self.presentingViewController = viewController self.viewModel = DashboardActivityLogViewModel(apiResponse: apiResponse) - tableView.dataSource = dataSource - updateDataSource(with: viewModel?.activitiesToDisplay ?? []) + let activities = viewModel?.activitiesToDisplay ?? [] + configureHostingController(with: activities, parent: viewController) configureHeaderAction(for: blog) configureContextMenu(for: blog) @@ -93,6 +75,44 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { blog: blog) } + private func configureHostingController(with activities: [Activity], parent: UIViewController?) { + guard let parent else { return } + + let listView = DashboardActivityLogListView(activities: activities) { [weak self] activity in + self?.didSelectActivity(activity) + } + + if let hostingController { + hostingController.rootView = listView + } else { + let hostingController = UIHostingController(rootView: listView) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.willMove(toParent: parent) + parent.addChild(hostingController) + cardFrameView.add(subview: hostingController.view) + hostingController.didMove(toParent: parent) + self.hostingController = hostingController + } + + hostingController?.view.invalidateIntrinsicContentSize() + } + + private func didSelectActivity(_ activity: Activity) { + guard let blog, + let presentingViewController else { + return + } + + WPAnalytics.track(.dashboardCardItemTapped, + properties: ["type": DashboardCard.activityLog.rawValue], + blog: blog) + + let detailView = ActivityLogDetailsView(activity: activity, blog: blog) + let hostingController = UIHostingController(rootView: detailView) + presentingViewController.navigationController?.pushViewController(hostingController, animated: true) + } + private func configureHeaderAction(for blog: Blog) { cardFrameView.onHeaderTap = { [weak self] in self?.showActivityLog(for: blog, tapSource: Constants.headerTapSource) @@ -144,43 +164,6 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { } -// MARK: - Diffable DataSource - -extension DashboardActivityLogCardCell { - - private func createDataSource() -> DataSource { - return DataSource(tableView: tableView) { (tableView, indexPath, activity) -> UITableViewCell? in - guard let cell = tableView.dequeueReusableCell(withIdentifier: ActivityTableViewCell.defaultReuseID) as? ActivityTableViewCell else { - return nil - } - - let formattableActivity = FormattableActivity(with: activity) - cell.configureCell(formattableActivity, displaysDate: true) - return cell - } - } - - private func updateDataSource(with activities: [Activity]) { - var snapshot = Snapshot() - snapshot.appendSections(ActivityLogSection.allCases) - snapshot.appendItems(activities, toSection: .activities) - dataSource.apply(snapshot) - } -} - -// MARK: - UITableViewDelegate - -extension DashboardActivityLogCardCell: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let activity = dataSource.itemIdentifier(for: indexPath) else { - return - } - - let formattableActivity = FormattableActivity(with: activity) - presentDetailsFor(activity: formattableActivity) - } -} - // MARK: - Helpers extension DashboardActivityLogCardCell { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogListView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogListView.swift new file mode 100644 index 000000000000..3471a8f07bf6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogListView.swift @@ -0,0 +1,27 @@ +import SwiftUI +import WordPressKit + +struct DashboardActivityLogListView: View { + let activities: [Activity] + let onActivityTap: (Activity) -> Void + + var body: some View { + VStack(spacing: 0) { + ForEach(activities, id: \.activityID) { activity in + Button(action: { + onActivityTap(activity) + }) { + ActivityLogRowView(viewModel: ActivityLogRowViewModel(activity: activity)) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(PlainButtonStyle()) + + if activity != activities.last { + Divider() + .padding(.leading, 64) + } + } + } + } +} From 5384608a2ec83a8a7849e7d6dbd2e78c9f891cef Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:16:44 -0400 Subject: [PATCH 51/90] Cleanup --- .../DashboardActivityLogCardCell+ActivityPresenter.swift | 3 --- .../Cards/Activity Log/DashboardActivityLogCardCell.swift | 6 +----- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift deleted file mode 100644 index 8edfff915efb..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation - -// This file is no longer needed as we're using the SwiftUI ActivityLogDetailsView directly diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift index c05c7bc9cd13..7af6e6a902e0 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift @@ -156,12 +156,8 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { presentingViewController?.navigationController?.pushViewController(activityLogController, animated: true) - WPAnalytics.track(.activityLogViewed, - withProperties: [ - WPAppAnalyticsKeyTapSource: tapSource - ]) + WPAnalytics.track(.activityLogViewed, withProperties: [WPAppAnalyticsKeyTapSource: tapSource]) } - } // MARK: - Helpers From 9325a27d0874edf105312240a9d75912e4e6917f Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:18:02 -0400 Subject: [PATCH 52/90] Remove Application from list --- .../ViewRelated/Activity/List/ActivityLogRowViewModel.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift index d7f040c79404..c11cacb95c49 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -22,8 +22,6 @@ struct ActivityLogRowViewModel: Identifiable { if let actor = activity.actor { if !actor.role.isEmpty { actorSubtitle = actor.role.localizedCapitalized - } else if !actor.type.isEmpty { - actorSubtitle = actor.type.localizedCapitalized } } self.date = activity.published From 1c8dc6f77b6fc1ce1b0dd40f91902f30e53d12e4 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:33:37 -0400 Subject: [PATCH 53/90] Remove BackupListViewController and use ActivityLogsViewController instead - Delete BackupListViewController.swift and its extension - Update showBackup() to use ActivityLogsViewController with isBackupMode - Remove BackupListViewController reference from ActivityDetailViewController - Implement displayBackupWithSiteID using ActivityLogsViewController - Add WPAnalytics tracking to ContentCoordinator backup display --- .../Classes/Utility/ContentCoordinator.swift | 10 ++-- .../ActivityDetailViewController.swift | 2 - ...ntroller+JetpackBannerViewController.swift | 19 ------- .../Backup/BackupListViewController.swift | 52 ------------------- .../BlogDetailsViewController+Swift.swift | 13 +---- 5 files changed, 9 insertions(+), 87 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index 3abf0f4f4658..bbc5259ef6cf 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressShared protocol ContentCoordinator { func displayReaderWithPostId(_ postID: NSNumber?, siteID: NSNumber?) throws @@ -84,13 +85,16 @@ struct DefaultContentCoordinator: ContentCoordinator { func displayBackupWithSiteID(_ siteID: NSNumber?) throws { guard let siteID, - let blog = Blog.lookup(withID: siteID, in: mainContext), - let backupListViewController = BackupListViewController.withJPBannerForBlog(blog) + let blog = Blog.lookup(withID: siteID, in: mainContext) else { throw DisplayError.missingParameter } - controller?.navigationController?.pushViewController(backupListViewController, animated: true) + let backupViewController = ActivityLogsViewController(blog: blog, isBackupMode: true) + backupViewController.navigationItem.largeTitleDisplayMode = .never + controller?.navigationController?.pushViewController(backupViewController, animated: true) + + WPAnalytics.track(.backupListOpened) } func displayScanWithSiteID(_ siteID: NSNumber?) throws { diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift index ea57c336dcd2..b1f2bd77f961 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift @@ -286,8 +286,6 @@ class ActivityDetailViewController: UIViewController, StoryboardLoadable { private func presentedFrom() -> String { if presenter is JetpackActivityLogViewController { return "activity_log" - } else if presenter is BackupListViewController { - return "backup" } else if presenter is DashboardActivityLogCardCell { return "dashboard" } else { diff --git a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift b/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift deleted file mode 100644 index da57ead27822..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift +++ /dev/null @@ -1,19 +0,0 @@ -import UIKit - -extension BackupListViewController { - @objc - static func withJPBannerForBlog(_ blog: Blog) -> UIViewController? { - guard let backupListVC = BackupListViewController(blog: blog) else { - return nil - } - backupListVC.navigationItem.largeTitleDisplayMode = .never - return JetpackBannerWrapperViewController(childVC: backupListVC, screen: .backup) - } - - override func scrollViewDidScroll(_ scrollView: UIScrollView) { - super.scrollViewDidScroll(scrollView) - if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { - jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) - } - } -} diff --git a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift b/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift deleted file mode 100644 index b14533ba8b5f..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Combine -import WordPressShared - -class BackupListViewController: BaseActivityListViewController { - - override init(site: JetpackSiteRef, store: ActivityStore, isFreeWPCom: Bool = false) { - store.onlyRestorableItems = true - - let activityListConfiguration = ActivityListConfiguration( - identifier: "backup", - title: NSLocalizedString("Backup", comment: "Title for the Jetpack's backup list"), - loadingTitle: NSLocalizedString("Loading Backups...", comment: "Text displayed while loading the activity feed for a site"), - noActivitiesTitle: NSLocalizedString("Your first backup will be ready soon", comment: "Title for the view when there aren't any Backups to display"), - noActivitiesSubtitle: NSLocalizedString("Your first backup will appear here within 24 hours and you will receive a notification once the backup has been completed", comment: "Text displayed in the view when there aren't any Backups to display"), - noMatchingTitle: NSLocalizedString("No matching backups found", comment: "Title for the view when there aren't any backups to display for a given filter."), - noMatchingSubtitle: NSLocalizedString("Try adjusting your date range filter", comment: "Text displayed in the view when there aren't any backups to display for a given filter."), - filterbarRangeButtonTapped: .backupFilterbarRangeButtonTapped, - filterbarSelectRange: .backupFilterbarSelectRange, - filterbarResetRange: .backupFilterbarResetRange, - numberOfItemsPerPage: 100 - ) - - super.init(site: site, store: store, configuration: activityListConfiguration, isFreeWPCom: isFreeWPCom) - - activityTypeFilterChip.isHidden = true - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc convenience init?(blog: Blog) { - precondition(blog.dotComID != nil) - guard let siteRef = JetpackSiteRef(blog: blog) else { - return nil - } - - let isFreeWPCom = blog.isHostedAtWPcom && !blog.hasPaidPlan - self.init(site: siteRef, store: StoreContainer.shared.activity, isFreeWPCom: isFreeWPCom) - } - - // MARK: - View lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.accessibilityIdentifier = "jetpack-backup-table" - - WPAnalytics.track(.backupListOpened) - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 88687d8a0329..420707edda15 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -182,17 +182,8 @@ extension BlogDetailsViewController { } @objc public func showBackup() { - let controller: UIViewController - - if FeatureFlag.dataViews.enabled { - controller = ActivityLogsViewController(blog: blog, isBackupMode: true) - controller.navigationItem.largeTitleDisplayMode = .never - } else { - guard let backupListVC = BackupListViewController.withJPBannerForBlog(blog) else { - return wpAssertionFailure("failed to instantiate") - } - controller = backupListVC - } + let controller = ActivityLogsViewController(blog: blog, isBackupMode: true) + controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) From 10e878ac7a1645cf28a5c697393d303e1b2449c6 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:36:32 -0400 Subject: [PATCH 54/90] Remove JetpackActivityLogViewController and use ActivityLogsViewController - Delete JetpackActivityLogViewController.swift - Update showActivity() methods to use ActivityLogsViewController directly - Update ActivityDetailViewController to check for ActivityLogsViewController - Simplify DashboardActivityLogCardCell to use ActivityLogsViewController --- .../ActivityDetailViewController.swift | 2 +- .../JetpackActivityLogViewController.swift | 63 ------------------- .../DashboardActivityLogCardCell.swift | 11 +--- .../BlogDetailsViewController+Swift.swift | 11 +--- 4 files changed, 3 insertions(+), 84 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift index b1f2bd77f961..23cb6f7eaad2 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift @@ -284,7 +284,7 @@ class ActivityDetailViewController: UIViewController, StoryboardLoadable { } private func presentedFrom() -> String { - if presenter is JetpackActivityLogViewController { + if presenter is ActivityLogsViewController { return "activity_log" } else if presenter is DashboardActivityLogCardCell { return "dashboard" diff --git a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift deleted file mode 100644 index eef433e3fa7b..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift +++ /dev/null @@ -1,63 +0,0 @@ -import UIKit -import Combine - -class JetpackActivityLogViewController: BaseActivityListViewController { - private let jetpackBannerView = JetpackBannerView() - let scrollViewTranslationPublisher = PassthroughSubject() - - override init(site: JetpackSiteRef, store: ActivityStore, isFreeWPCom: Bool = false) { - store.onlyRestorableItems = false - - let activityListConfiguration = ActivityListConfiguration( - identifier: "activity_log", - title: NSLocalizedString("Activity", comment: "Title for the activity list"), - loadingTitle: NSLocalizedString("Loading Activities...", comment: "Text displayed while loading the activity feed for a site"), - noActivitiesTitle: NSLocalizedString("No activity yet", comment: "Title for the view when there aren't any Activities to display in the Activity Log"), - noActivitiesSubtitle: NSLocalizedString("When you make changes to your site you'll be able to see your activity history here.", comment: "Text display when the view when there aren't any Activities to display in the Activity Log"), - noMatchingTitle: NSLocalizedString("No matching events found.", comment: "Title for the view when there aren't any Activities to display in the Activity Log for a given filter."), - noMatchingSubtitle: NSLocalizedString("Try adjusting your date range or activity type filters", comment: "Text display when the view when there aren't any Activities to display in the Activity Log for a given filter."), - filterbarRangeButtonTapped: .activitylogFilterbarRangeButtonTapped, - filterbarSelectRange: .activitylogFilterbarSelectRange, - filterbarResetRange: .activitylogFilterbarResetRange, - numberOfItemsPerPage: 20 - ) - - super.init(site: site, store: store, configuration: activityListConfiguration, isFreeWPCom: isFreeWPCom) - - if JetpackBrandingVisibility.all.enabled { - configureBanner() - } - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc convenience init?(blog: Blog) { - precondition(blog.dotComID != nil) - guard let siteRef = JetpackSiteRef(blog: blog) else { - return nil - } - let isFreeWPCom = blog.isHostedAtWPcom && !blog.hasPaidPlan - self.init(site: siteRef, store: StoreContainer.shared.activity, isFreeWPCom: isFreeWPCom) - } - - // MARK: - View lifecycle - - private func configureBanner() { - containerStackView.addArrangedSubview(jetpackBannerView) - addTranslationObserver(jetpackBannerView) - let textProvider = JetpackBrandingTextProvider(screen: JetpackBannerScreen.activityLog) - jetpackBannerView.configure(title: textProvider.brandingText()) { [unowned self] in - JetpackBrandingCoordinator.presentOverlay(from: self) - JetpackBrandingAnalyticsHelper.trackJetpackPoweredBannerTapped(screen: .activityLog) - } - } -} - -extension JetpackActivityLogViewController: JPScrollViewDelegate { - override func scrollViewDidScroll(_ scrollView: UIScrollView) { - super.scrollViewDidScroll(scrollView) - processJetpackBannerVisibility(scrollView) - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift index 7af6e6a902e0..07d230a3a635 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift @@ -144,16 +144,7 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { // MARK: - Navigation private func showActivityLog(for blog: Blog, tapSource: String) { - let activityLogController: UIViewController - - if FeatureFlag.dataViews.enabled { - activityLogController = ActivityLogsViewController(blog: blog) - } else if let jetpackController = JetpackActivityLogViewController(blog: blog) { - activityLogController = jetpackController - } else { - return - } - + let activityLogController = ActivityLogsViewController(blog: blog) presentingViewController?.navigationController?.pushViewController(activityLogController, animated: true) WPAnalytics.track(.activityLogViewed, withProperties: [WPAppAnalyticsKeyTapSource: tapSource]) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 420707edda15..61c4494166ca 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -149,16 +149,7 @@ extension BlogDetailsViewController { } @objc public func showActivity() { - let controller: UIViewController - - if FeatureFlag.dataViews.enabled { - controller = ActivityLogsViewController(blog: blog) - } else if let jetpackController = JetpackActivityLogViewController(blog: blog) { - controller = jetpackController - } else { - return wpAssertionFailure("failed to instantiate") - } - + let controller = ActivityLogsViewController(blog: blog) controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) From cedbc2cb6f0e516ee503de9a8045217dcc765d56 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:39:18 -0400 Subject: [PATCH 55/90] Remove dataViews feature flag - Remove dataViews case from FeatureFlag enum - Remove dataViews from enabled property switch statement - Remove dataViews description The feature flag was already removed from usage in previous commits where we removed JetpackActivityLogViewController and BackupListViewController. --- WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 2712f8ca9f3a..79fce8ba652b 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -25,7 +25,6 @@ public enum FeatureFlag: Int, CaseIterable { case pluginManagementOverhaul case nativeJetpackConnection case newsletterSubscribers - case dataViews /// Returns a boolean indicating if the feature is enabled. /// @@ -82,8 +81,6 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .newsletterSubscribers: return true - case .dataViews: - return BuildConfiguration.current == .debug } } @@ -127,7 +124,6 @@ extension FeatureFlag { case .readerGutenbergCommentComposer: "Gutenberg Comment Composer" case .nativeJetpackConnection: "Native Jetpack Connection" case .newsletterSubscribers: "Newsletter Subscribers" - case .dataViews: "Data Views" } } } From cc1719cadb10dc77eeef827bf1ae6d97e17400d0 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:45:55 -0400 Subject: [PATCH 56/90] Create separate BackupsViewController and remove isBackupMode from ActivityLogsViewController - Create new BackupsViewController and BackupsView that reuses ActivityLogsView - Remove isBackupMode parameter from ActivityLogsViewController - Update all usages to use BackupsViewController for backups - Update ActivityDetailViewController to recognize BackupsViewController presenter - Keep ActivityLogsView and ActivityLogsViewModel unchanged as implementation details --- .../Classes/Utility/ContentCoordinator.swift | 2 +- .../ActivityDetailViewController.swift | 2 ++ .../List/ActivityLogsViewController.swift | 13 +++++------ .../Activity/List/BackupsViewController.swift | 22 +++++++++++++++++++ .../BlogDetailsViewController+Swift.swift | 2 +- 5 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index bbc5259ef6cf..abe1de9b61ae 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -90,7 +90,7 @@ struct DefaultContentCoordinator: ContentCoordinator { throw DisplayError.missingParameter } - let backupViewController = ActivityLogsViewController(blog: blog, isBackupMode: true) + let backupViewController = BackupsViewController(blog: blog) backupViewController.navigationItem.largeTitleDisplayMode = .never controller?.navigationController?.pushViewController(backupViewController, animated: true) diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift index 23cb6f7eaad2..e3c60142123c 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift @@ -286,6 +286,8 @@ class ActivityDetailViewController: UIViewController, StoryboardLoadable { private func presentedFrom() -> String { if presenter is ActivityLogsViewController { return "activity_log" + } else if presenter is BackupsViewController { + return "backup" } else if presenter is DashboardActivityLogCardCell { return "dashboard" } else { diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift index 7356e25aab23..b80b8cb1b367 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift @@ -3,13 +3,13 @@ import SwiftUI import WordPressUI import WordPressKit -final class ActivityLogsViewController: UIHostingController { +final class ActivityLogsViewController: UIHostingController { private let viewModel: ActivityLogsViewModel - init(blog: Blog, isBackupMode: Bool = false) { - self.viewModel = ActivityLogsViewModel(blog: blog, isBackupMode: isBackupMode) - super.init(rootView: AnyView(ActivityLogsView(viewModel: viewModel))) - self.title = isBackupMode ? Strings.backupsTitle : Strings.activityTitle + init(blog: Blog) { + self.viewModel = ActivityLogsViewModel(blog: blog) + super.init(rootView: ActivityLogsView(viewModel: viewModel)) + self.title = Strings.title } required dynamic init?(coder aDecoder: NSCoder) { @@ -18,6 +18,5 @@ final class ActivityLogsViewController: UIHostingController { } private enum Strings { - static let activityTitle = NSLocalizedString("activity.logs.title", value: "Activity", comment: "Title for the activity logs screen") - static let backupsTitle = NSLocalizedString("backups.title", value: "Backups", comment: "Title for the backups screen") + static let title = NSLocalizedString("activityLogs.title", value: "Activity", comment: "Title for the activity logs screen") } diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift new file mode 100644 index 000000000000..f45e40570fa0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift @@ -0,0 +1,22 @@ +import UIKit +import SwiftUI +import WordPressUI +import WordPressKit + +final class BackupsViewController: UIHostingController { + private let viewModel: ActivityLogsViewModel + + init(blog: Blog) { + self.viewModel = ActivityLogsViewModel(blog: blog, isBackupMode: true) + super.init(rootView: ActivityLogsView(viewModel: viewModel)) + self.title = Strings.title + } + + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private enum Strings { + static let title = NSLocalizedString("backups.title", value: "Backups", comment: "Title for the backups screen") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 61c4494166ca..9c9d6a9c58ae 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -173,7 +173,7 @@ extension BlogDetailsViewController { } @objc public func showBackup() { - let controller = ActivityLogsViewController(blog: blog, isBackupMode: true) + let controller = BackupsViewController(blog: blog) controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) From e376f3030c26857de6561bb30a8ded7fed9d7093 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:54:10 -0400 Subject: [PATCH 57/90] Remove ActivityListViewModelTests --- .../Activity/ActivityListViewModelTests.swift | 148 ------------------ .../DashboardActivityLogViewModelTests.swift | 15 ++ 2 files changed, 15 insertions(+), 148 deletions(-) delete mode 100644 Tests/KeystoneTests/Tests/Features/Activity/ActivityListViewModelTests.swift diff --git a/Tests/KeystoneTests/Tests/Features/Activity/ActivityListViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Activity/ActivityListViewModelTests.swift deleted file mode 100644 index 38966339c80b..000000000000 --- a/Tests/KeystoneTests/Tests/Features/Activity/ActivityListViewModelTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import XCTest -import WordPressFlux - -@testable import WordPress - -class ActivityListViewModelTests: XCTestCase { - - let activityListConfiguration = ActivityListConfiguration( - identifier: "identifier", - title: "Title", - loadingTitle: "Loading Activities...", - noActivitiesTitle: "No activity yet", - noActivitiesSubtitle: "When you make changes to your site you'll be able to see your activity history here.", - noMatchingTitle: "No matching events found.", - noMatchingSubtitle: "Try adjusting your date range or activity type filters", - filterbarRangeButtonTapped: .activitylogFilterbarRangeButtonTapped, - filterbarSelectRange: .activitylogFilterbarSelectRange, - filterbarResetRange: .activitylogFilterbarResetRange, - numberOfItemsPerPage: 20 - ) - - // Check if `loadMore` dispatchs the correct action and params - // - func testLoadMore() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") - let activityStoreMock = ActivityStoreMock() - let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) - - activityListViewModel.loadMore() - - XCTAssertEqual(activityStoreMock.dispatchedAction, "loadMoreActivities") - XCTAssertEqual(activityStoreMock.quantity, 20) - XCTAssertEqual(activityStoreMock.offset, 20) - } - - // Check if `loadMore` dispatchs the correct offset - // - func testLoadMoreOffset() throws { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") - let activityStoreMock = ActivityStoreMock() - let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) - activityStoreMock.state.activities[jetpackSiteRef] = try [Activity.mock(), Activity.mock(), Activity.mock()] - - activityListViewModel.loadMore() - activityListViewModel.loadMore() - - XCTAssertEqual(activityStoreMock.dispatchedAction, "loadMoreActivities") - XCTAssertEqual(activityStoreMock.quantity, 20) - XCTAssertEqual(activityStoreMock.offset, 40) - } - - // Check if `loadMore` dispatchs the correct after/before date and groups - // - func testLoadMoreAfterBeforeDate() throws { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") - let activityStoreMock = ActivityStoreMock() - let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) - activityStoreMock.state.activities[jetpackSiteRef] = try [Activity.mock(), Activity.mock(), Activity.mock()] - let afterDate = Date() - let beforeDate = Date(timeIntervalSinceNow: 86400) - let activityGroup = ActivityGroup.mock() - activityListViewModel.refresh(after: afterDate, before: beforeDate, group: [activityGroup]) - - activityListViewModel.loadMore() - - XCTAssertEqual(activityStoreMock.dispatchedAction, "loadMoreActivities") - XCTAssertEqual(activityStoreMock.afterDate, afterDate) - XCTAssertEqual(activityStoreMock.beforeDate, beforeDate) - XCTAssertEqual(activityStoreMock.group, [activityGroup.key]) - } - - // Should not load more if already loading - // - func testLoadMoreDoesntTriggeredWhenAlreadyFetching() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") - let activityStoreMock = ActivityStoreMock() - let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) - activityStoreMock.isFetching = true - - activityListViewModel.loadMore() - - XCTAssertNil(activityStoreMock.dispatchedAction) - } - - // When filtering, remove all current activities - // - func testRefreshRemoveAllActivities() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") - let activityStoreMock = ActivityStoreMock() - let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) - activityStoreMock.isFetching = true - - activityListViewModel.refresh(after: Date(), before: Date()) - - XCTAssertEqual(activityStoreMock.dispatchedAction, "resetActivities") - } -} - -class ActivityStoreMock: ActivityStore { - var dispatchedAction: String? - var site: JetpackSiteRef? - var quantity: Int? - var offset: Int? - var isFetching = false - var afterDate: Date? - var beforeDate: Date? - var group: [String]? - - override func isFetchingActivities(site: JetpackSiteRef) -> Bool { - return isFetching - } - - override func onDispatch(_ action: Action) { - guard let activityAction = action as? ActivityAction else { - return - } - - switch activityAction { - case .loadMoreActivities(let site, let quantity, let offset, let afterDate, let beforeDate, let group): - dispatchedAction = "loadMoreActivities" - self.site = site - self.quantity = quantity - self.offset = offset - self.afterDate = afterDate - self.beforeDate = beforeDate - self.group = group - case .resetActivities: - dispatchedAction = "resetActivities" - default: - break - } - } -} - -extension Activity { - static func mock(id: String = "1", isRewindable: Bool = false) throws -> Activity { - let dictionary = [ - "activity_id": id, - "summary": "", - "is_rewindable": isRewindable, - "rewind_id": "1", - "content": ["text": ""], - "published": "2020-11-09T13:16:43.701+00:00" - ] as [String: AnyObject] - let data = try JSONSerialization.data(withJSONObject: dictionary, options: .prettyPrinted) - return try JSONDecoder().decode(Activity.self, from: data) - } -} diff --git a/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardActivityLogViewModelTests.swift b/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardActivityLogViewModelTests.swift index d1358090a2ff..2b313866356a 100644 --- a/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardActivityLogViewModelTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardActivityLogViewModelTests.swift @@ -51,3 +51,18 @@ final class DashboardActivityLogViewModelTests: XCTestCase { XCTAssertEqual(viewModel.activitiesToDisplay[2].activityID, "3") } } + +extension Activity { + static func mock(id: String = "1", isRewindable: Bool = false) throws -> Activity { + let dictionary = [ + "activity_id": id, + "summary": "", + "is_rewindable": isRewindable, + "rewind_id": "1", + "content": ["text": ""], + "published": "2020-11-09T13:16:43.701+00:00" + ] as [String: AnyObject] + let data = try JSONSerialization.data(withJSONObject: dictionary, options: .prettyPrinted) + return try JSONDecoder().decode(Activity.self, from: data) + } +} From 8957b9f6471ff008fe3ebcb8387a19c478d79eca Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:55:11 -0400 Subject: [PATCH 58/90] Remove ActivityTypeSelectorViewController --- .../ActivityTypeSelectorViewController.swift | 153 ------------------ 1 file changed, 153 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift deleted file mode 100644 index 33b485ff93d9..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation -import WordPressFlux -import WordPressUI - -protocol ActivityTypeSelectorDelegate: AnyObject { - func didCancel(selectorViewController: ActivityTypeSelectorViewController) - func didSelect(selectorViewController: ActivityTypeSelectorViewController, groups: [ActivityGroup]) -} - -class ActivityTypeSelectorViewController: UITableViewController { - private let viewModel: ActivityListViewModel! - - private var storeReceipt: Receipt? - private var selectedGroupsKeys: [String] = [] - - private var noResultsViewController: NoResultsViewController? - - weak var delegate: ActivityTypeSelectorDelegate? - - init(viewModel: ActivityListViewModel) { - self.viewModel = viewModel - self.selectedGroupsKeys = viewModel.selectedGroups.map { $0.key } - super.init(style: .insetGrouped) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - configureTableView() - - setupNavButtons() - - viewModel.refreshGroups() - - updateNoResults() - - storeReceipt = viewModel.store.onChange { [weak self] in - self?.tableView.reloadData() - self?.updateNoResults() - } - - title = NSLocalizedString("Filter by activity type", comment: "Title of a screen that shows activity types so the user can filter using them (eg.: posts, images, users)") - } - - private func configureTableView() { - tableView.cellLayoutMarginsFollowReadableWidth = true - tableView.register(WPTableViewCell.self, forCellReuseIdentifier: Constants.groupCellIdentifier) - WPStyleGuide.configureAutomaticHeightRows(for: tableView) - } - - private func setupNavButtons() { - let doneButton = UIBarButtonItem(title: NSLocalizedString("Done", comment: "Label for Done button"), style: .done, target: self, action: #selector(done)) - navigationItem.setRightBarButton(doneButton, animated: false) - - navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)), animated: false) - } - - @objc private func done() { - let selectedGroups = viewModel.groups.filter { selectedGroupsKeys.contains($0.key) } - - delegate?.didSelect(selectorViewController: self, groups: selectedGroups) - } - - @objc private func cancel() { - delegate?.didCancel(selectorViewController: self) - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.groups.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: Constants.groupCellIdentifier, for: indexPath) as? WPTableViewCell else { - return UITableViewCell() - } - - let activityGroup = viewModel.groups[indexPath.row] - - cell.textLabel?.text = "\(activityGroup.name) (\(activityGroup.count))" - cell.accessoryType = selectedGroupsKeys.contains(activityGroup.key) ? .checkmark : .none - - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - - let cell = tableView.cellForRow(at: indexPath) - - let selectedGroupKey = viewModel.groups[indexPath.row].key - - if selectedGroupsKeys.contains(selectedGroupKey) { - cell?.accessoryType = .none - selectedGroupsKeys = selectedGroupsKeys.filter { $0 != selectedGroupKey } - } else { - cell?.accessoryType = .checkmark - selectedGroupsKeys.append(selectedGroupKey) - } - } - - private enum Constants { - static let groupCellIdentifier = "GroupCellIdentifier" - } -} - -// MARK: - NoResults Handling - -private extension ActivityTypeSelectorViewController { - - func updateNoResults() { - if let noResultsViewModel = viewModel.noResultsGroupsViewModel() { - showNoResults(noResultsViewModel) - } else { - noResultsViewController?.view.isHidden = true - } - } - - func showNoResults(_ viewModel: NoResultsViewController.Model) { - if noResultsViewController == nil { - noResultsViewController = NoResultsViewController.controller() - noResultsViewController?.delegate = self - - guard let noResultsViewController else { - return - } - - if noResultsViewController.view.superview != tableView { - tableView.addSubview(withFadeAnimation: noResultsViewController.view) - } - - addChild(noResultsViewController) - } - - noResultsViewController?.bindViewModel(viewModel) - noResultsViewController?.didMove(toParent: self) - noResultsViewController?.view.translatesAutoresizingMaskIntoConstraints = false - tableView.pinSubviewToSafeArea(noResultsViewController!.view) - noResultsViewController?.view.isHidden = false - } - -} - -// MARK: - NoResultsViewControllerDelegate - -extension ActivityTypeSelectorViewController: NoResultsViewControllerDelegate { - func actionButtonPressed() { - viewModel.refreshGroups() - } -} From 07a1f60db311f417501279e3c3b393e570429488 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:57:17 -0400 Subject: [PATCH 59/90] Remove ActivityListViewModel --- .../ActivityDetailViewController.swift | 7 + .../Activity/ActivityListViewModel.swift | 396 ------------------ 2 files changed, 7 insertions(+), 396 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift index e3c60142123c..32888c85140c 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift @@ -2,6 +2,13 @@ import UIKit import Gridicons import WordPressUI +protocol ActivityPresenter: AnyObject { + func presentDetailsFor(activity: FormattableActivity) + func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) + func presentRestoreFor(activity: Activity, from: String?) + func presentBackupFor(activity: Activity, from: String?) +} + class ActivityDetailViewController: UIViewController, StoryboardLoadable { // MARK: - StoryboardLoadable Protocol diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift b/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift deleted file mode 100644 index 87609b718314..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift +++ /dev/null @@ -1,396 +0,0 @@ -import WordPressFlux -import WordPressShared -import WordPressUI - -protocol ActivityPresenter: AnyObject { - func presentDetailsFor(activity: FormattableActivity) - func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) - func presentRestoreFor(activity: Activity, from: String?) - func presentBackupFor(activity: Activity, from: String?) -} - -class ActivityListViewModel: Observable { - - let site: JetpackSiteRef - let store: ActivityStore - - let changeDispatcher = Dispatcher() - - private let activitiesReceipt: Receipt - private let rewindStatusReceipt: Receipt - private let noResultsTexts: ActivityListConfiguration - private var storeReceipt: Receipt? - - private var numberOfItemsPerPage = 20 - private var page = 0 - private(set) var after: Date? - private(set) var before: Date? - private(set) var selectedGroups: [ActivityGroup] = [] - - private(set) var refreshing = false { - didSet { - if refreshing != oldValue { - emitChange() - } - } - } - - var hasMore: Bool { - store.state.hasMore - } - - var dateFilterIsActive: Bool { - return after != nil || before != nil - } - - var groupFilterIsActive: Bool { - return !selectedGroups.isEmpty - } - - var isAnyFilterActive: Bool { - return dateFilterIsActive || groupFilterIsActive - } - - var groups: [ActivityGroup] { - return store.state.groups[site] ?? [] - } - - lazy var downloadPromptView: AppFeedbackPromptView = { - AppFeedbackPromptView() - }() - - init(site: JetpackSiteRef, - store: ActivityStore = StoreContainer.shared.activity, - configuration: ActivityListConfiguration) { - self.site = site - self.store = store - self.noResultsTexts = configuration - - numberOfItemsPerPage = configuration.numberOfItemsPerPage - store.numberOfItemsPerPage = numberOfItemsPerPage - - activitiesReceipt = store.query(.activities(site: site)) - rewindStatusReceipt = store.query(.restoreStatus(site: site)) - - storeReceipt = store.onChange { [weak self] in - self?.updateState() - } - } - - private func updateState() { - changeDispatcher.dispatch() - refreshing = store.isFetchingActivities(site: site) - } - - public func refresh(after: Date? = nil, before: Date? = nil, group: [ActivityGroup] = []) { - store.fetchRewindStatus(site: site) - - ActionDispatcher.dispatch(ActivityAction.refreshBackupStatus(site: site)) - - // If a new filter is being applied, remove all activities - if isApplyingNewFilter(after: after, before: before, group: group) { - ActionDispatcher.dispatch(ActivityAction.resetActivities(site: site)) - } - - // If a new date range is being applied, remove the current activity types - if isApplyingDateFilter(after: after, before: before) { - ActionDispatcher.dispatch(ActivityAction.resetGroups(site: site)) - } - - self.page = 0 - self.after = after - self.before = before - self.selectedGroups = group - - ActionDispatcher.dispatch(ActivityAction.refreshActivities(site: site, quantity: numberOfItemsPerPage, afterDate: after, beforeDate: before, group: group.map { $0.key })) - } - - public func loadMore() { - if !store.isFetchingActivities(site: site) { - page += 1 - let offset = page * numberOfItemsPerPage - ActionDispatcher.dispatch(ActivityAction.loadMoreActivities(site: site, quantity: numberOfItemsPerPage, offset: offset, afterDate: after, beforeDate: before, group: selectedGroups.map { $0.key })) - } - } - - public func removeDateFilter() { - refresh(after: nil, before: nil, group: selectedGroups) - } - - public func removeGroupFilter() { - refresh(after: after, before: before, group: []) - } - - public func refreshGroups() { - ActionDispatcher.dispatch(ActivityAction.refreshGroups(site: site, afterDate: after, beforeDate: before)) - } - - func noResultsViewModel(connectionAvailable: Bool = ReachabilityUtils.connectionAvailable) -> NoResultsViewController.Model? { - guard store.getActivities(site: site) == nil || - store.getActivities(site: site)?.isEmpty == true else { - return nil - } - - if store.isFetchingActivities(site: site) { - return NoResultsViewController.Model(title: noResultsTexts.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) - } - - if let activites = store.getActivities(site: site), activites.isEmpty { - if isAnyFilterActive { - return NoResultsViewController.Model(title: noResultsTexts.noMatchingTitle, subtitle: noResultsTexts.noMatchingSubtitle) - } else { - return NoResultsViewController.Model(title: noResultsTexts.noActivitiesTitle, subtitle: NoResultsText.noActivitiesSubtitle) - } - } - - guard connectionAvailable else { - return NoResultsViewController.Model(title: NoResultsText.noConnectionTitle, subtitle: NoResultsText.noConnectionSubtitle) - } - - return NoResultsViewController.Model( - title: NoResultsText.errorTitle, - subtitle: NoResultsText.errorSubtitle, - buttonText: NoResultsText.errorButtonText - ) - } - - func noResultsGroupsViewModel(connectionAvailable: Bool = ReachabilityUtils.connectionAvailable) -> NoResultsViewController.Model? { - guard store.getGroups(site: site) == nil || - store.getGroups(site: site)?.isEmpty == true else { - return nil - } - - if store.isFetchingGroups(site: site) { - return NoResultsViewController.Model(title: noResultsTexts.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) - } - - if let groups = store.getGroups(site: site), groups.isEmpty { - return NoResultsViewController.Model(title: NoResultsText.noGroupsTitle, subtitle: NoResultsText.noGroupsSubtitle) - } - - guard connectionAvailable else { - return NoResultsViewController.Model(title: NoResultsText.noConnectionTitle, subtitle: NoResultsText.noConnectionSubtitle) - } - - return NoResultsViewController.Model( - title: NoResultsText.errorTitle, - subtitle: NoResultsText.errorSubtitle, - buttonText: NoResultsText.groupsErrorButtonText - ) - } - - func tableViewModel(presenter: ActivityPresenter) -> ImmuTable { - guard let activities = store.getActivities(site: site) else { - return .Empty - } - let formattableActivities = activities.map(FormattableActivity.init) - let activitiesRows = formattableActivities.map({ formattableActivity in - return ActivityListRow( - formattableActivity: formattableActivity, - action: { [weak presenter] (row) in - presenter?.presentDetailsFor(activity: formattableActivity) - }, - actionButtonHandler: { [weak presenter] (button) in - presenter?.presentBackupOrRestoreFor(activity: formattableActivity.activity, from: button) - } - ) - }) - - let groupedRows = activitiesRows.sortedGroup { - return longDateFormatterWithoutTime.string(from: $0.activity.published) - } - - let activitiesSections = groupedRows - .map { (date, rows) in - return ImmuTableSection(headerText: date, - optionalRows: rows, - footerText: nil) - } - - return ImmuTable(optionalSections: [backupStatusSection(), restoreStatusSection()] + activitiesSections) - // So far the only "extra" section is the restore one. In the future, this will include - // showing plugin updates/CTA's and other things like this. - } - - func dateRangeDescription() -> String? { - guard after != nil || before != nil else { - return NSLocalizedString("Date Range", comment: "Label of a button that displays a calendar") - } - - let format = shouldDisplayFullYear(with: after, and: before) ? "MMM d, yyyy" : "MMM d" - dateFormatter.setLocalizedDateFormatFromTemplate(format) - - var formattedDateRanges: [String] = [] - - if let after { - formattedDateRanges.append(dateFormatter.string(from: after)) - } - - if let before { - formattedDateRanges.append(dateFormatter.string(from: before)) - } - - return formattedDateRanges.joined(separator: " - ") - } - - func backupDownloadHeader() -> UIView? { - guard let validUntil = store.getBackupStatus(site: site)?.validUntil, - Date() < validUntil, - let backupPoint = store.getBackupStatus(site: site)?.backupPoint, - let downloadURLString = store.getBackupStatus(site: site)?.url, - let downloadURL = URL(string: downloadURLString), - let downloadID = store.getBackupStatus(site: site)?.downloadID else { - return nil - } - - let headingMessage = NSLocalizedString("We successfully created a backup of your site as of %@", comment: "Message displayed when a backup has finished") - downloadPromptView.setupHeading(String.init(format: headingMessage, arguments: [longDateFormatterWithTime.string(from: backupPoint)])) - - let downloadTitle = NSLocalizedString("Download", comment: "Download button title") - downloadPromptView.setupYesButton(title: downloadTitle) { _ in - UIApplication.shared.open(downloadURL) - } - - let dismissTitle = NSLocalizedString( - "activityList.dismiss.title", - value: "Dismiss", - comment: "Dismiss button title" - ) - downloadPromptView.setupNoButton(title: dismissTitle) { [weak self] button in - guard let self else { - return - } - - ActionDispatcher.dispatch(ActivityAction.dismissBackupNotice(site: self.site, downloadID: downloadID)) - } - - return downloadPromptView - } - - func activityTypeDescription() -> String? { - if selectedGroups.isEmpty { - return NSLocalizedString("Activity Type", comment: "Label for the Activity Type filter button") - } else if selectedGroups.count > 1 { - return String.localizedStringWithFormat(NSLocalizedString("Activity Type (%1$d)", comment: "Label for the Activity Type filter button when there are more than 1 activity type selected"), selectedGroups.count) - } - - return selectedGroups.first?.name - } - - private func shouldDisplayFullYear(with firstDate: Date?, and secondDate: Date?) -> Bool { - guard let firstDate, let secondDate else { - return false - } - - let currentYear = Calendar.current.dateComponents([.year], from: Date()).year - let firstYear = Calendar.current.dateComponents([.year], from: firstDate).year - let secondYear = Calendar.current.dateComponents([.year], from: secondDate).year - - return firstYear != currentYear || secondYear != currentYear - } - - private func isApplyingNewFilter(after: Date? = nil, before: Date? = nil, group: [ActivityGroup]) -> Bool { - let isSameGroup = group.count == self.selectedGroups.count && self.selectedGroups.elementsEqual(group, by: { $0.key == $1.key }) - - return isApplyingDateFilter(after: after, before: before) || !isSameGroup - } - - private func isApplyingDateFilter(after: Date? = nil, before: Date? = nil) -> Bool { - after != self.after || before != self.before - } - - private func backupStatusSection() -> ImmuTableSection? { - guard let backup = store.getBackupStatus(site: site), let backupProgress = backup.progress else { - return nil - } - - let title = NSLocalizedString("Backing up site", comment: "Title of the cell displaying status of a backup in progress") - let summary: String - let progress = max(Float(backupProgress) / 100, 0.05) - // We don't want to show a completely empty progress bar — it'd seem something is broken. 5% looks acceptable - // for the starting state. - - summary = NSLocalizedString("Creating downloadable backup", comment: "Description of the cell displaying status of a backup in progress") - - let rewindRow = RewindStatusRow( - title: title, - summary: summary, - progress: progress - ) - - return ImmuTableSection(headerText: NSLocalizedString("Backup", comment: "Title of section showing backup status"), - rows: [rewindRow], - footerText: nil) - } - - private func restoreStatusSection() -> ImmuTableSection? { - guard let restore = store.getCurrentRewindStatus(site: site)?.restore, restore.status == .running || restore.status == .queued else { - return nil - } - - let title = NSLocalizedString("Currently restoring your site", comment: "Title of the cell displaying status of a rewind in progress") - let summary: String - let progress = max(Float(restore.progress) / 100, 0.05) - // We don't want to show a completely empty progress bar — it'd seem something is broken. 5% looks acceptable - // for the starting state. - - if let rewindPoint = store.getActivity(site: site, rewindID: restore.id) { - let dateString = mediumDateFormatterWithTime.string(from: rewindPoint.published) - - let messageFormat = NSLocalizedString("Restoring to %@", - comment: "Text showing the point in time the site is being currently restored to. %@' is a placeholder that will expand to a date.") - - summary = String(format: messageFormat, dateString) - } else { - summary = "" - } - - let rewindRow = RewindStatusRow( - title: title, - summary: summary, - progress: progress - ) - - let headerText = NSLocalizedString("Restore", comment: "Title of section showing restore status") - - return ImmuTableSection(headerText: headerText, - rows: [rewindRow], - footerText: nil) - } - - private struct NoResultsText { - static let noActivitiesSubtitle = NSLocalizedString("When you make changes to your site you'll be able to see your activity history here.", comment: "Text display when the view when there aren't any Activities to display in the Activity Log") - static let errorTitle = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading Activity Log") - static let errorSubtitle = NSLocalizedString("There was an error loading activities", comment: "Text displayed when there is a failure loading the activity feed") - static let errorButtonText = NSLocalizedString("Contact support", comment: "Button label for contacting support") - static let noConnectionTitle = NSLocalizedString("No connection", comment: "Title for the error view when there's no connection") - static let noConnectionSubtitle = NSLocalizedString("An active internet connection is required to view activities", comment: "Error message shown when trying to view the Activity Log feature and there is no internet connection.") - static let noGroupsTitle = NSLocalizedString("No activities available", comment: "Title for the view when there aren't any Activities Types to display in the Activity Log Types picker") - static let noGroupsSubtitle = NSLocalizedString("No activities recorded in the selected date range.", comment: "Text display in the view when there aren't any Activities Types to display in the Activity Log Types picker") - static let groupsErrorButtonText = NSLocalizedString("Try again", comment: "Button label for trying to retrieve the activities type again") - } - - // MARK: - Date/Time handling - - lazy var longDateFormatterWithoutTime: DateFormatter = { - return ActivityDateFormatting.longDateFormatter(for: site, withTime: false) - }() - - lazy var longDateFormatterWithTime: DateFormatter = { - return ActivityDateFormatting.longDateFormatter(for: site, withTime: true) - }() - - lazy var mediumDateFormatterWithTime: DateFormatter = { - return ActivityDateFormatting.mediumDateFormatterWithTime(for: site) - }() - - lazy var dateFormatter: DateFormatter = { - DateFormatter() - }() -} - -extension ActivityGroup: @retroactive Equatable { - public static func == (lhs: ActivityGroup, rhs: ActivityGroup) -> Bool { - lhs.key == rhs.key - } -} From b8dd6745703fac748ccca00570571321256e7227 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 10:59:26 -0400 Subject: [PATCH 60/90] Remove BaseActivityListViewController --- .../BaseActivityListViewController.swift | 554 ------------------ 1 file changed, 554 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift diff --git a/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift b/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift deleted file mode 100644 index 6594c1f9a176..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift +++ /dev/null @@ -1,554 +0,0 @@ -import Foundation -import SVProgressHUD -import WordPressShared -import WordPressFlux -import WordPressUI - -struct ActivityListConfiguration { - /// An identifier of the View Controller - let identifier: String - - /// The title of the View Controller - let title: String - - /// The title for when loading activities - let loadingTitle: String - - /// Title for when there are no activities - let noActivitiesTitle: String - - /// Subtitle for when there are no activities - let noActivitiesSubtitle: String - - /// Title for when there are no activities for the selected filter - let noMatchingTitle: String - - /// Subtitle for when there are no activities for the selected filter - let noMatchingSubtitle: String - - /// Event to be fired when the date range button is tapped - let filterbarRangeButtonTapped: WPAnalyticsEvent - - /// Event to be fired when a date range is selected - let filterbarSelectRange: WPAnalyticsEvent - - /// Event to be fired when the range date reset button is tapped - let filterbarResetRange: WPAnalyticsEvent - - /// The number of items to be requested for each page - let numberOfItemsPerPage: Int -} - -/// ActivityListViewController is used as a base ViewController for -/// Jetpack's Activity Log and Backup -/// -class BaseActivityListViewController: UIViewController, TableViewContainer, ImmuTablePresenter { - let site: JetpackSiteRef - let store: ActivityStore - let configuration: ActivityListConfiguration - let isFreeWPCom: Bool - - var changeReceipt: Receipt? - var isUserTriggeredRefresh: Bool = false - - let containerStackView = UIStackView() - - let filterView = FilterBarView() - let dateFilterChip = FilterChipButton() - let activityTypeFilterChip = FilterChipButton() - - let tableView = UITableView(frame: .zero, style: .plain) - let refreshControl = UIRefreshControl() - - let numberOfItemsPerPage = 100 - - fileprivate lazy var handler: ImmuTableViewHandler = { - return ImmuTableViewHandler(takeOver: self, with: self) - }() - - private lazy var spinner: UIActivityIndicatorView = { - let spinner = UIActivityIndicatorView(style: .medium) - spinner.startAnimating() - spinner.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) - return spinner - }() - - var viewModel: ActivityListViewModel - private enum Constants { - static let estimatedRowHeight: CGFloat = 62 - } - - // MARK: - GUI - - fileprivate var noResultsViewController: NoResultsViewController? - - // MARK: - Constructors - - init(site: JetpackSiteRef, - store: ActivityStore, - isFreeWPCom: Bool = false) { - fatalError("A configuration struct needs to be provided") - } - - init(site: JetpackSiteRef, - store: ActivityStore, - configuration: ActivityListConfiguration, - isFreeWPCom: Bool = false) { - self.site = site - self.store = store - self.isFreeWPCom = isFreeWPCom - self.configuration = configuration - self.viewModel = ActivityListViewModel(site: site, store: store, configuration: configuration) - - super.init(nibName: nil, bundle: nil) - - self.changeReceipt = viewModel.onChange { [weak self] in - self?.refreshModel() - } - - view.backgroundColor = .systemBackground - view.addSubview(containerStackView) - containerStackView.axis = .vertical - - if site.shouldShowActivityLogFilter() { - setupFilterBar() - } - - containerStackView.addArrangedSubview(tableView) - - containerStackView.translatesAutoresizingMaskIntoConstraints = false - view.pinSubviewToSafeArea(containerStackView) - - tableView.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(userRefresh), for: .valueChanged) - - title = configuration.title - } - - @objc private func showCalendar() { - let calendarViewController = CalendarViewController(startDate: viewModel.after, endDate: viewModel.before) - calendarViewController.delegate = self - let navigationController = UINavigationController(rootViewController: calendarViewController) - navigationController.view.backgroundColor = .systemBackground - present(navigationController, animated: true, completion: nil) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - View lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - refreshModel() - - tableView.estimatedRowHeight = Constants.estimatedRowHeight - - tableView.register(ActivityListSectionHeaderView.defaultNib, forHeaderFooterViewReuseIdentifier: ActivityListSectionHeaderView.identifier) - ImmuTable.registerRows([ActivityListRow.self, RewindStatusRow.self], tableView: tableView) - - tableView.tableFooterView = spinner - tableView.tableFooterView?.isHidden = true - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - SVProgressHUD.dismiss() - } - - @objc func userRefresh() { - isUserTriggeredRefresh = true - viewModel.refresh(after: viewModel.after, before: viewModel.before, group: viewModel.selectedGroups) - } - - func refreshModel() { - updateHeader() - handler.viewModel = viewModel.tableViewModel(presenter: self) - updateRefreshControl() - updateNoResults() - updateFilters() - } - - private func updateHeader() { - tableView.tableHeaderView = viewModel.backupDownloadHeader() - - guard let tableHeaderView = tableView.tableHeaderView else { - return - } - - tableHeaderView.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - tableHeaderView.topAnchor.constraint(equalTo: tableView.topAnchor), - tableHeaderView.safeLeadingAnchor.constraint(equalTo: tableView.safeLeadingAnchor), - tableHeaderView.safeTrailingAnchor.constraint(equalTo: tableView.safeTrailingAnchor) - ]) - tableView.tableHeaderView?.layoutIfNeeded() - } - - private func updateRefreshControl() { - switch (viewModel.refreshing, refreshControl.isRefreshing) { - case (true, false): - if isUserTriggeredRefresh { - refreshControl.beginRefreshing() - isUserTriggeredRefresh = false - } else if tableView.numberOfSections > 0 { - tableView.tableFooterView?.isHidden = false - } - case (false, true): - refreshControl.endRefreshing() - default: - tableView.tableFooterView?.isHidden = true - break - } - } - - private func updateFilters() { - viewModel.dateFilterIsActive ? dateFilterChip.enableResetButton() : dateFilterChip.disableResetButton() - dateFilterChip.title = viewModel.dateRangeDescription() - - viewModel.groupFilterIsActive ? activityTypeFilterChip.enableResetButton() : activityTypeFilterChip.disableResetButton() - activityTypeFilterChip.title = viewModel.activityTypeDescription() - } - - private func setupFilterBar() { - containerStackView.addArrangedSubview(filterView) - - filterView.add(button: dateFilterChip) - filterView.add(button: activityTypeFilterChip) - - setupDateFilter() - setupActivityTypeFilter() - } - - private func setupDateFilter() { - dateFilterChip.resetButton.accessibilityLabel = NSLocalizedString("Reset Date Range filter", comment: "Accessibility label for the reset date range button") - - dateFilterChip.tapped = { [unowned self] in - WPAnalytics.track(self.configuration.filterbarRangeButtonTapped) - self.showCalendar() - } - - dateFilterChip.resetTapped = { [unowned self] in - WPAnalytics.track(self.configuration.filterbarResetRange) - self.viewModel.removeDateFilter() - self.dateFilterChip.disableResetButton() - } - } - - private func setupActivityTypeFilter() { - activityTypeFilterChip.resetButton.accessibilityLabel = NSLocalizedString("Reset Activity Type filter", comment: "Accessibility label for the reset activity type button") - - activityTypeFilterChip.tapped = { [weak self] in - guard let self else { - return - } - - WPAnalytics.track(.activitylogFilterbarTypeButtonTapped) - - let activityTypeSelectorViewController = ActivityTypeSelectorViewController( - viewModel: self.viewModel - ) - activityTypeSelectorViewController.delegate = self - let navigationController = UINavigationController(rootViewController: activityTypeSelectorViewController) - self.present(navigationController, animated: true, completion: nil) - } - - activityTypeFilterChip.resetTapped = { [weak self] in - WPAnalytics.track(.activitylogFilterbarResetType) - self?.viewModel.removeGroupFilter() - self?.activityTypeFilterChip.disableResetButton() - } - } - -} - -extension BaseActivityListViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - handler.tableView(tableView, numberOfRowsInSection: section) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - handler.tableView(tableView, cellForRowAt: indexPath) - } -} - -// MARK: - UITableViewDelegate - -extension BaseActivityListViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - let isLastSection = handler.viewModel.sections.count == section + 1 - - guard isFreeWPCom, isLastSection, let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: ActivityListSectionHeaderView.identifier) as? ActivityListSectionHeaderView else { - return nil - } - - cell.separator.isHidden = true - cell.titleLabel.text = NSLocalizedString("Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Text displayed as a footer of a table view with Activities when user is on a free plan") - cell.backgroundColorView.backgroundColor = .clear - - return cell - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - let isLastSection = handler.viewModel.sections.count == section + 1 - - guard isFreeWPCom, isLastSection else { - return 0.0 - } - - return UITableView.automaticDimension - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - 44 - } - - func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - guard let row = handler.viewModel.rowAtIndexPath(indexPath) as? ActivityListRow else { - return false - } - - return row.activity.isRewindable - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let offsetY = scrollView.contentOffset.y - let contentHeight = scrollView.contentSize.height - let shouldLoadMore = offsetY > contentHeight - (2 * scrollView.frame.size.height) && viewModel.hasMore - - if shouldLoadMore { - viewModel.loadMore() - } - } -} - -// MARK: - NoResultsViewControllerDelegate - -extension BaseActivityListViewController: NoResultsViewControllerDelegate { - func actionButtonPressed() { - let supportVC = SupportTableViewController() - supportVC.showFromTabBar() - } -} - -// MARK: - ActivityPresenter - -extension BaseActivityListViewController: ActivityPresenter { - - func presentDetailsFor(activity: FormattableActivity) { - let detailVC = ActivityDetailViewController.loadFromStoryboard() - - detailVC.site = site - detailVC.rewindStatus = store.state.rewindStatus[site] - detailVC.formattableActivity = activity - detailVC.presenter = self - - self.navigationController?.pushViewController(detailVC, animated: true) - } - - func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) { - let rewindStatus = store.state.rewindStatus[site] - - let title = rewindStatus?.isMultisite() == true ? RewindStatus.Strings.multisiteNotAvailable : nil - - let alertController = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) - - if rewindStatus?.state == .active { - let restoreTitle = NSLocalizedString("Restore", comment: "Title displayed for restore action.") - - let restoreOptionsVC = JetpackRestoreOptionsViewController(site: site, - activity: activity, - isAwaitingCredentials: store.isAwaitingCredentials(site: site)) - restoreOptionsVC.restoreStatusDelegate = self - restoreOptionsVC.presentedFrom = configuration.identifier - alertController.addDefaultActionWithTitle(restoreTitle, handler: { _ in - self.present(UINavigationController(rootViewController: restoreOptionsVC), animated: true) - }) - } - - let backupTitle = NSLocalizedString("Download backup", comment: "Title displayed for download backup action.") - let backupOptionsVC = JetpackBackupOptionsViewController(site: site, activity: activity) - backupOptionsVC.backupStatusDelegate = self - backupOptionsVC.presentedFrom = configuration.identifier - alertController.addDefaultActionWithTitle(backupTitle, handler: { _ in - self.present(UINavigationController(rootViewController: backupOptionsVC), animated: true) - }) - if let backupAction = alertController.actions.last { - backupAction.accessibilityIdentifier = "jetpack-download-backup-button" - } - - let cancelTitle = NSLocalizedString("Cancel", comment: "Title for cancel action. Dismisses the action sheet.") - alertController.addCancelActionWithTitle(cancelTitle) - - if let presentationController = alertController.popoverPresentationController { - presentationController.permittedArrowDirections = .any - presentationController.sourceView = sender - presentationController.sourceRect = sender.bounds - } - - self.present(alertController, animated: true, completion: nil) - } - - func presentRestoreFor(activity: Activity, from: String? = nil) { - guard activity.isRewindable, activity.rewindID != nil else { - return - } - - let restoreOptionsVC = JetpackRestoreOptionsViewController(site: site, - activity: activity, - isAwaitingCredentials: store.isAwaitingCredentials(site: site)) - - restoreOptionsVC.restoreStatusDelegate = self - restoreOptionsVC.presentedFrom = from ?? configuration.identifier - let navigationVC = UINavigationController(rootViewController: restoreOptionsVC) - self.present(navigationVC, animated: true) - } - - func presentBackupFor(activity: Activity, from: String? = nil) { - let backupOptionsVC = JetpackBackupOptionsViewController(site: site, activity: activity) - backupOptionsVC.backupStatusDelegate = self - backupOptionsVC.presentedFrom = from ?? configuration.identifier - let navigationVC = UINavigationController(rootViewController: backupOptionsVC) - self.present(navigationVC, animated: true) - } -} - -// MARK: - NoResults Handling - -private extension BaseActivityListViewController { - - func updateNoResults() { - if let noResultsViewModel = viewModel.noResultsViewModel() { - showNoResults(noResultsViewModel) - } else { - noResultsViewController?.view.isHidden = true - } - } - - func showNoResults(_ viewModel: NoResultsViewController.Model) { - if noResultsViewController == nil { - noResultsViewController = NoResultsViewController.controller() - noResultsViewController?.delegate = self - - guard let noResultsViewController else { - return - } - - if noResultsViewController.view.superview != tableView { - tableView.addSubview(withFadeAnimation: noResultsViewController.view) - } - - addChild(noResultsViewController) - - noResultsViewController.view.translatesAutoresizingMaskIntoConstraints = false - } - - noResultsViewController?.bindViewModel(viewModel) - noResultsViewController?.didMove(toParent: self) - tableView.pinSubviewToSafeArea(noResultsViewController!.view) - noResultsViewController?.view.isHidden = false - } - -} - -// MARK: - Restore Status Handling - -extension BaseActivityListViewController: JetpackRestoreStatusViewControllerDelegate { - - func didFinishViewing(_ controller: JetpackRestoreStatusViewController) { - controller.dismiss(animated: true, completion: { [weak self] in - guard let self else { - return - } - self.store.fetchRewindStatus(site: self.site) - }) - } -} - -// MARK: - Restore Status Handling - -extension BaseActivityListViewController: JetpackBackupStatusViewControllerDelegate { - - func didFinishViewing() { - viewModel.refresh() - } -} - -// MARK: - Calendar Handling -extension BaseActivityListViewController: CalendarViewControllerDelegate { - func didCancel(calendar: CalendarViewController) { - calendar.dismiss(animated: true, completion: nil) - } - - func didSelect(calendar: CalendarViewController, startDate: Date?, endDate: Date?) { - guard startDate != viewModel.after || endDate != viewModel.before else { - calendar.dismiss(animated: true, completion: nil) - return - } - - trackSelectedRange(startDate: startDate, endDate: endDate) - - viewModel.refresh(after: startDate, before: endDate, group: viewModel.selectedGroups) - calendar.dismiss(animated: true, completion: nil) - } - - private func trackSelectedRange(startDate: Date?, endDate: Date?) { - guard let startDate else { - if viewModel.after != nil || viewModel.before != nil { - WPAnalytics.track(configuration.filterbarResetRange) - } - - return - } - - var duration: Int // Number of selected days - var distance: Int // Distance from the startDate to today (in days) - - if let endDate { - duration = Int((endDate.timeIntervalSinceReferenceDate - startDate.timeIntervalSinceReferenceDate) / Double(24 * 60 * 60)) + 1 - } else { - duration = 1 - } - - distance = Int((Date().timeIntervalSinceReferenceDate - startDate.timeIntervalSinceReferenceDate) / Double(24 * 60 * 60)) - - WPAnalytics.track(configuration.filterbarSelectRange, properties: ["duration": duration, "distance": distance]) - } -} - -// MARK: - Activity type filter handling -extension BaseActivityListViewController: ActivityTypeSelectorDelegate { - func didCancel(selectorViewController: ActivityTypeSelectorViewController) { - selectorViewController.dismiss(animated: true, completion: nil) - } - - func didSelect(selectorViewController: ActivityTypeSelectorViewController, groups: [ActivityGroup]) { - guard groups != viewModel.selectedGroups else { - selectorViewController.dismiss(animated: true, completion: nil) - return - } - - trackSelectedGroups(groups) - - viewModel.refresh(after: viewModel.after, before: viewModel.before, group: groups) - selectorViewController.dismiss(animated: true, completion: nil) - } - - private func trackSelectedGroups(_ selectedGroups: [ActivityGroup]) { - if !viewModel.selectedGroups.isEmpty && selectedGroups.isEmpty { - WPAnalytics.track(.activitylogFilterbarResetType) - } else { - let totalActivitiesSelected = selectedGroups.map { $0.count }.reduce(0, +) - var selectTypeProperties: [AnyHashable: Any] = [:] - selectedGroups.forEach { selectTypeProperties["group_\($0.key)"] = true } - selectTypeProperties["num_groups_selected"] = selectedGroups.count - selectTypeProperties["num_total_activities_selected"] = totalActivitiesSelected - WPAnalytics.track(.activitylogFilterbarSelectType, properties: selectTypeProperties) - } - } -} From e8022ecbae12058701f9cbe6e9da97caf2f666d1 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:01:11 -0400 Subject: [PATCH 61/90] Fix compilation --- .../ViewRelated/Activity/List/BackupsViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift index f45e40570fa0..cc38b84e8d9c 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift @@ -3,7 +3,7 @@ import SwiftUI import WordPressUI import WordPressKit -final class BackupsViewController: UIHostingController { +final class BackupsViewController: UIHostingController { private let viewModel: ActivityLogsViewModel init(blog: Blog) { From 23aa07a7f07cd9792eb0b6e8a253d31dfe257c27 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:02:40 -0400 Subject: [PATCH 62/90] Remove RewindStatusRow --- .../Activity/RewindStatusRow.swift | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/RewindStatusRow.swift diff --git a/WordPress/Classes/ViewRelated/Activity/RewindStatusRow.swift b/WordPress/Classes/ViewRelated/Activity/RewindStatusRow.swift deleted file mode 100644 index 7d59e01b32dc..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/RewindStatusRow.swift +++ /dev/null @@ -1,25 +0,0 @@ -import UIKit - -struct RewindStatusRow: ImmuTableRow { - - typealias CellType = RewindStatusTableViewCell - - static let cell: ImmuTableCell = { - let nib = UINib(nibName: "RewindStatusTableViewCell", bundle: Bundle.keystone) - return ImmuTableCell.nib(nib, CellType.self) - }() - - let action: ImmuTableAction? = nil - - let title: String - let summary: String - let progress: Float - - func configureCell(_ cell: UITableViewCell) { - let cell = cell as! CellType - - cell.configureCell(title: title, summary: summary, progress: progress) - cell.selectionStyle = .none - } - -} From bb07b08f5fa2a4f1355912f3c49328c9764af353 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:03:33 -0400 Subject: [PATCH 63/90] Remove RewindStatusTableViewCell --- .../Activity/ActivityTableViewCell.swift | 39 ------ .../Activity/RewindStatusTableViewCell.xib | 115 ------------------ 2 files changed, 154 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift index 9a12e8d2eba7..58698d0553e0 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift @@ -91,42 +91,3 @@ open class ActivityTableViewCell: UITableViewCell, NibReusable { @IBOutlet fileprivate var actionButtonContainer: UIView! @IBOutlet fileprivate var actionButton: UIButton! } - -open class RewindStatusTableViewCell: ActivityTableViewCell { - - @IBOutlet private var progressView: UIProgressView! - - private(set) var title = "" - private(set) var summary = "" - private(set) var progress: Float = 0.0 - - open func configureCell(title: String, - summary: String, - progress: Float) { - configureFonts() - - self.title = title - self.summary = summary - self.progress = progress - - contentLabel.text = title - summaryLabel.text = summary - - iconBackgroundImageView.backgroundColor = UIAppColor.primary - iconImageView.image = UIImage.gridicon(.noticeOutline).imageWithTintColor(.white) - iconImageView.isHidden = false - actionButtonContainer.isHidden = true - - progressView.progressTintColor = UIAppColor.primary - progressView.trackTintColor = UIColor(light: (UIAppColor.primary(.shade5)), dark: (UIAppColor.primary(.shade80))) - progressView.setProgress(progress, animated: true) - } - - private func configureFonts() { - contentLabel.adjustsFontForContentSizeCategory = true - contentLabel.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) - - summaryLabel.adjustsFontForContentSizeCategory = true - summaryLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) - } -} diff --git a/WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib b/WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib deleted file mode 100644 index e6be3239eb5c..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From fe68785ae31edc09ac4c9249f2f450ecf560f865 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:04:13 -0400 Subject: [PATCH 64/90] Remove ActivityListRow --- .../Activity/ActivityListRow.swift | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift b/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift deleted file mode 100644 index bffd7e6b3f75..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift +++ /dev/null @@ -1,32 +0,0 @@ -import UIKit - -struct ActivityListRow: ImmuTableRow { - typealias CellType = ActivityTableViewCell - - static let cell: ImmuTableCell = { - return ImmuTableCell.nib(ActivityTableViewCell.defaultNib, CellType.self) - }() - - var activity: Activity { - return formattableActivity.activity - } - let action: ImmuTableAction? - let actionButtonHandler: (UIButton) -> Void - - private let formattableActivity: FormattableActivity - - init(formattableActivity: FormattableActivity, - action: ImmuTableAction?, - actionButtonHandler: @escaping (UIButton) -> Void) { - self.formattableActivity = formattableActivity - self.action = action - self.actionButtonHandler = actionButtonHandler - } - - func configureCell(_ cell: UITableViewCell) { - let cell = cell as! CellType - cell.configureCell(formattableActivity) - cell.selectionStyle = .none - cell.actionButtonHandler = actionButtonHandler - } -} From 43d84b6a396c7293d09e8cb43a6b6ba989224fc9 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:04:41 -0400 Subject: [PATCH 65/90] Remove ActivityTableViewCell --- .../Activity/ActivityTableViewCell.swift | 93 ------------- .../Activity/ActivityTableViewCell.xib | 127 ------------------ 2 files changed, 220 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift deleted file mode 100644 index 58698d0553e0..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift +++ /dev/null @@ -1,93 +0,0 @@ -import UIKit -import Gridicons -import WordPressShared -import WordPressUI - -open class ActivityTableViewCell: UITableViewCell, NibReusable { - - var actionButtonHandler: ((UIButton) -> Void)? - - // MARK: - Overwritten Methods - - open override func awakeFromNib() { - super.awakeFromNib() - assert(iconBackgroundImageView != nil) - assert(contentLabel != nil) - assert(summaryLabel != nil) - assert(actionButton != nil) - } - - // MARK: - Public Methods - - func configureCell(_ formattableActivity: FormattableActivity, displaysDate: Bool = false) { - activity = formattableActivity.activity - guard let activity else { - return - } - - configureFonts() - - dateLabel.isHidden = !displaysDate - bulletLabel.isHidden = !displaysDate - - summaryLabel.text = activity.summary - dateLabel.text = activity.published.toMediumString() - bulletLabel.text = "\u{2022}" - contentLabel.text = activity.text.isEmpty ? "–" : activity.text - - summaryLabel.textColor = .secondaryLabel - dateLabel.textColor = .secondaryLabel - bulletLabel.textColor = .secondaryLabel - contentLabel.textColor = .label - - iconBackgroundImageView.backgroundColor = activity.statusColor - if let iconImage = activity.icon { - iconImageView.image = iconImage.imageFlippedForRightToLeftLayoutDirection() - iconImageView.isHidden = false - } else { - iconImageView.isHidden = true - } - - contentView.backgroundColor = Style.backgroundColor() - actionButtonContainer.isHidden = !activity.isRewindable || displaysDate - actionButton.setImage(actionGridicon, for: .normal) - actionButton.tintColor = .secondaryLabel - actionButton.accessibilityIdentifier = "activity-cell-action-button" - - separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - } - - private func configureFonts() { - contentLabel.adjustsFontForContentSizeCategory = true - contentLabel.font = WPStyleGuide.fontForTextStyle(.callout, fontWeight: .medium) - - [summaryLabel, bulletLabel, dateLabel].forEach { - $0.adjustsFontForContentSizeCategory = true - $0.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) - } - } - - @IBAction func didTapActionButton(_ sender: UIButton) { - actionButtonHandler?(sender) - } - - typealias Style = WPStyleGuide.ActivityStyleGuide - - // MARK: - Private Properties - - fileprivate var activity: Activity? - fileprivate var actionGridicon: UIImage { - return UIImage.gridicon(.ellipsis) - } - - // MARK: - IBOutlets - - @IBOutlet fileprivate var iconBackgroundImageView: CircularImageView! - @IBOutlet fileprivate var iconImageView: UIImageView! - @IBOutlet fileprivate var contentLabel: UILabel! - @IBOutlet fileprivate var summaryLabel: UILabel! - @IBOutlet fileprivate var bulletLabel: UILabel! - @IBOutlet fileprivate var dateLabel: UILabel! - @IBOutlet fileprivate var actionButtonContainer: UIView! - @IBOutlet fileprivate var actionButton: UIButton! -} diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib deleted file mode 100644 index 5920a8ad5034..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 733f01adfd595ed0164b28a1d5236489d51bc78c Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:10:58 -0400 Subject: [PATCH 66/90] Remove CalendarViewController --- .../Posts/WeekdaysHeaderViewTests.swift | 31 -- .../Activity/CalendarViewController.swift | 285 ----------- .../Scheduling/CalendarCollectionView.swift | 460 ------------------ .../Post/Scheduling/CalendarMonthView.swift | 31 -- 4 files changed, 807 deletions(-) delete mode 100644 Tests/KeystoneTests/Tests/Features/Posts/WeekdaysHeaderViewTests.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/CalendarViewController.swift delete mode 100644 WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift delete mode 100644 WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift diff --git a/Tests/KeystoneTests/Tests/Features/Posts/WeekdaysHeaderViewTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/WeekdaysHeaderViewTests.swift deleted file mode 100644 index c953a0f7403d..000000000000 --- a/Tests/KeystoneTests/Tests/Features/Posts/WeekdaysHeaderViewTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import XCTest -@testable import WordPress - -class WeekdaysHeaderViewTests: XCTestCase { - - func testDisplayedWeekdaysMonday() { - // A calendar with Monday as first weekday - var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = 2 - - let headerView = WeekdaysHeaderView(calendar: calendar) - let firstWeekday = headerView.subviews.first as? UILabel - let lastWeekday = headerView.subviews.last as? UILabel - - XCTAssertEqual(firstWeekday?.text, "M", "Label should show Monday as first weekday") - XCTAssertEqual(lastWeekday?.text, "S", "Label should show Sunday as last weekday") - } - - func testDisplayedWeekdaysSunday() { - // A calendar with Monday as first weekday - var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = 1 - - let headerView = WeekdaysHeaderView(calendar: calendar) - let firstWeekday = headerView.subviews.first as? UILabel - let lastWeekday = headerView.subviews.last as? UILabel - - XCTAssertEqual(firstWeekday?.text, "S", "Label should show Sunday as first weekday") - XCTAssertEqual(lastWeekday?.text, "S", "Label should show Saturday as last weekday") - } -} diff --git a/WordPress/Classes/ViewRelated/Activity/CalendarViewController.swift b/WordPress/Classes/ViewRelated/Activity/CalendarViewController.swift deleted file mode 100644 index 4a69c729dec0..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/CalendarViewController.swift +++ /dev/null @@ -1,285 +0,0 @@ -import UIKit -import WordPressUI - -protocol CalendarViewControllerDelegate: AnyObject { - func didCancel(calendar: CalendarViewController) - func didSelect(calendar: CalendarViewController, startDate: Date?, endDate: Date?) -} - -class CalendarViewController: UIViewController { - - private var calendarCollectionView: CalendarCollectionView! - private var startDateLabel: UILabel! - private var separatorDateLabel: UILabel! - private var endDateLabel: UILabel! - private var header: UIStackView! - private let gradient = GradientView() - - private var startDate: Date? - private var endDate: Date? - - weak var delegate: CalendarViewControllerDelegate? - - private lazy var formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") - return formatter - }() - - private enum Constants { - static let headerPadding: CGFloat = 16 - static let endDateLabel = NSLocalizedString("End Date", comment: "Placeholder for the end date in calendar range selection") - static let startDateLabel = NSLocalizedString("Start Date", comment: "Placeholder for the start date in calendar range selection") - static let rangeSummaryAccessibilityLabel = NSLocalizedString( - "Selected range: %1$@ to %2$@", - comment: "Accessibility label for summary of currently selected range. %1$@ is the start date, %2$@ is " + - "the end date.") - static let singleDateRangeSummaryAccessibilityLabel = NSLocalizedString( - "Selected range: %1$@ only", - comment: "Accessibility label for summary of currently single date. %1$@ is the date") - static let noRangeSelectedAccessibilityLabelPlaceholder = NSLocalizedString( - "No date range selected", - comment: "Accessibility label for no currently selected range.") - } - - /// Creates a full screen year calendar controller - /// - /// - Parameters: - /// - startDate: An optional Date representing the first selected date - /// - endDate: An optional Date representing the end selected date - init(startDate: Date? = nil, endDate: Date? = nil) { - self.startDate = startDate - self.endDate = endDate - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - override func viewDidLoad() { - super.viewDidLoad() - - title = NSLocalizedString("Choose date range", comment: "Title to choose date range in a calendar") - - // Configure Calendar - let calendar = Calendar.current - self.calendarCollectionView = CalendarCollectionView( - calendar: calendar, - style: .year, - startDate: startDate, - endDate: endDate - ) - - // Configure headers and add the calendar to the view - configureHeader() - let stackView = UIStackView(arrangedSubviews: [ - header, - calendarCollectionView - ]) - stackView.axis = .vertical - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.setCustomSpacing(Constants.headerPadding, after: header) - view.addSubview(stackView) - view.pinSubviewToAllEdges(stackView, insets: UIEdgeInsets(top: Constants.headerPadding, left: 0, bottom: 0, right: 0)) - view.backgroundColor = .systemBackground - edgesForExtendedLayout = [] - - setupNavButtons() - - setUpGradient() - - calendarCollectionView.calDataSource.didSelect = { [weak self] startDate, endDate in - self?.updateDates(startDate: startDate, endDate: endDate) - } - - calendarCollectionView.scrollsToTop = false - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - scrollToVisibleDate() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - coordinator.animate(alongsideTransition: { _ in - self.calendarCollectionView.reloadData(withAnchor: self.startDate ?? Date(), completionHandler: nil) - }, completion: nil) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - setUpGradientColors() - } - - private func setupNavButtons() { - let doneButton = UIBarButtonItem(title: NSLocalizedString("Done", comment: "Label for Done button"), style: .done, target: self, action: #selector(done)) - navigationItem.setRightBarButton(doneButton, animated: false) - - navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)), animated: false) - } - - private func updateDates(startDate: Date?, endDate: Date?) { - self.startDate = startDate - self.endDate = endDate - - updateLabels() - } - - private func updateLabels() { - guard let startDate else { - resetLabels() - return - } - - startDateLabel.text = formatter.string(from: startDate) - startDateLabel.textColor = .label - startDateLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) - - if let endDate { - endDateLabel.text = formatter.string(from: endDate) - endDateLabel.textColor = .label - endDateLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) - separatorDateLabel.textColor = .label - separatorDateLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) - } else { - endDateLabel.text = Constants.endDateLabel - endDateLabel.font = WPStyleGuide.fontForTextStyle(.title3) - endDateLabel.textColor = .secondaryLabel - separatorDateLabel.textColor = .secondaryLabel - } - - header.accessibilityLabel = accessibilityLabelForRangeSummary(startDate: startDate, endDate: endDate) - } - - private func configureHeader() { - header = startEndDateHeader() - resetLabels() - } - - private func startEndDateHeader() -> UIStackView { - let header = UIStackView(frame: .zero) - header.distribution = .fill - - let startDate = UILabel() - startDate.isAccessibilityElement = false - startDateLabel = startDate - startDate.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) - if view.effectiveUserInterfaceLayoutDirection == .leftToRight { - // swiftlint:disable:next inverse_text_alignment - startDate.textAlignment = .right - } else { - // swiftlint:disable:next natural_text_alignment - startDate.textAlignment = .left - } - header.addArrangedSubview(startDate) - startDate.widthAnchor.constraint(equalTo: header.widthAnchor, multiplier: 0.47).isActive = true - - let separator = UILabel() - separator.isAccessibilityElement = false - separatorDateLabel = separator - separator.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) - separator.textAlignment = .center - header.addArrangedSubview(separator) - separator.widthAnchor.constraint(equalTo: header.widthAnchor, multiplier: 0.06).isActive = true - - let endDate = UILabel() - endDate.isAccessibilityElement = false - endDateLabel = endDate - endDate.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) - if view.effectiveUserInterfaceLayoutDirection == .leftToRight { - // swiftlint:disable:next natural_text_alignment - endDate.textAlignment = .left - } else { - // swiftlint:disable:next inverse_text_alignment - endDate.textAlignment = .right - } - header.addArrangedSubview(endDate) - endDate.widthAnchor.constraint(equalTo: header.widthAnchor, multiplier: 0.47).isActive = true - - header.isAccessibilityElement = true - header.accessibilityTraits = [.header, .summaryElement] - - return header - } - - private func scrollToVisibleDate() { - if calendarCollectionView.frame.height == 0 { - calendarCollectionView.superview?.layoutIfNeeded() - } - - if let startDate { - calendarCollectionView.scrollToDate(startDate, - animateScroll: true, - preferredScrollPosition: .centeredVertically, - extraAddedOffset: -(self.calendarCollectionView.frame.height / 2)) - } else { - calendarCollectionView.setContentOffset(CGPoint( - x: 0, - y: calendarCollectionView.contentSize.height - calendarCollectionView.frame.size.height - ), animated: false) - } - - } - - private func resetLabels() { - startDateLabel.text = Constants.startDateLabel - - separatorDateLabel.text = "-" - - endDateLabel.text = Constants.endDateLabel - - [startDateLabel, separatorDateLabel, endDateLabel].forEach { label in - label?.textColor = .secondaryLabel - label?.font = WPStyleGuide.fontForTextStyle(.title3) - } - - header.accessibilityLabel = accessibilityLabelForRangeSummary(startDate: nil, endDate: nil) - } - - private func accessibilityLabelForRangeSummary(startDate: Date?, endDate: Date?) -> String { - switch (startDate, endDate) { - case (nil, _): - return Constants.noRangeSelectedAccessibilityLabelPlaceholder - case (.some(let startDate), nil): - let startDateString = formatter.string(from: startDate) - return String.localizedStringWithFormat(Constants.singleDateRangeSummaryAccessibilityLabel, startDateString) - case (.some(let startDate), .some(let endDate)): - let startDateString = formatter.string(from: startDate) - let endDateString = formatter.string(from: endDate) - return String.localizedStringWithFormat(Constants.rangeSummaryAccessibilityLabel, startDateString, endDateString) - } - } - - private func setUpGradient() { - gradient.isUserInteractionEnabled = false - gradient.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(gradient) - - NSLayoutConstraint.activate([ - gradient.heightAnchor.constraint(equalToConstant: 50), - gradient.topAnchor.constraint(equalTo: calendarCollectionView.topAnchor), - gradient.leadingAnchor.constraint(equalTo: calendarCollectionView.leadingAnchor), - gradient.trailingAnchor.constraint(equalTo: calendarCollectionView.trailingAnchor) - ]) - - setUpGradientColors() - } - - private func setUpGradientColors() { - gradient.fromColor = .systemBackground - gradient.toColor = UIColor.systemBackground.withAlphaComponent(0) - } - - @objc private func done() { - delegate?.didSelect(calendar: self, startDate: startDate, endDate: endDate) - } - - @objc private func cancel() { - delegate?.didCancel(calendar: self) - } -} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift deleted file mode 100644 index 0481d02df06f..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift +++ /dev/null @@ -1,460 +0,0 @@ -import UIKit -import JTAppleCalendar -import WordPressUI - -enum CalendarCollectionViewStyle { - case month - case year -} - -class CalendarCollectionView: WPJTACMonthView { - - let calDataSource: CalendarDataSource - let style: CalendarCollectionViewStyle - - init(calendar: Calendar, - style: CalendarCollectionViewStyle = .month, - startDate: Date? = nil, - endDate: Date? = nil) { - calDataSource = CalendarDataSource( - calendar: calendar, - style: style, - startDate: startDate, - endDate: endDate - ) - - self.style = style - super.init() - - setup() - } - - required init?(coder aDecoder: NSCoder) { - calDataSource = CalendarDataSource(calendar: Calendar.current, style: .month) - style = .month - super.init(coder: aDecoder) - - setup() - } - - private func setup() { - register(DateCell.self, forCellWithReuseIdentifier: DateCell.Constants.reuseIdentifier) - register(CalendarYearHeaderView.self, - forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, - withReuseIdentifier: CalendarYearHeaderView.reuseIdentifier) - - backgroundColor = .clear - - switch style { - case .month: - scrollDirection = .horizontal - scrollingMode = .stopAtEachCalendarFrame - case .year: - scrollDirection = .vertical - - allowsMultipleSelection = true - allowsRangedSelection = true - rangeSelectionMode = .continuous - - minimumLineSpacing = 0 - minimumInteritemSpacing = 0 - - cellSize = 50 - } - - showsHorizontalScrollIndicator = false - isDirectionalLockEnabled = true - - calendarDataSource = calDataSource - calendarDelegate = calDataSource - } - - /// VoiceOver scrollback workaround - /// When using VoiceOver, moving focus from the surrounding elements (usually the next month button) to the calendar DateCells, a - /// scrollback to 0 was triggered by the system. This appears to be expected (though irritating) behaviour with a paging UICollectionView. - /// The impact of this scrollback for the month view calendar (as used to schedule a post) is that the calendar jumps to 1951-01-01, with - /// the only way to navigate forwards being to tap the "next month" button repeatedly. - /// Ignoring these scrolls back to 0 when VoiceOver is in use prevents this issue, while not impacting other use of the calendar. - /// Similar behaviour sometimes occurs with the non-paging year view calendar (as used for activity log filtering) which is harder to reproduce, - /// but also remedied by this change. - override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) { - if shouldPreventAccessibilityFocusScrollback(for: contentOffset) { - return - } - super.setContentOffset(contentOffset, animated: animated) - } - - func shouldPreventAccessibilityFocusScrollback(for newContentOffset: CGPoint) -> Bool { - if UIAccessibility.isVoiceOverRunning { - switch style { - case .month: - return newContentOffset.x == 0 && contentOffset.x > 0 - case .year: - return newContentOffset.y == 0 && contentOffset.y > 0 - } - } - return false - } -} - -class CalendarDataSource: JTACMonthViewDataSource { - - var willScroll: ((DateSegmentInfo) -> Void)? - var didScroll: ((DateSegmentInfo) -> Void)? - var didSelect: ((Date?, Date?) -> Void)? - - // First selected date - var firstDate: Date? - - // End selected date - var endDate: Date? - - private let calendar: Calendar - private let style: CalendarCollectionViewStyle - - init(calendar: Calendar, - style: CalendarCollectionViewStyle, - startDate: Date? = nil, - endDate: Date? = nil) { - self.calendar = calendar - self.style = style - self.firstDate = startDate - self.endDate = endDate - } - - func configureCalendar(_ calendar: JTACMonthView) -> ConfigurationParameters { - /// When style is year, display the last 20 years til this month - if style == .year { - var dateComponent = DateComponents() - dateComponent.year = -20 - let startDate = Calendar.current.date(byAdding: dateComponent, to: Date()) - let endDate = Date().endOfMonth - - if let startDate, let endDate { - return ConfigurationParameters(startDate: startDate, endDate: endDate, calendar: self.calendar) - } - } - - let startDate = Date.farPastDate - let endDate = Date.farFutureDate - return ConfigurationParameters(startDate: startDate, endDate: endDate, calendar: self.calendar) - } -} - -extension CalendarDataSource: JTACMonthViewDelegate { - func calendar(_ calendar: JTACMonthView, cellForItemAt date: Date, cellState: CellState, indexPath: IndexPath) -> JTACDayCell { - let cell = calendar.dequeueReusableJTAppleCell(withReuseIdentifier: DateCell.Constants.reuseIdentifier, for: indexPath) - if let dateCell = cell as? DateCell { - configure(cell: dateCell, with: cellState) - } - return cell - } - - func calendar(_ calendar: JTACMonthView, willDisplay cell: JTACDayCell, forItemAt date: Date, cellState: CellState, indexPath: IndexPath) { - configure(cell: cell, with: cellState) - } - - func calendar(_ calendar: JTACMonthView, willScrollToDateSegmentWith visibleDates: DateSegmentInfo) { - willScroll?(visibleDates) - } - - func calendar(_ calendar: JTACMonthView, didScrollToDateSegmentWith visibleDates: DateSegmentInfo) { - didScroll?(visibleDates) - } - - func calendar(_ calendar: JTACMonthView, didSelectDate date: Date, cell: JTACDayCell?, cellState: CellState, indexPath: IndexPath) { - if style == .year { - // If the date is in the future, bail out - if date > Date() { - return - } - - if let firstDate { - if let endDate { - // When tapping a selected firstDate or endDate reset the rest - if date == firstDate || date == endDate { - self.firstDate = date - self.endDate = nil - // Increase the range at the left side - } else if date < firstDate { - self.firstDate = date - // Increase the range at the right side - } else { - self.endDate = date - } - // When tapping a single selected date, deselect everything - } else if date == firstDate { - self.firstDate = nil - self.endDate = nil - // When selecting a second date - } else { - self.firstDate = min(firstDate, date) - endDate = max(firstDate, date) - } - // When selecting the first date - } else { - firstDate = date - } - // Monthly calendar only selects a single date - } else { - firstDate = date - } - - didSelect?(firstDate, endDate) - UIView.performWithoutAnimation { - calendar.reloadItems(at: calendar.indexPathsForVisibleItems) - } - - configure(cell: cell, with: cellState) - } - - func calendar(_ calendar: JTACMonthView, didDeselectDate date: Date, cell: JTACDayCell?, cellState: CellState, indexPath: IndexPath) { - configure(cell: cell, with: cellState) - } - - func calendarSizeForMonths(_ calendar: JTACMonthView?) -> MonthSize? { - return style == .year ? MonthSize(defaultSize: 50) : nil - } - - func calendar(_ calendar: JTACMonthView, headerViewForDateRange range: (start: Date, end: Date), at indexPath: IndexPath) -> JTACMonthReusableView { - let date = range.start - let formatter = DateFormatter() - formatter.dateFormat = "MMMM yyyy" - let header = calendar.dequeueReusableJTAppleSupplementaryView(withReuseIdentifier: CalendarYearHeaderView.reuseIdentifier, for: indexPath) - (header as! CalendarYearHeaderView).titleLabel.text = formatter.string(from: date) - return header - } - - private func configure(cell: JTACDayCell?, with state: CellState) { - let cell = cell as? DateCell - cell?.configure(with: state, startDate: firstDate, endDate: endDate, hideInOutDates: style == .year) - } -} - -class DateCell: JTACDayCell { - - struct Constants { - static let labelSize: CGFloat = 28 - static let reuseIdentifier = "dateCell" - static var selectedColor: UIColor { - UIColor(light: UIAppColor.primary(.shade5), dark: UIAppColor.primary(.shade90)) - } - } - - let dateLabel = UILabel() - let leftPlaceholder = UIView() - let rightPlaceholder = UIView() - - let dateFormatter = DateFormatter() - - override init(frame: CGRect) { - super.init(frame: frame) - - setup() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setup() { - dateLabel.translatesAutoresizingMaskIntoConstraints = false - dateLabel.textAlignment = .center - dateLabel.font = UIFont.preferredFont(forTextStyle: .callout) - - // Show circle behind text for selected day - dateLabel.clipsToBounds = true - dateLabel.layer.cornerRadius = Constants.labelSize / 2 - - addSubview(dateLabel) - - NSLayoutConstraint.activate([ - dateLabel.widthAnchor.constraint(equalToConstant: Constants.labelSize), - dateLabel.heightAnchor.constraint(equalTo: dateLabel.widthAnchor), - dateLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - dateLabel.centerXAnchor.constraint(equalTo: centerXAnchor) - ]) - - leftPlaceholder.translatesAutoresizingMaskIntoConstraints = false - rightPlaceholder.translatesAutoresizingMaskIntoConstraints = false - - addSubview(leftPlaceholder) - addSubview(rightPlaceholder) - - NSLayoutConstraint.activate([ - leftPlaceholder.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.6), - leftPlaceholder.heightAnchor.constraint(equalTo: dateLabel.heightAnchor), - leftPlaceholder.trailingAnchor.constraint(equalTo: centerXAnchor), - leftPlaceholder.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - - NSLayoutConstraint.activate([ - rightPlaceholder.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5), - rightPlaceholder.heightAnchor.constraint(equalTo: dateLabel.heightAnchor), - rightPlaceholder.leadingAnchor.constraint(equalTo: centerXAnchor, constant: 0), - rightPlaceholder.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - - bringSubviewToFront(dateLabel) - } -} - -extension DateCell { - /// Configure the DateCell - /// - /// - Parameters: - /// - state: the representation of the cell state - /// - startDate: the first Date selected - /// - endDate: the last Date selected - /// - hideInOutDates: a Bool to hide/display dates outside of the current month (filling the entire row) - /// - Returns: UIColor. Red in cases of error - func configure(with state: CellState, - startDate: Date? = nil, - endDate: Date? = nil, - hideInOutDates: Bool = false) { - - dateLabel.text = state.text - - dateFormatter.setLocalizedDateFormatFromTemplate("EEE MMM d, yyyy") - dateLabel.accessibilityLabel = dateFormatter.string(from: state.date) - dateLabel.accessibilityTraits = .button - - var textColor: UIColor - - if hideInOutDates && state.dateBelongsTo != .thisMonth { - isHidden = true - } else { - isHidden = false - } - - // Reset state - leftPlaceholder.backgroundColor = .clear - rightPlaceholder.backgroundColor = .clear - dateLabel.backgroundColor = .clear - textColor = .label - dateLabel.accessibilityTraits = .button - if state.isSelected { - dateLabel.accessibilityTraits.insert(.selected) - } - - switch position(for: state.date, startDate: startDate, endDate: endDate) { - case .middle: - textColor = .label - leftPlaceholder.backgroundColor = Constants.selectedColor - rightPlaceholder.backgroundColor = Constants.selectedColor - dateLabel.backgroundColor = .clear - case .left: - textColor = .white - dateLabel.backgroundColor = UIAppColor.primary - rightPlaceholder.backgroundColor = Constants.selectedColor - case .right: - textColor = .white - dateLabel.backgroundColor = UIAppColor.primary - leftPlaceholder.backgroundColor = Constants.selectedColor - case .full: - textColor = .invertedLabel - leftPlaceholder.backgroundColor = .clear - rightPlaceholder.backgroundColor = .clear - dateLabel.backgroundColor = UIAppColor.primary - case .none: - leftPlaceholder.backgroundColor = .clear - rightPlaceholder.backgroundColor = .clear - dateLabel.backgroundColor = .clear - if state.date > Date() { - textColor = .secondaryLabel - } else if state.dateBelongsTo == .thisMonth { - textColor = .label - } else { - textColor = .secondaryLabel - } - } - - dateLabel.textColor = textColor - } - - func position(for date: Date, startDate: Date?, endDate: Date?) -> SelectionRangePosition { - if let startDate, let endDate { - if date == startDate { - return .left - } else if date == endDate { - return .right - } else if date > startDate && date < endDate { - return .middle - } - } else if let startDate { - if date == startDate { - return .full - } - } - - return .none - } -} - -// MARK: - Year Header View -class CalendarYearHeaderView: JTACMonthReusableView { - static let reuseIdentifier = "CalendarYearHeaderView" - - let titleLabel: UILabel = UILabel() - - override init(frame: CGRect) { - super.init(frame: frame) - let stackView = UIStackView() - stackView.axis = .vertical - stackView.spacing = Constants.stackViewSpacing - - addSubview(stackView) - stackView.translatesAutoresizingMaskIntoConstraints = false - pinSubviewToSafeArea(stackView) - - stackView.addArrangedSubview(titleLabel) - titleLabel.font = .preferredFont(forTextStyle: .headline) - titleLabel.textAlignment = .center - titleLabel.textColor = Constants.titleColor - titleLabel.accessibilityTraits = .header - - let weekdaysView = WeekdaysHeaderView(calendar: Calendar.current) - stackView.addArrangedSubview(weekdaysView) - - stackView.setCustomSpacing(Constants.spacingAfterWeekdays, after: weekdaysView) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private enum Constants { - static let stackViewSpacing: CGFloat = 16 - static let spacingAfterWeekdays: CGFloat = 8 - static let titleColor = UIColor(light: UIAppColor.gray(.shade70), dark: .secondaryLabel) - } -} - -extension Date { - var startOfMonth: Date? { - return Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Calendar.current.startOfDay(for: self))) - } - - var endOfMonth: Date? { - guard let startOfMonth else { - return nil - } - - return Calendar.current.date(byAdding: DateComponents(month: 1, day: -1), to: startOfMonth) - } -} - -class WPJTACMonthView: JTACMonthView { - - // Avoids content to scroll above/below the maximum/minimum size - override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) { - let maxY = contentSize.height - frame.size.height - if contentOffset.y > maxY { - super.setContentOffset(CGPoint(x: contentOffset.x, y: maxY), animated: animated) - } else if contentOffset.y < 0 { - super.setContentOffset(CGPoint(x: contentOffset.x, y: 0), animated: animated) - } else { - super.setContentOffset(contentOffset, animated: animated) - } - } -} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift deleted file mode 100644 index f89932cee816..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift +++ /dev/null @@ -1,31 +0,0 @@ -import UIKit -import WordPressUI - -/// A view containing weekday symbols horizontally aligned for use in a calendar header -class WeekdaysHeaderView: UIStackView { - convenience init(calendar: Calendar) { - /// Adjust the weekday symbols array so that the first week day matches - let weekdaySymbols = calendar.veryShortWeekdaySymbols.rotateLeft(calendar.firstWeekday - 1) - self.init(arrangedSubviews: weekdaySymbols.map({ symbol in - let label = UILabel() - label.text = symbol - label.textAlignment = .center - label.font = UIFont.preferredFont(forTextStyle: .caption1) - label.textColor = UIAppColor.neutral(.shade30) - label.isAccessibilityElement = false - return label - })) - self.distribution = .fillEqually - } -} - -extension Collection { - /// Rotates the array to the left ([1,2,3,4] -> [2,3,4,1]) - /// - Parameter offset: The offset by which to shift the array. - func rotateLeft(_ offset: Int) -> [Self.Element] { - let initialDigits = (abs(offset) % self.count) - let elementToPutAtEnd = Array(self[startIndex.. Date: Fri, 20 Jun 2025 11:11:55 -0400 Subject: [PATCH 67/90] Remove JTAppleCalendar dependency --- Modules/Package.resolved | 11 +---------- Modules/Package.swift | 2 -- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/Modules/Package.resolved b/Modules/Package.resolved index a4b19f453615..e22d45bf0323 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fa035de2741e39c23eaba17409869db3ebef935eb88585d1f08d5dc2497e6d7d", + "originHash" : "8c8d98aed7eab1f454a6d3f34ad7f1d90ae38d5b0ca1d5e9ce24c7675adf295c", "pins" : [ { "identity" : "alamofire", @@ -153,15 +153,6 @@ "version" : "0.3.0" } }, - { - "identity" : "jtapplecalendar", - "kind" : "remoteSourceControl", - "location" : "https://github.com/patchthecode/JTAppleCalendar", - "state" : { - "revision" : "718f0ab68ba0fcd2bc134f6e9d30edc1b9b038e1", - "version" : "8.0.5" - } - }, { "identity" : "lottie-ios", "kind" : "remoteSourceControl", diff --git a/Modules/Package.swift b/Modules/Package.swift index 7e40bab002a0..80a28b4f912c 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -37,7 +37,6 @@ let package = Package( .package(url: "https://github.com/erikdoe/ocmock", revision: "2c0bfd373289f4a7716db5d6db471640f91a6507"), .package(url: "https://github.com/johnxnguyen/Down", branch: "master"), .package(url: "https://github.com/kaishin/Gifu", from: "3.4.1"), - .package(url: "https://github.com/patchthecode/JTAppleCalendar", from: "8.0.5"), .package(url: "https://github.com/Quick/Nimble", from: "10.0.0"), .package(url: "https://github.com/scinfu/SwiftSoup", exact: "2.7.5"), .package(url: "https://github.com/squarefrog/UIDeviceIdentifier", from: "2.3.0"), @@ -300,7 +299,6 @@ enum XcodeSupport { .product(name: "GravatarUI", package: "Gravatar-SDK-iOS"), .product(name: "Gridicons", package: "Gridicons-iOS"), .product(name: "GutenbergKit", package: "GutenbergKit"), - .product(name: "JTAppleCalendar", package: "JTAppleCalendar"), .product(name: "Lottie", package: "lottie-ios"), .product(name: "MediaEditor", package: "MediaEditor-iOS"), .product(name: "NSObject-SafeExpectations", package: "NSObject-SafeExpectations"), From 0ceeaa117d4858794343b731106d614545a02c86 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:24:03 -0400 Subject: [PATCH 68/90] Remove FilterBarView --- .../Activity/Filter/FilterBarView.swift | 73 ------------------- 1 file changed, 73 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/Filter/FilterBarView.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Filter/FilterBarView.swift b/WordPress/Classes/ViewRelated/Activity/Filter/FilterBarView.swift deleted file mode 100644 index 8c3b7f8d6b7d..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Filter/FilterBarView.swift +++ /dev/null @@ -1,73 +0,0 @@ -import UIKit -import WordPressShared - -class FilterBarView: UIScrollView { - let filterStackView = UIStackView() - - override init(frame: CGRect) { - super.init(frame: frame) - - filterStackView.alignment = .center - filterStackView.spacing = Constants.filterStackViewSpacing - filterStackView.translatesAutoresizingMaskIntoConstraints = false - - let filterIcon = UIImageView(image: UIImage.gridicon(.filter)) - filterIcon.tintColor = .secondaryLabel - filterIcon.heightAnchor.constraint(equalToConstant: Constants.filterHeightAnchor).isActive = true - - filterStackView.addArrangedSubview(filterIcon) - - canCancelContentTouches = true - showsHorizontalScrollIndicator = false - addSubview(filterStackView) - - NSLayoutConstraint.activate([ - filterStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.filterBarHorizontalPadding), - filterStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -1 * Constants.filterBarHorizontalPadding), - filterStackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.filterBarVerticalPadding), - filterStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constants.filterBarVerticalPadding), - heightAnchor.constraint(equalTo: filterStackView.heightAnchor, constant: 2 * Constants.filterBarVerticalPadding) - ]) - - // Ensure that the stackview is right aligned in RTL layouts - if userInterfaceLayoutDirection() == .rightToLeft { - transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) - filterStackView.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) - } - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - - guard let superview else { - return - } - - let separator = UIView() - separator.translatesAutoresizingMaskIntoConstraints = false - superview.addSubview(separator) - NSLayoutConstraint.activate([ - separator.bottomAnchor.constraint(equalTo: bottomAnchor), - separator.trailingAnchor.constraint(equalTo: superview.trailingAnchor), - separator.leadingAnchor.constraint(equalTo: superview.leadingAnchor), - separator.heightAnchor.constraint(equalToConstant: 1) - ]) - WPStyleGuide.applyBorderStyle(separator) - separator.layer.zPosition = 10 - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - func add(button chip: FilterChipButton) { - filterStackView.addArrangedSubview(chip) - } - - private enum Constants { - static let filterHeightAnchor: CGFloat = 24 - static let filterStackViewSpacing: CGFloat = 8 - static let filterBarHorizontalPadding: CGFloat = 16 - static let filterBarVerticalPadding: CGFloat = 8 - } -} From 94809f00fcae0af08eb767cc02bc6540a779fe3d Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:24:40 -0400 Subject: [PATCH 69/90] Remove FilterChipButton --- .../Activity/Filter/FilterChipButton.swift | 102 ------------------ 1 file changed, 102 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/Filter/FilterChipButton.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Filter/FilterChipButton.swift b/WordPress/Classes/ViewRelated/Activity/Filter/FilterChipButton.swift deleted file mode 100644 index cf6ac1a72435..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Filter/FilterChipButton.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import WordPressShared - -/// A button that represents a filter chip -/// -class FilterChipButton: UIView { - /// The title of the button - var title: String? { - didSet { - mainButton.setTitle(title, for: .normal) - } - } - - let mainButton = UIButton(type: .system) - let resetButton = UIButton(type: .system) - - /// Callback called when the button is tapped - var tapped: (() -> Void)? - - /// Callback called when the reset ("X") is tapped - var resetTapped: (() -> Void)? - - override init(frame: CGRect) { - super.init(frame: frame) - - let stackView = UIStackView() - stackView.axis = .horizontal - - stackView.addArrangedSubview(mainButton) - - resetButton.widthAnchor.constraint(greaterThanOrEqualToConstant: Constants.minResetButtonWidth).isActive = true - resetButton.imageEdgeInsets = Constants.resetImageInsets - resetButton.isHidden = true - stackView.addArrangedSubview(resetButton) - - mainButton.addTarget(self, action: #selector(mainButtonTapped), for: .touchUpInside) - resetButton.addTarget(self, action: #selector(resetButtonTapped), for: .touchUpInside) - - addSubview(stackView) - pinSubviewToAllEdges(stackView) - stackView.translatesAutoresizingMaskIntoConstraints = false - - layer.borderWidth = Constants.borderWidth - layer.cornerRadius = Constants.cornerRadius - - mainButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.callout) - mainButton.setTitleColor(.label, for: .normal) - mainButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.minButtonHeight).isActive = true - mainButton.contentEdgeInsets = Constants.buttonContentInset - - applyColors() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - /// Enables the reset button - func enableResetButton() { - resetButton.isHidden = false - mainButton.contentEdgeInsets = Constants.buttonContentInsetWithResetEnabled - } - - /// Disables the reset button - func disableResetButton() { - resetButton.isHidden = true - mainButton.contentEdgeInsets = Constants.buttonContentInset - UIAccessibility.post(notification: .layoutChanged, argument: mainButton) - } - - @objc private func mainButtonTapped() { - tapped?() - } - - @objc private func resetButtonTapped() { - resetTapped?() - } - - private func applyColors() { - layer.borderColor = UIColor.quaternaryLabel.cgColor - resetButton.setImage(UIImage.gridicon(.crossCircle), for: .normal) - resetButton.tintColor = .secondaryLabel - } - - private enum Constants { - static let minResetButtonWidth: CGFloat = 32 - static let resetImageInsets = UIEdgeInsets(top: 8, left: 6, bottom: 8, right: 10).flippedForRightToLeft - static let borderWidth: CGFloat = 1 - static let cornerRadius: CGFloat = 16 - static let minButtonHeight: CGFloat = 32 - static let buttonContentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12).flippedForRightToLeft - static let buttonContentInsetWithResetEnabled = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 0).flippedForRightToLeft - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { - applyColors() - } - } -} From 4e976a9aa71e84b3d79ef6147e5b524ed2eede62 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:28:43 -0400 Subject: [PATCH 70/90] Remove ActivityDetailViewController --- .../ActivityDetailViewController.storyboard | 221 ------------ .../ActivityDetailViewController.swift | 320 ------------------ .../Activity/RewindStatus+multiSite.swift | 18 - 3 files changed, 559 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.storyboard delete mode 100644 WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/RewindStatus+multiSite.swift diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.storyboard b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.storyboard deleted file mode 100644 index 357e53ea78bf..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.storyboard +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift deleted file mode 100644 index 32888c85140c..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ /dev/null @@ -1,320 +0,0 @@ -import UIKit -import Gridicons -import WordPressUI - -protocol ActivityPresenter: AnyObject { - func presentDetailsFor(activity: FormattableActivity) - func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) - func presentRestoreFor(activity: Activity, from: String?) - func presentBackupFor(activity: Activity, from: String?) -} - -class ActivityDetailViewController: UIViewController, StoryboardLoadable { - - // MARK: - StoryboardLoadable Protocol - - static var defaultStoryboardName = defaultControllerID - - // MARK: - Properties - - var formattableActivity: FormattableActivity? { - didSet { - setupActivity() - setupRouter() - } - } - var site: JetpackSiteRef? - - var rewindStatus: RewindStatus? - - weak var presenter: ActivityPresenter? - - @IBOutlet private var imageView: CircularImageView! - - @IBOutlet private var roleLabel: UILabel! - @IBOutlet private var nameLabel: UILabel! - - @IBOutlet private var timeLabel: UILabel! - @IBOutlet private var dateLabel: UILabel! - - @IBOutlet weak var textView: UITextView! { - didSet { - textView.delegate = self - } - } - - @IBOutlet weak var jetpackBadgeView: UIView! - - //TODO: remove! - @IBOutlet private var textLabel: UILabel! - @IBOutlet private var summaryLabel: UILabel! - - @IBOutlet private var headerStackView: UIStackView! - @IBOutlet private var rewindStackView: UIStackView! - @IBOutlet private var backupStackView: UIStackView! - @IBOutlet private var contentStackView: UIStackView! - @IBOutlet private var containerView: UIView! - - @IBOutlet weak var warningButton: MultilineButton! - - @IBOutlet private var bottomConstaint: NSLayoutConstraint! - - @IBOutlet private var rewindButton: UIButton! - @IBOutlet private var backupButton: UIButton! - - private var activity: Activity? - - var router: ActivityContentRouter? - - override func viewDidLoad() { - super.viewDidLoad() - - setupLabelStyles() - setupViews() - setupText() - setupAccesibility() - hideRestoreIfNeeded() - showWarningIfNeeded() - WPAnalytics.track(.activityLogDetailViewed, withProperties: ["source": presentedFrom()]) - } - - @IBAction func rewindButtonTapped(sender: UIButton) { - guard let activity else { - return - } - presenter?.presentRestoreFor(activity: activity, from: "\(presentedFrom())/detail") - } - - @IBAction func backupButtonTapped(sender: UIButton) { - guard let activity else { - return - } - presenter?.presentBackupFor(activity: activity, from: "\(presentedFrom())/detail") - } - - @IBAction func warningTapped(_ sender: Any) { - guard let url = URL(string: Constants.supportUrl) else { - return - } - - let navController = UINavigationController(rootViewController: WebViewControllerFactory.controller(url: url, source: "activity_detail_warning")) - - present(navController, animated: true) - } - - private func setupLabelStyles() { - nameLabel.textColor = .label - nameLabel.font = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize, - weight: .semibold) - textLabel.textColor = .label - summaryLabel.textColor = .secondaryLabel - - roleLabel.textColor = .secondaryLabel - dateLabel.textColor = .secondaryLabel - timeLabel.textColor = .secondaryLabel - - rewindButton.setTitleColor(UIAppColor.primary, for: .normal) - rewindButton.setTitleColor(UIAppColor.primaryDark, for: .highlighted) - - backupButton.setTitleColor(UIAppColor.primary, for: .normal) - backupButton.setTitleColor(UIAppColor.primaryDark, for: .highlighted) - } - - private func setupViews() { - guard let activity else { - return - } - - view.backgroundColor = .systemBackground - containerView.backgroundColor = .systemBackground - - textLabel.isHidden = true - textView.textContainerInset = .zero - textView.textContainer.lineFragmentPadding = 0 - - if activity.isRewindable { - bottomConstaint.constant = 0 - rewindStackView.isHidden = false - backupStackView.isHidden = false - } - - if let avatar = activity.actor?.avatarURL, let avatarURL = URL(string: avatar) { - imageView.backgroundColor = UIAppColor.neutral(.shade20) - imageView.downloadImage(from: avatarURL, placeholderImage: .gridicon(.user, size: Constants.gridiconSize)) - } else if let iconType = activity.gridiconType { - imageView.contentMode = .center - imageView.backgroundColor = activity.statusColor - imageView.image = .gridicon(iconType, size: Constants.gridiconSize) - } else { - imageView.isHidden = true - } - - rewindButton.naturalContentHorizontalAlignment = .leading - rewindButton.setImage(.gridicon(.history, size: Constants.gridiconSize), for: .normal) - - backupButton.naturalContentHorizontalAlignment = .leading - backupButton.setImage(.gridicon(.cloudDownload, size: Constants.gridiconSize), for: .normal) - - let attributedTitle = StringHighlighter.highlightString(RewindStatus.Strings.multisiteNotAvailableSubstring, - inString: RewindStatus.Strings.multisiteNotAvailable) - - warningButton.setAttributedTitle(attributedTitle, for: .normal) - warningButton.setTitleColor(.systemGray, for: .normal) - warningButton.titleLabel?.numberOfLines = 0 - warningButton.titleLabel?.lineBreakMode = .byWordWrapping - warningButton.naturalContentHorizontalAlignment = .leading - warningButton.backgroundColor = view.backgroundColor - setupJetpackBadge() - } - - private func setupJetpackBadge() { - guard JetpackBrandingVisibility.all.enabled else { - return - } - jetpackBadgeView.isHidden = false - let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.activityDetail) - let jetpackBadgeButton = JetpackButton(style: .badge, title: textProvider.brandingText()) - jetpackBadgeButton.translatesAutoresizingMaskIntoConstraints = false - jetpackBadgeButton.addTarget(self, action: #selector(jetpackButtonTapped), for: .touchUpInside) - jetpackBadgeView.addSubview(jetpackBadgeButton) - NSLayoutConstraint.activate([ - jetpackBadgeButton.centerXAnchor.constraint(equalTo: jetpackBadgeView.centerXAnchor), - jetpackBadgeButton.topAnchor.constraint(equalTo: jetpackBadgeView.topAnchor, constant: Constants.jetpackBadgeTopInset), - jetpackBadgeButton.bottomAnchor.constraint(equalTo: jetpackBadgeView.bottomAnchor) - ]) - jetpackBadgeView.backgroundColor = .systemGroupedBackground - } - - @objc private func jetpackButtonTapped() { - JetpackBrandingCoordinator.presentOverlay(from: self) - JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .activityDetail) - } - - private func setupText() { - guard let activity, let site else { - return - } - - title = NSLocalizedString("Event", comment: "Title for the activity detail view") - nameLabel.text = activity.actor?.displayName - roleLabel.text = activity.actor?.role.localizedCapitalized - - textView.attributedText = formattableActivity?.formattedContent(using: ActivityContentStyles()) - summaryLabel.text = activity.summary - - rewindButton.setTitle(NSLocalizedString("Restore", comment: "Title for button allowing user to restore their Jetpack site"), - for: .normal) - backupButton.setTitle(NSLocalizedString("Download backup", comment: "Title for button allowing user to backup their Jetpack site"), - for: .normal) - - let dateFormatter = ActivityDateFormatting.longDateFormatter(for: site, withTime: false) - dateLabel.text = dateFormatter.string(from: activity.published) - - let timeFormatter = DateFormatter() - timeFormatter.dateStyle = .none - timeFormatter.timeStyle = .short - timeFormatter.timeZone = dateFormatter.timeZone - - timeLabel.text = timeFormatter.string(from: activity.published) - } - - private func setupAccesibility() { - guard let activity else { - return - } - - contentStackView.isAccessibilityElement = true - contentStackView.accessibilityTraits = UIAccessibilityTraits.staticText - contentStackView.accessibilityLabel = "\(activity.text), \(activity.summary)" - textLabel.isAccessibilityElement = false - summaryLabel.isAccessibilityElement = false - - if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { - headerStackView.axis = .vertical - - dateLabel.textAlignment = .center - timeLabel.textAlignment = .center - } else { - headerStackView.axis = .horizontal - - if view.effectiveUserInterfaceLayoutDirection == .leftToRight { - // swiftlint:disable:next inverse_text_alignment - dateLabel.textAlignment = .right - // swiftlint:disable:next inverse_text_alignment - timeLabel.textAlignment = .right - } else { - // swiftlint:disable:next natural_text_alignment - dateLabel.textAlignment = .left - // swiftlint:disable:next natural_text_alignment - timeLabel.textAlignment = .left - } - } - } - - private func hideRestoreIfNeeded() { - guard let isRestoreActive = rewindStatus?.isActive() else { - return - } - - rewindStackView.isHidden = !isRestoreActive - } - - private func showWarningIfNeeded() { - guard let isMultiSite = rewindStatus?.isMultisite() else { - return - } - - warningButton.isHidden = !isMultiSite - } - - func setupRouter() { - guard let activity = formattableActivity else { - router = nil - return - } - let coordinator = DefaultContentCoordinator(controller: self, context: ContextManager.shared.mainContext) - router = ActivityContentRouter( - activity: activity, - coordinator: coordinator) - } - - func setupActivity() { - activity = formattableActivity?.activity - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { - setupLabelStyles() - setupAccesibility() - } - } - - private func presentedFrom() -> String { - if presenter is ActivityLogsViewController { - return "activity_log" - } else if presenter is BackupsViewController { - return "backup" - } else if presenter is DashboardActivityLogCardCell { - return "dashboard" - } else { - return "unknown" - } - } - - private enum Constants { - static let gridiconSize: CGSize = CGSize(width: 24, height: 24) - static let supportUrl = "https://jetpack.com/support/backup/" - // the distance ought to be 30, and the stackView spacing is 16, thus the top inset is 14. - static let jetpackBadgeTopInset: CGFloat = 14 - } -} - -// MARK: - UITextViewDelegate - -extension ActivityDetailViewController: UITextViewDelegate { - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { - router?.routeTo(URL) - return false - } -} diff --git a/WordPress/Classes/ViewRelated/Activity/RewindStatus+multiSite.swift b/WordPress/Classes/ViewRelated/Activity/RewindStatus+multiSite.swift deleted file mode 100644 index 0109881caa22..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/RewindStatus+multiSite.swift +++ /dev/null @@ -1,18 +0,0 @@ -import WordPressKit - -extension RewindStatus { - func isMultisite() -> Bool { - reason == "multisite_not_supported" - } - - func isActive() -> Bool { - state == .active - } - - enum Strings { - static let multisiteNotAvailable = String(format: Self.multisiteNotAvailableFormat, - Self.multisiteNotAvailableSubstring) - static let multisiteNotAvailableFormat = NSLocalizedString("Jetpack Backup for Multisite installations provides downloadable backups, no one-click restores. For more information %1$@.", comment: "Message for Jetpack users that have multisite WP installation, thus Restore is not available. %1$@ is a placeholder for the string 'visit our documentation page'.") - static let multisiteNotAvailableSubstring = NSLocalizedString("visit our documentation page", comment: "Portion of a message for Jetpack users that have multisite WP installation, thus Restore is not available. This part is a link, colored with a different color.") - } -} From cbfbad3e61c213cadf401eba7a797092abd70db2 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:32:44 -0400 Subject: [PATCH 71/90] Remove ActivityStore usages --- WordPress/Classes/Stores/StoreContainer.swift | 1 - .../Cards/Activity Log/DashboardActivityLogCardCell.swift | 2 -- 2 files changed, 3 deletions(-) diff --git a/WordPress/Classes/Stores/StoreContainer.swift b/WordPress/Classes/Stores/StoreContainer.swift index 1b58889ad73d..b0d36efb83d1 100644 --- a/WordPress/Classes/Stores/StoreContainer.swift +++ b/WordPress/Classes/Stores/StoreContainer.swift @@ -14,7 +14,6 @@ class StoreContainer { let plugin = PluginStore() let notice = NoticeStore() let timezone = TimeZoneStore() - let activity = ActivityStore() let jetpackInstall = JetpackInstallStore() let statsWidgets = StatsWidgetsStore() } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift index 07d230a3a635..86b62a285815 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift @@ -9,8 +9,6 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { private var viewModel: DashboardActivityLogViewModel? private var hostingController: UIHostingController? - let store = StoreContainer.shared.activity - // MARK: - Views private lazy var cardFrameView: BlogDashboardCardFrameView = { From 8420f3178da924e72ad18019ff81f4fa837e1d5d Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:33:34 -0400 Subject: [PATCH 72/90] Remove ActivityStoreTests --- .../Tests/Stores/ActivityStoreTests.swift | 251 ------------------ 1 file changed, 251 deletions(-) delete mode 100644 Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift diff --git a/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift b/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift deleted file mode 100644 index 41597c123ef0..000000000000 --- a/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift +++ /dev/null @@ -1,251 +0,0 @@ -import WordPressFlux -import XCTest -import WordPressKit - -@testable import WordPress -@testable import WordPressData - -class ActivityStoreTests: CoreDataTestCase { - private var dispatcher: ActionDispatcher! - private var store: ActivityStore! - private var activityServiceMock: ActivityServiceRemoteMock! - private var backupServiceMock: JetpackBackupServiceMock! - - override func setUp() { - super.setUp() - - dispatcher = ActionDispatcher() - activityServiceMock = ActivityServiceRemoteMock() - backupServiceMock = JetpackBackupServiceMock(coreDataStack: contextManager) - store = ActivityStore(dispatcher: dispatcher, activityServiceRemote: activityServiceMock, backupService: backupServiceMock) - } - - override func tearDown() { - dispatcher = nil - store = nil - - super.tearDown() - } - - // Check if refreshActivities call the service with the correct after and before date - // - func testRefreshActivities() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - let afterDate = Date() - let beforeDate = Date(timeIntervalSinceNow: 86400) - let group = ["post"] - - dispatch(.refreshActivities(site: jetpackSiteRef, quantity: 10, afterDate: afterDate, beforeDate: beforeDate, group: group)) - - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithAfterDate, afterDate) - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithBeforeDate, beforeDate) - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithGroup, group) - } - - // Check if loadMoreActivities call the service with the correct params - // - func testLoadMoreActivities() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - let afterDate = Date() - let beforeDate = Date(timeIntervalSinceNow: 86400) - let group = ["post", "user"] - - dispatch(.loadMoreActivities(site: jetpackSiteRef, quantity: 10, offset: 20, afterDate: afterDate, beforeDate: beforeDate, group: group)) - - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithSiteID, 9) - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithCount, 10) - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithOffset, 20) - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithAfterDate, afterDate) - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithBeforeDate, beforeDate) - XCTAssertEqual(activityServiceMock.getActivityForSiteCalledWithGroup, group) - } - - // Check if loadMoreActivities keep the activies and add the new retrieved ones - // - func testLoadMoreActivitiesKeepTheExistent() throws { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - store.state.activities[jetpackSiteRef] = try [Activity.mock()] - activityServiceMock.activitiesToReturn = try [Activity.mock(), Activity.mock()] - activityServiceMock.hasMore = true - - dispatch(.loadMoreActivities(site: jetpackSiteRef, quantity: 10, offset: 20, afterDate: nil, beforeDate: nil, group: [])) - - XCTAssertEqual(store.state.activities[jetpackSiteRef]?.count, 3) - XCTAssertTrue(store.state.hasMore) - } - - // resetActivities remove all activities - // - func testResetActivities() throws { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - store.state.activities[jetpackSiteRef] = try [Activity.mock()] - activityServiceMock.activitiesToReturn = try [Activity.mock(), Activity.mock()] - activityServiceMock.hasMore = true - - dispatch(.resetActivities(site: jetpackSiteRef)) - - XCTAssertTrue(store.state.activities[jetpackSiteRef]!.isEmpty) - XCTAssertFalse(store.state.fetchingActivities[jetpackSiteRef]!) - XCTAssertFalse(store.state.hasMore) - } - - // Check if loadMoreActivities keep the activies and add the new retrieved ones - // - func testReturnOnlyRewindableActivities() throws { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - store.state.activities[jetpackSiteRef] = try [Activity.mock()] - activityServiceMock.activitiesToReturn = try [Activity.mock(isRewindable: true), Activity.mock()] - activityServiceMock.hasMore = true - - store.onlyRestorableItems = true - dispatch(.loadMoreActivities(site: jetpackSiteRef, quantity: 10, offset: 20, afterDate: nil, beforeDate: nil, group: [])) - - XCTAssertEqual(store.state.activities[jetpackSiteRef]?.filter { $0.isRewindable }.count, 1) - } - - // refreshGroups call the service with the correct params - // - func testRefreshGroups() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - let afterDate = Date() - let beforeDate = Date(timeIntervalSinceNow: 86400) - - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: afterDate, beforeDate: beforeDate)) - - XCTAssertEqual(activityServiceMock.getActivityGroupsForSiteCalledWithSiteID, 9) - XCTAssertEqual(activityServiceMock.getActivityGroupsForSiteCalledWithAfterDate, afterDate) - XCTAssertEqual(activityServiceMock.getActivityGroupsForSiteCalledWithBeforeDate, beforeDate) - } - - // refreshGroups stores the returned groups - // - func testRefreshGroupsStoreGroups() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - activityServiceMock.groupsToReturn = [try! ActivityGroup("post", dictionary: ["name": "Posts and Pages", "count": 5] as [String: AnyObject])] - - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: nil, beforeDate: nil)) - - XCTAssertEqual(store.state.groups[jetpackSiteRef]?.count, 1) - XCTAssertTrue(store.state.groups[jetpackSiteRef]!.contains(where: { $0.key == "post" && $0.name == "Posts and Pages" && $0.count == 5})) - } - - // refreshGroups does not produce multiple requests - // - func testRefreshGroupsDoesNotProduceMultipleRequests() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: nil, beforeDate: nil)) - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: nil, beforeDate: nil)) - - XCTAssertEqual(activityServiceMock.getActivityGroupsForSiteCalledTimes, 1) - } - - // When a previous request for Activity types has suceeded, return the cached groups - // - func testRefreshGroupsUseCache() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - activityServiceMock.groupsToReturn = [try! ActivityGroup("post", dictionary: ["name": "Posts and Pages", "count": 5] as [String: AnyObject])] - - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: nil, beforeDate: nil)) - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: nil, beforeDate: nil)) - - XCTAssertEqual(activityServiceMock.getActivityGroupsForSiteCalledTimes, 1) - XCTAssertTrue(store.state.groups[jetpackSiteRef]!.contains(where: { $0.key == "post" && $0.name == "Posts and Pages" && $0.count == 5})) - } - - // Request groups endpoint again if the cache expired - // - func testRefreshGroupsRequestsAgainIfTheFirstSucceeds() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - activityServiceMock.groupsToReturn = [try! ActivityGroup("post", dictionary: ["name": "Posts and Pages", "count": 5] as [String: AnyObject])] - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: nil, beforeDate: nil)) - - dispatch(.resetGroups(site: jetpackSiteRef)) - dispatch(.refreshGroups(site: jetpackSiteRef, afterDate: Date(), beforeDate: nil)) - - XCTAssertEqual(activityServiceMock.getActivityGroupsForSiteCalledTimes, 2) - } - - // MARK: - Backup Status - - // Query for backup status call the service - // - func testBackupStatusQueryCallsService() { - let jetpackSiteRef = JetpackSiteRef.mock(siteID: 9, username: "foo") - - _ = store.query(.backupStatus(site: jetpackSiteRef)) - store.processQueries() - - XCTAssertEqual(backupServiceMock.didCallGetAllBackupStatusWithSite, jetpackSiteRef) - } - - // MARK: - Helpers - - private func dispatch(_ action: ActivityAction) { - dispatcher.dispatch(action) - } -} - -class ActivityServiceRemoteMock: ActivityServiceRemote { - var getActivityForSiteCalledWithSiteID: Int? - var getActivityForSiteCalledWithOffset: Int? - var getActivityForSiteCalledWithCount: Int? - var getActivityForSiteCalledWithAfterDate: Date? - var getActivityForSiteCalledWithBeforeDate: Date? - var getActivityForSiteCalledWithGroup: [String]? - - var getActivityGroupsForSiteCalledWithSiteID: Int? - var getActivityGroupsForSiteCalledWithAfterDate: Date? - var getActivityGroupsForSiteCalledWithBeforeDate: Date? - var getActivityGroupsForSiteCalledTimes = 0 - - var activitiesToReturn: [Activity]? - var hasMore = false - - var groupsToReturn: [ActivityGroup]? - - override func getActivityForSite(_ siteID: Int, offset: Int = 0, count: Int, after: Date? = nil, before: Date? = nil, group: [String] = [], rewindable: Bool? = nil, searchText: String? = nil, success: @escaping (_ activities: [WordPressKit.Activity], _ hasMore: Bool) -> Void, failure: @escaping (any Error) -> Void) { - getActivityForSiteCalledWithSiteID = siteID - getActivityForSiteCalledWithCount = count - getActivityForSiteCalledWithOffset = offset - getActivityForSiteCalledWithAfterDate = after - getActivityForSiteCalledWithBeforeDate = before - getActivityForSiteCalledWithGroup = group - - if let activitiesToReturn { - success(activitiesToReturn, hasMore) - } - } - - override func getActivityGroupsForSite(_ siteID: Int, after: Date? = nil, before: Date? = nil, success: @escaping ([ActivityGroup]) -> Void, failure: @escaping (Error) -> Void) { - getActivityGroupsForSiteCalledWithSiteID = siteID - getActivityGroupsForSiteCalledWithAfterDate = after - getActivityGroupsForSiteCalledWithBeforeDate = before - getActivityGroupsForSiteCalledTimes += 1 - - if let groupsToReturn { - success(groupsToReturn) - } - } - - override func getRewindStatus(_ siteID: Int, success: @escaping (RewindStatus) -> Void, failure: @escaping (Error) -> Void) { - - } -} - -extension ActivityGroup { - class func mock() -> ActivityGroup { - try! ActivityGroup("post", dictionary: ["name": "Posts and Pages", "count": 5] as [String: AnyObject]) - } -} - -class JetpackBackupServiceMock: JetpackBackupService { - var didCallGetAllBackupStatusWithSite: JetpackSiteRef? - - override func getAllBackupStatus(for site: JetpackSiteRef, success: @escaping ([JetpackBackup]) -> Void, failure: @escaping (Error) -> Void) { - didCallGetAllBackupStatusWithSite = site - - let jetpackBackup = JetpackBackup(backupPoint: Date(), downloadID: 100, rewindID: "", startedAt: Date(), progress: 10, downloadCount: 0, url: "", validUntil: nil) - success([jetpackBackup]) - } -} From d77e1d4ed1bc9de30ae745b2190245d8e4f5a4dd Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:53:46 -0400 Subject: [PATCH 73/90] Fix how isAwaitingCredentials works --- .../ActivityLogDetailsCoordinator.swift | 6 +-- .../Details/ActivityLogDetailsView.swift | 43 +++++++++++++++++-- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift index b3b0320118ef..47755b08526c 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -5,7 +5,7 @@ import WordPressKit /// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers enum ActivityLogDetailsCoordinator { - static func presentRestore(activity: Activity, blog: Blog) { + static func presentRestore(activity: Activity, blog: Blog, rewindStatus: RewindStatus) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog), activity.isRewindable, @@ -13,9 +13,7 @@ enum ActivityLogDetailsCoordinator { return } - // Check if the store has the credentials status cached - let store = StoreContainer.shared.activity - let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) + let isAwaitingCredentials = rewindStatus.state == .awaitingCredentials let restoreViewController = JetpackRestoreOptionsViewController( site: siteRef, diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index c6c7d28f1295..0bee63d08424 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -9,6 +9,7 @@ struct ActivityLogDetailsView: View { let blog: Blog @Environment(\.dismiss) var dismiss + @State private var isLoadingRewindStatus = false var body: some View { ScrollView { @@ -61,13 +62,20 @@ struct ActivityLogDetailsView: View { // Action buttons HStack(spacing: 12) { Button(action: { - trackRestoreTapped() - ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) + handleRestoreTapped() }) { - Label(Strings.restore, systemImage: "arrow.counterclockwise") - .fontWeight(.medium) + ZStack { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + .opacity(isLoadingRewindStatus ? 0 : 1) + + if isLoadingRewindStatus { + ProgressView() + } + } } .buttonStyle(.borderedProminent) + .disabled(isLoadingRewindStatus) Button(action: { trackBackupTapped() @@ -222,6 +230,33 @@ private enum Strings { ) } +// MARK: - Actions + +private extension ActivityLogDetailsView { + func handleRestoreTapped() { + trackRestoreTapped() + + guard let siteRef = JetpackSiteRef(blog: blog) else { + return + } + + isLoadingRewindStatus = true + + let service = JetpackRestoreService(coreDataStack: ContextManager.shared) + service.getRewindStatus(for: siteRef) { rewindStatus in + DispatchQueue.main.async { + self.isLoadingRewindStatus = false + ActivityLogDetailsCoordinator.presentRestore(activity: self.activity, blog: self.blog, rewindStatus: rewindStatus) + } + } failure: { error in + DispatchQueue.main.async { + self.isLoadingRewindStatus = false + DDLogError("Failed to fetch rewind status: \(error.localizedDescription)") + } + } + } +} + // MARK: - Analytics private extension ActivityLogDetailsView { From c4c37d21a7e2d51a8e0d023ae177a64bc2c70978 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 11:59:20 -0400 Subject: [PATCH 74/90] Remove ActivityStore --- WordPress/Classes/Stores/ActivityStore.swift | 653 ------------------- 1 file changed, 653 deletions(-) delete mode 100644 WordPress/Classes/Stores/ActivityStore.swift diff --git a/WordPress/Classes/Stores/ActivityStore.swift b/WordPress/Classes/Stores/ActivityStore.swift deleted file mode 100644 index 58fc86e6bb32..000000000000 --- a/WordPress/Classes/Stores/ActivityStore.swift +++ /dev/null @@ -1,653 +0,0 @@ -import Foundation -import WordPressKit -import WordPressFlux -import WordPressShared - -// MARK: - Store helper types - -enum ActivityAction: Action { - case refreshActivities(site: JetpackSiteRef, quantity: Int, afterDate: Date?, beforeDate: Date?, group: [String]) - case loadMoreActivities(site: JetpackSiteRef, quantity: Int, offset: Int, afterDate: Date?, beforeDate: Date?, group: [String]) - case receiveActivities(site: JetpackSiteRef, activities: [Activity], hasMore: Bool, loadingMore: Bool) - case receiveActivitiesFailed(site: JetpackSiteRef, error: Error) - case resetActivities(site: JetpackSiteRef) - - case rewind(site: JetpackSiteRef, rewindID: String) - case rewindStarted(site: JetpackSiteRef, rewindID: String, restoreID: String) - case rewindRequestFailed(site: JetpackSiteRef, error: Error) - case rewindFinished(site: JetpackSiteRef, restoreID: String) - case rewindFailed(site: JetpackSiteRef, restoreID: String) - - case rewindStatusUpdated(site: JetpackSiteRef, status: RewindStatus) - case rewindStatusUpdateFailed(site: JetpackSiteRef, error: Error) - case rewindStatusUpdateTimedOut(site: JetpackSiteRef) - - case refreshBackupStatus(site: JetpackSiteRef) - case backupStatusUpdated(site: JetpackSiteRef, status: JetpackBackup) - case backupStatusUpdateFailed(site: JetpackSiteRef, error: Error) - case backupStatusUpdateTimedOut(site: JetpackSiteRef) - case dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) - - case refreshGroups(site: JetpackSiteRef, afterDate: Date?, beforeDate: Date?) - case resetGroups(site: JetpackSiteRef) -} - -enum ActivityQuery { - case activities(site: JetpackSiteRef) - case restoreStatus(site: JetpackSiteRef) - case backupStatus(site: JetpackSiteRef) - - var site: JetpackSiteRef { - switch self { - case .activities(let site): - return site - case .restoreStatus(let site): - return site - case .backupStatus(let site): - return site - } - } -} - -struct ActivityStoreState { - var activities = [JetpackSiteRef: [Activity]]() - var lastFetch = [JetpackSiteRef: Date]() - var fetchingActivities = [JetpackSiteRef: Bool]() - var hasMore = false - - var groups = [JetpackSiteRef: [ActivityGroup]]() - var fetchingGroups = [JetpackSiteRef: Bool]() - - var rewindStatus = [JetpackSiteRef: RewindStatus]() - var fetchingRewindStatus = [JetpackSiteRef: Bool]() - - var backupStatus = [JetpackSiteRef: JetpackBackup]() - var fetchingBackupStatus = [JetpackSiteRef: Bool]() - - // This needs to be `fileprivate` because `DelayStateWrapper` is private. - fileprivate var rewindStatusRetries = [JetpackSiteRef: DelayStateWrapper]() - fileprivate var backupStatusRetries = [JetpackSiteRef: DelayStateWrapper]() -} - -private enum Constants { - /// Sequence of increasing delays to apply to the fetch restore status mechanism (in seconds) - static let delaySequence = [1, 5] - static let maxRetries = 12 -} - -enum ActivityStoreError: Error { - case rewindAlreadyRunning -} - -class ActivityStore: QueryStore { - - private let refreshInterval: TimeInterval = 60 - - private let activityServiceRemote: ActivityServiceRemote? - - private let backupService: JetpackBackupService - - /// When set to true, this store will only return items that are restorable - var onlyRestorableItems = false - - var numberOfItemsPerPage = 20 - - override func queriesChanged() { - super.queriesChanged() - processQueries() - } - - init(dispatcher: ActionDispatcher = .global, - activityServiceRemote: ActivityServiceRemote? = nil, - backupService: JetpackBackupService? = nil) { - self.activityServiceRemote = activityServiceRemote - self.backupService = backupService ?? JetpackBackupService(coreDataStack: ContextManager.shared) - super.init(initialState: ActivityStoreState(), dispatcher: dispatcher) - } - - override func logError(_ error: String) { - DDLogError("\(error)") - } - - func processQueries() { - guard !activeQueries.isEmpty else { - transaction { state in - state.activities = [:] - state.rewindStatus = [:] - state.backupStatus = [:] - state.rewindStatusRetries = [:] - state.lastFetch = [:] - state.fetchingActivities = [:] - state.fetchingRewindStatus = [:] - state.fetchingBackupStatus = [:] - } - return - } - - // Fetching Activities. - sitesToFetch - .forEach { fetchActivities(site: $0, count: numberOfItemsPerPage) } - - // Fetching Status - sitesStatusesToFetch - .filter { state.fetchingRewindStatus[$0] != true } - .forEach { - fetchRewindStatus(site: $0) - } - - // Fetching Backup Status - sitesStatusesToFetch - .filter { state.fetchingBackupStatus[$0] != true } - .forEach { - fetchBackupStatus(site: $0) - } - } - private var sitesToFetch: [JetpackSiteRef] { - return activeQueries - .filter { - if case .activities = $0 { - return true - } else { - return false - } - } - .compactMap { $0.site } - .unique - .filter { shouldFetch(site: $0) } - } - - private var sitesStatusesToFetch: [JetpackSiteRef] { - return activeQueries - .filter { - if case .restoreStatus = $0 { - return true - } else if case .backupStatus = $0 { - return true - } else { - return false - } - } - .compactMap { $0.site } - .unique - } - - func shouldFetch(site: JetpackSiteRef) -> Bool { - let lastFetch = state.lastFetch[site, default: .distantPast] - let needsRefresh = lastFetch + refreshInterval < Date() - let currentlyFetching = isFetchingActivities(site: site) - return needsRefresh && !currentlyFetching - } - - func isFetchingActivities(site: JetpackSiteRef) -> Bool { - return state.fetchingActivities[site, default: false] - } - - func isFetchingGroups(site: JetpackSiteRef) -> Bool { - return state.fetchingGroups[site, default: false] - } - - override func onDispatch(_ action: Action) { - guard let activityAction = action as? ActivityAction else { - return - } - - switch activityAction { - case .receiveActivities(let site, let activities, let hasMore, let loadingMore): - receiveActivities(site: site, activities: activities, hasMore: hasMore, loadingMore: loadingMore) - case .loadMoreActivities(let site, let quantity, let offset, let afterDate, let beforeDate, let group): - loadMoreActivities(site: site, quantity: quantity, offset: offset, afterDate: afterDate, beforeDate: beforeDate, group: group) - case .receiveActivitiesFailed(let site, let error): - receiveActivitiesFailed(site: site, error: error) - case .refreshActivities(let site, let quantity, let afterDate, let beforeDate, let group): - refreshActivities(site: site, quantity: quantity, afterDate: afterDate, beforeDate: beforeDate, group: group) - case .resetActivities(let site): - resetActivities(site: site) - case .rewind(let site, let rewindID): - rewind(site: site, rewindID: rewindID) - case .rewindStarted(let site, let rewindID, let restoreID): - rewindStarted(site: site, rewindID: rewindID, restoreID: restoreID) - case .rewindRequestFailed(let site, let error): - rewindFailed(site: site, error: error) - case .rewindStatusUpdated(let site, let status): - rewindStatusUpdated(site: site, status: status) - case .rewindStatusUpdateFailed(let site, _): - delayedRetryFetchRewindStatus(site: site) - case .rewindFinished(let site, let restoreID): - rewindFinished(site: site, restoreID: restoreID) - case .rewindFailed(let site, _), - .rewindStatusUpdateTimedOut(let site): - transaction { state in - state.fetchingRewindStatus[site] = false - state.rewindStatusRetries[site] = nil - } - - if shouldPostStateUpdates(for: site) { - let notice = Notice(title: NSLocalizedString("Your restore is taking longer than usual, please check again in a few minutes.", - comment: "Text displayed when a site restore takes too long.")) - actionDispatcher.dispatch(NoticeAction.post(notice)) - } - case .refreshGroups(let site, let afterDate, let beforeDate): - refreshGroups(site: site, afterDate: afterDate, beforeDate: beforeDate) - case .resetGroups(let site): - resetGroups(site: site) - case .refreshBackupStatus(let site): - fetchBackupStatus(site: site) - case .backupStatusUpdated(let site, let status): - backupStatusUpdated(site: site, status: status) - case .backupStatusUpdateFailed(let site, _): - delayedRetryFetchBackupStatus(site: site) - case .backupStatusUpdateTimedOut(let site): - transaction { state in - state.fetchingBackupStatus[site] = false - state.backupStatusRetries[site] = nil - } - - if shouldPostStateUpdates(for: site) { - let notice = Notice(title: NSLocalizedString("Your backup is taking longer than usual, please check again in a few minutes.", - comment: "Text displayed when a site backup takes too long.")) - actionDispatcher.dispatch(NoticeAction.post(notice)) - } - case .dismissBackupNotice(let site, let downloadID): - dismissBackupNotice(site: site, downloadID: downloadID) - } - } -} -// MARK: - Selectors -extension ActivityStore { - func getActivities(site: JetpackSiteRef) -> [Activity]? { - return state.activities[site] ?? nil - } - - func getGroups(site: JetpackSiteRef) -> [ActivityGroup]? { - return state.groups[site] ?? nil - } - - func getActivity(site: JetpackSiteRef, rewindID: String) -> Activity? { - return getActivities(site: site)?.filter { $0.rewindID == rewindID }.first - } - - func getCurrentRewindStatus(site: JetpackSiteRef) -> RewindStatus? { - return state.rewindStatus[site] ?? nil - } - - func getBackupStatus(site: JetpackSiteRef) -> JetpackBackup? { - return state.backupStatus[site] ?? nil - } - - func isRestoreAlreadyRunning(site: JetpackSiteRef) -> Bool { - let currentStatus = getCurrentRewindStatus(site: site) - let restoreStatus = currentStatus?.restore?.status - return currentStatus != nil && (restoreStatus == .running || restoreStatus == .queued) - } - - func isAwaitingCredentials(site: JetpackSiteRef) -> Bool { - let currentStatus = getCurrentRewindStatus(site: site) - return currentStatus?.state == .awaitingCredentials - } - - func fetchRewindStatus(site: JetpackSiteRef) { - state.fetchingRewindStatus[site] = true - - remote(site: site)?.getRewindStatus( - site.siteID, - success: { [actionDispatcher] rewindStatus in - actionDispatcher.dispatch(ActivityAction.rewindStatusUpdated(site: site, status: rewindStatus)) - }, - failure: { [actionDispatcher] error in - actionDispatcher.dispatch(ActivityAction.rewindStatusUpdateFailed(site: site, error: error)) - }) - } - - func fetchBackupStatus(site: JetpackSiteRef) { - guard site.hasBackup else { - return - } - - state.fetchingBackupStatus[site] = true - - backupService.getAllBackupStatus(for: site, success: { [actionDispatcher] backupsStatus in - guard let status = backupsStatus.first else { - return - } - - actionDispatcher.dispatch(ActivityAction.backupStatusUpdated(site: site, status: status)) - }, failure: { [actionDispatcher] error in - actionDispatcher.dispatch(ActivityAction.backupStatusUpdateFailed(site: site, error: error)) - }) - } - -} - -private extension ActivityStore { - func fetchActivities(site: JetpackSiteRef, - count: Int, - offset: Int = 0, - afterDate: Date? = nil, - beforeDate: Date? = nil, - group: [String] = []) { - state.fetchingActivities[site] = true - - remote(site: site)?.getActivityForSite( - site.siteID, - offset: offset, - count: count, - after: afterDate, - before: beforeDate, - group: group, - success: { [weak self, actionDispatcher] (activities, hasMore) in - guard let self else { - return - } - - let loadingMore = offset > 0 - actionDispatcher.dispatch( - ActivityAction.receiveActivities( - site: site, - activities: self.onlyRestorableItems ? activities.filter { $0.isRewindable } : activities, - hasMore: hasMore, - loadingMore: loadingMore) - ) - }, - failure: { [actionDispatcher] error in - actionDispatcher.dispatch(ActivityAction.receiveActivitiesFailed(site: site, error: error)) - }) - } - - func receiveActivities(site: JetpackSiteRef, activities: [Activity], hasMore: Bool = false, loadingMore: Bool = false) { - transaction { state in - let allActivities = loadingMore ? (state.activities[site] ?? []) + activities : activities - state.activities[site] = allActivities - state.fetchingActivities[site] = false - state.lastFetch[site] = Date() - state.hasMore = hasMore - } - } - - func receiveActivitiesFailed(site: JetpackSiteRef, error: Error) { - transaction { state in - state.fetchingActivities[site] = false - state.lastFetch[site] = Date() - } - } - - func refreshActivities(site: JetpackSiteRef, quantity: Int, afterDate: Date?, beforeDate: Date?, group: [String]) { - guard !isFetchingActivities(site: site) else { - DDLogInfo("Activity Log refresh triggered while one was in progress") - return - } - fetchActivities(site: site, count: quantity, afterDate: afterDate, beforeDate: beforeDate, group: group) - } - - func loadMoreActivities(site: JetpackSiteRef, quantity: Int, offset: Int, afterDate: Date?, beforeDate: Date?, group: [String]) { - guard !isFetchingActivities(site: site) else { - DDLogInfo("Activity Log refresh triggered while one was in progress") - return - } - fetchActivities(site: site, count: quantity, offset: offset, afterDate: afterDate, beforeDate: beforeDate, group: group) - } - - func resetActivities(site: JetpackSiteRef) { - transaction { state in - state.activities[site] = [] - state.fetchingActivities[site] = false - state.lastFetch[site] = Date() - state.hasMore = false - } - } - - func rewind(site: JetpackSiteRef, rewindID: String) { - if isRestoreAlreadyRunning(site: site) { - actionDispatcher.dispatch(ActivityAction.rewindRequestFailed(site: site, error: ActivityStoreError.rewindAlreadyRunning)) - return - } - - remoteV1(site: site)?.restoreSite( - site.siteID, - rewindID: rewindID, - success: { [actionDispatcher] restoreID, _ in - actionDispatcher.dispatch(ActivityAction.rewindStarted(site: site, rewindID: rewindID, restoreID: restoreID)) - }, - failure: { [actionDispatcher] error in - actionDispatcher.dispatch(ActivityAction.rewindRequestFailed(site: site, error: error)) - }) - } - - func rewindStarted(site: JetpackSiteRef, rewindID: String, restoreID: String) { - fetchRewindStatus(site: site) - - let notice: Notice - let title = NSLocalizedString("Your site is being restored", - comment: "Title of a message displayed when user starts a restore operation") - - if let activity = getActivity(site: site, rewindID: rewindID) { - let formattedString = mediumString(from: activity.published, adjustingTimezoneTo: site) - - let message = String(format: NSLocalizedString("Restoring to %@", comment: "Notice showing the date the site is being restored to. '%@' is a placeholder that will expand to a date."), formattedString) - notice = Notice(title: title, message: message) - } else { - notice = Notice(title: title) - } - WPAnalytics.track(.activityLogRewindStarted) - actionDispatcher.dispatch(NoticeAction.post(notice)) - } - - func rewindFinished(site: JetpackSiteRef, restoreID: String) { - transaction { state in - state.fetchingRewindStatus[site] = false - state.rewindStatusRetries[site] = nil - } - - let notice: Notice - let title = NSLocalizedString("Your site has been succesfully restored", - comment: "Title of a message displayed when a site has finished rewinding") - - if let activity = getActivity(site: site, rewindID: restoreID) { - let formattedString = mediumString(from: activity.published, adjustingTimezoneTo: site) - - let message = String(format: NSLocalizedString("Restored to %@", comment: "Notice showing the date the site is being rewinded to. '%@' is a placeholder that will expand to a date."), formattedString) - notice = Notice(title: title, message: message) - } else { - notice = Notice(title: title) - } - - actionDispatcher.dispatch(NoticeAction.post(notice)) - } - - func rewindFailed(site: JetpackSiteRef, error: Error) { - let message: String - switch error { - case ActivityStoreError.rewindAlreadyRunning: - message = NSLocalizedString("There's a restore currently in progress, please wait before starting next one", - comment: "Text displayed when user tries to start a restore when there is already one running") - default: - message = NSLocalizedString("Unable to restore your site, please try again later or contact support.", - comment: "Text displayed when a site restore fails.") - } - - let noticeAction = NoticeAction.post(Notice(title: message)) - - actionDispatcher.dispatch(noticeAction) - } - - func delayedRetryFetchRewindStatus(site: JetpackSiteRef) { - guard sitesStatusesToFetch.contains(site) == false else { - // if we still have an active query asking about status of this site (e.g. it's still visible on screen) - // let's keep retrying as long as it's registered — we want users to see the updates. - // The retry logic should only kick-in when the site is off-screen, so we can pop-up a Notice - // letting users know what's happening with their site. - _ = DispatchDelayedAction(delay: .seconds(Constants.delaySequence.last!)) { [weak self] in - self?.fetchRewindStatus(site: site) - } - return - } - - // Note: this might look sorta weird, because it appears we're not at any point actually - // scheduling the rewind, *but*: initiating/`increment`ing the `DelayStateWrapper` has a side-effect - // of automagically calling the closure after an appropriate amount of time elapses. - guard var existingWrapper = state.rewindStatusRetries[site] else { - let newDelayWrapper = DelayStateWrapper(delaySequence: Constants.delaySequence) { [weak self] in - self?.fetchRewindStatus(site: site) - } - - state.rewindStatusRetries[site] = newDelayWrapper - return - } - - guard existingWrapper.retryAttempt < Constants.maxRetries else { - existingWrapper.delayedRetryAction.cancel() - actionDispatcher.dispatch(ActivityAction.rewindStatusUpdateTimedOut(site: site)) - return - } - - existingWrapper.increment() - state.rewindStatusRetries[site] = existingWrapper - } - - func delayedRetryFetchBackupStatus(site: JetpackSiteRef) { - guard sitesStatusesToFetch.contains(site) == false else { - _ = DispatchDelayedAction(delay: .seconds(Constants.delaySequence.last!)) { [weak self] in - self?.fetchBackupStatus(site: site) - } - return - } - - guard var existingWrapper = state.backupStatusRetries[site] else { - let newDelayWrapper = DelayStateWrapper(delaySequence: Constants.delaySequence) { [weak self] in - self?.fetchBackupStatus(site: site) - } - - state.backupStatusRetries[site] = newDelayWrapper - return - } - - guard existingWrapper.retryAttempt < Constants.maxRetries else { - existingWrapper.delayedRetryAction.cancel() - actionDispatcher.dispatch(ActivityAction.backupStatusUpdateTimedOut(site: site)) - return - } - - existingWrapper.increment() - state.backupStatusRetries[site] = existingWrapper - } - - func dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) { - backupService.dismissBackupNotice(site: site, downloadID: downloadID) - state.backupStatus[site] = nil - } - - func rewindStatusUpdated(site: JetpackSiteRef, status: RewindStatus) { - state.rewindStatus[site] = status - - guard let restoreStatus = status.restore else { - return - } - - switch restoreStatus.status { - case .running, .queued: - delayedRetryFetchRewindStatus(site: site) - case .finished: - if shouldPostStateUpdates(for: site) { - actionDispatcher.dispatch(ActivityAction.rewindFinished(site: site, restoreID: restoreStatus.id)) - } - case .fail: - if shouldPostStateUpdates(for: site) { - actionDispatcher.dispatch(ActivityAction.rewindFailed(site: site, restoreID: restoreStatus.id)) - } - } - } - - func backupStatusUpdated(site: JetpackSiteRef, status: JetpackBackup) { - state.backupStatus[site] = status - - if let progress = status.progress, progress > 0 { - delayedRetryFetchBackupStatus(site: site) - } - } - - func refreshGroups(site: JetpackSiteRef, afterDate: Date?, beforeDate: Date?) { - guard !isFetchingGroups(site: site) else { - DDLogInfo("Activity Log fetch groups triggered while one was in progress") - return - } - - state.fetchingGroups[site] = true - - if state.groups[site]?.isEmpty ?? true { - remote(site: site)?.getActivityGroupsForSite( - site.siteID, - after: afterDate, - before: beforeDate, - success: { [weak self] groups in - self?.receiveGroups(site: site, groups: groups) - }, failure: { [weak self] error in - self?.failedGroups(site: site) - }) - } else { - receiveGroups(site: site, groups: state.groups[site] ?? []) - } - } - - func receiveGroups(site: JetpackSiteRef, groups: [ActivityGroup]) { - transaction { state in - state.fetchingGroups[site] = false - state.groups[site] = groups.sorted { $0.count > $1.count } - } - } - - func failedGroups(site: JetpackSiteRef) { - transaction { state in - state.fetchingGroups[site] = false - state.groups[site] = nil - } - } - - func resetGroups(site: JetpackSiteRef) { - transaction { state in - state.groups[site] = [] - state.fetchingGroups[site] = false - } - } - - // MARK: - Helpers - - func remote(site: JetpackSiteRef) -> ActivityServiceRemote? { - guard activityServiceRemote == nil else { - return activityServiceRemote - } - - guard let token = CredentialsService().getOAuthToken(site: site) else { - return nil - } - - let api = WordPressComRestApi.defaultApi(oAuthToken: token, userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) - - return ActivityServiceRemote(wordPressComRestApi: api) - } - - func remoteV1(site: JetpackSiteRef) -> ActivityServiceRemote_ApiVersion1_0? { - guard let token = CredentialsService().getOAuthToken(site: site) else { - return nil - } - let api = WordPressComRestApi.defaultApi(oAuthToken: token, userAgent: WPUserAgent.wordPress()) - - return ActivityServiceRemote_ApiVersion1_0(wordPressComRestApi: api) - } - - private func mediumString(from date: Date, adjustingTimezoneTo site: JetpackSiteRef) -> String { - let formatter = ActivityDateFormatting.mediumDateFormatterWithTime(for: site) - return formatter.string(from: date) - } - - private func shouldPostStateUpdates(for site: JetpackSiteRef) -> Bool { - // The way our API works, if there was a restore event "recently" (for some undefined value of "recently", - // on the order of magnitude of ~30 minutes or so), it'll be reported back by the API. - // But if the restore has finished a good while back (e.g. there's also an event in the AL telling us - // about the restore happening) we don't neccesarily want to display that redundant info to the users. - // Hence this somewhat dumb hack — if we've gotten updates about a RewindStatus before (which means we have displayed the UI) - // we're gonna show users "hey, your rewind finished!". But if the only thing we know the restore is - // that it has finished in a recent past, we don't do anything special. - - return getCurrentRewindStatus(site: site)?.restore?.status == .running || - getCurrentRewindStatus(site: site)?.restore?.status == .queued - } -} From 450d149554b80df7b9cf435850c36150508d2754 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 12:20:08 -0400 Subject: [PATCH 75/90] Integrate ActivityContentRouter and FormattableActivity --- .../ActivityFormattableContentView.swift | 69 +++++++++++++++++++ .../Details/ActivityLogDetailsView.swift | 32 +++++---- .../Extensions/Activity+Extensions.swift | 47 ------------- 3 files changed, 86 insertions(+), 62 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift new file mode 100644 index 000000000000..3f07a6831291 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift @@ -0,0 +1,69 @@ +import SwiftUI +import UIKit +import WordPressUI +import FormattableContentKit + +struct ActivityFormattableContentView: UIViewRepresentable { + let formattableActivity: FormattableActivity + let blog: Blog + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.linkTextAttributes = [ + .foregroundColor: UIAppColor.primary + ] + textView.delegate = context.coordinator + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + let styles = ActivityContentStyles() + let formattedContent = formattableActivity.formattedContent(using: styles) + textView.attributedText = formattedContent + } + + func makeCoordinator() -> Coordinator { + Coordinator(formattableActivity: formattableActivity, blog: blog) + } + + class Coordinator: NSObject, UITextViewDelegate { + let formattableActivity: FormattableActivity + let blog: Blog + + init(formattableActivity: FormattableActivity, blog: Blog) { + self.formattableActivity = formattableActivity + self.blog = blog + super.init() + } + + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + guard interaction == .invokeDefaultAction else { + return false + } + + // Get the top view controller to create content coordinator + guard let viewController = UIViewController.topViewController else { + return false + } + + let contentCoordinator = DefaultContentCoordinator( + controller: viewController, + context: ContextManager.shared.mainContext + ) + + let router = ActivityContentRouter( + activity: formattableActivity, + coordinator: contentCoordinator + ) + + router.routeTo(URL) + + return false + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 0bee63d08424..4b119b5dfeee 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -3,18 +3,26 @@ import WordPressKit import WordPressUI import WordPressShared import Gridicons +import UIKit struct ActivityLogDetailsView: View { let activity: Activity let blog: Blog - @Environment(\.dismiss) var dismiss @State private var isLoadingRewindStatus = false + private let formattableActivity: FormattableActivity + + init(activity: Activity, blog: Blog) { + self.activity = activity + self.blog = blog + self.formattableActivity = FormattableActivity(with: activity) + } + var body: some View { ScrollView { VStack(spacing: 24) { - ActivityHeaderView(activity: activity) + ActivityHeaderView(activity: activity, blog: blog, formattableActivity: formattableActivity) if activity.isRewindable { restoreSiteCard } @@ -68,7 +76,7 @@ struct ActivityLogDetailsView: View { Label(Strings.restore, systemImage: "arrow.counterclockwise") .fontWeight(.medium) .opacity(isLoadingRewindStatus ? 0 : 1) - + if isLoadingRewindStatus { ProgressView() } @@ -95,6 +103,8 @@ struct ActivityLogDetailsView: View { private struct ActivityHeaderView: View { let activity: Activity + let blog: Blog + let formattableActivity: FormattableActivity var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -122,18 +132,10 @@ private struct ActivityHeaderView: View { // Activity details if !activity.text.isEmpty { - if let formattedContent = activity.formattedContent { - Text(formattedContent) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(3) - .tint(Color.accentColor) - } else { - Text(activity.text) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(3) - } + ActivityFormattableContentView( + formattableActivity: formattableActivity, + blog: blog + ) } // Date and time diff --git a/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift index 39359bc00bbe..994c522abb86 100644 --- a/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift +++ b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift @@ -4,53 +4,6 @@ import DesignSystem import WordPressKit extension Activity { - /// Returns an AttributedString with clickable links based on content ranges - var formattedContent: AttributedString? { - guard let content, - let text = content["text"] as? String, - !text.isEmpty else { - return nil - } - - var attributedString = AttributedString(text) - - // Apply links from ranges if available - if let ranges = content["ranges"] as? [[String: Any]] { - for range in ranges { - guard let indices = range["indices"] as? [NSNumber], - indices.count == 2, - let urlString = range["url"] as? String, - let url = URL(string: urlString) else { - continue - } - - let startIndex = indices[0].intValue - let endIndex = indices[1].intValue - - // Convert string indices to AttributedString indices - guard startIndex >= 0, - endIndex <= text.count, - startIndex < endIndex else { - continue - } - - // Convert character indices to AttributedString.Index - let stringStartIndex = text.index(text.startIndex, offsetBy: startIndex) - let stringEndIndex = text.index(text.startIndex, offsetBy: endIndex) - - // Find corresponding indices in AttributedString - guard let attrStartIndex = AttributedString.Index(stringStartIndex, within: attributedString), - let attrEndIndex = AttributedString.Index(stringEndIndex, within: attributedString) else { - continue - } - - // Apply the link attribute to the exact range - attributedString[attrStartIndex.. Date: Fri, 20 Jun 2025 12:25:04 -0400 Subject: [PATCH 76/90] Update filter icon --- .../Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift | 4 ++-- .../Classes/ViewRelated/Activity/List/ActivityLogsView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift index e1ea7a8ec0e5..dc2c433c19be 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift @@ -2,7 +2,7 @@ import SwiftUI import WordPressKit import WordPressShared -struct ActivityLogsMenu: View { +struct ActivityLogsFiltersMenu: View { @ObservedObject var viewModel: ActivityLogsViewModel @State private var isShowingActivityTypePicker = false @@ -21,7 +21,7 @@ struct ActivityLogsMenu: View { } } } label: { - Image(systemName: "ellipsis") + Image(systemName: "line.3.horizontal.decrease.circle") } .sheet(isPresented: $isShowingActivityTypePicker) { NavigationView { diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index 47752788a173..265d3ef57833 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -74,7 +74,7 @@ private struct ActivityLogsListView: View { } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - ActivityLogsMenu(viewModel: viewModel) + ActivityLogsFiltersMenu(viewModel: viewModel) } } } From f49120607e5b7105ee39ceef17166d03542c1fdb Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 12:30:18 -0400 Subject: [PATCH 77/90] Update filters icon --- .../ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift | 4 ++-- .../ViewRelated/Blog/Subscribers/List/SubscribersView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift index efb5afcaf906..ac63b0a68314 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift @@ -1,6 +1,6 @@ import SwiftUI -struct SubscribersMenu: View { +struct SubscribersFiltersMenu: View { @ObservedObject var viewModel: SubscribersViewModel var body: some View { @@ -14,7 +14,7 @@ struct SubscribersMenu: View { Text("\(Strings.subscribers) \(count)") } } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "line.3.horizontal.decrease.circle") } } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift index 4840aab8ff8d..7ef6c6d74654 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift @@ -73,7 +73,7 @@ private struct SubscribersListView: View { } label: { Image(systemName: "plus") } - SubscribersMenu(viewModel: viewModel) + SubscribersFiltersMenu(viewModel: viewModel) } } .sheet(isPresented: $isShowingInviteView) { From c9304761cf6442a8b765a2bc27edcd7dac1dc0f4 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 12:44:19 -0400 Subject: [PATCH 78/90] Remove WPStyleGuide+Activity and fix an issue with ActivityFormattableContentView layout --- .../ActivityFormattableContentView.swift | 2 + .../Details/ActivityLogDetailsView.swift | 10 ---- .../ActivityContentStyles.swift | 53 +++++++++++++++-- .../Activity/WPStyleGuide+Activity.swift | 58 ------------------- 4 files changed, 50 insertions(+), 73 deletions(-) delete mode 100644 WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift index 3f07a6831291..a311fc6c6eb2 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift @@ -14,6 +14,8 @@ struct ActivityFormattableContentView: UIViewRepresentable { textView.backgroundColor = .clear textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.lineBreakMode = .byWordWrapping + textView.textContainer.maximumNumberOfLines = 0 textView.linkTextAttributes = [ .foregroundColor: UIAppColor.primary ] diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 4b119b5dfeee..fdd8fa0ad7ba 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -153,16 +153,6 @@ private struct ActivityHeaderView: View { } } -// MARK: - Actor Card - -private struct ActorCard: View { - let actor: ActivityActor - - var body: some View { - - } -} - // MARK: - Preview #Preview("Backup Activity") { diff --git a/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentStyles.swift b/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentStyles.swift index 2d8e28f3b4e3..97fc2b81fd50 100644 --- a/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentStyles.swift +++ b/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentStyles.swift @@ -1,20 +1,63 @@ import FormattableContentKit import WordPressShared +import WordPressUI class ActivityContentStyles: FormattableContentStyles { var attributes: [NSAttributedString.Key: Any] { - return WPStyleGuide.ActivityStyleGuide.contentRegularStyle + return contentRegularStyle } var rangeStylesMap: [FormattableRangeKind: [NSAttributedString.Key: Any]]? { return [ - .post: WPStyleGuide.ActivityStyleGuide.contentItalicStyle, - .comment: WPStyleGuide.ActivityStyleGuide.contentRegularStyle, - .italic: WPStyleGuide.ActivityStyleGuide.contentItalicStyle + .post: contentItalicStyle, + .comment: contentRegularStyle, + .italic: contentItalicStyle ] } - let linksColor: UIColor? = WPStyleGuide.ActivityStyleGuide.linkColor + let linksColor: UIColor? = UIAppColor.primary let quoteStyles: [NSAttributedString.Key: Any]? = nil let key: String = "ActivityContentStyles" + + // MARK: - Private Properties + + private var contentRegularStyle: [NSAttributedString.Key: Any] { + return [ + .paragraphStyle: contentParagraph, + .font: contentRegularFont, + .foregroundColor: UIColor.label + ] + } + + private var contentItalicStyle: [NSAttributedString.Key: Any] { + return [ + .paragraphStyle: contentParagraph, + .font: contentItalicFont, + .foregroundColor: UIColor.label + ] + } + + private var minimumLineHeight: CGFloat { + return contentFontSize * 1.3 + } + + private var contentParagraph: NSMutableParagraphStyle { + let style = NSMutableParagraphStyle() + style.minimumLineHeight = minimumLineHeight + style.lineBreakMode = .byWordWrapping + style.alignment = .natural + return style + } + + private var contentFontSize: CGFloat { + return UIFont.preferredFont(forTextStyle: .body).pointSize + } + + private var contentRegularFont: UIFont { + return WPStyleGuide.fontForTextStyle(.body) + } + + private var contentItalicFont: UIFont { + return WPStyleGuide.fontForTextStyle(.body, symbolicTraits: .traitItalic) + } } diff --git a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift b/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift deleted file mode 100644 index df46084d686a..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift +++ /dev/null @@ -1,58 +0,0 @@ -import UIKit -import Gridicons -import WordPressShared -import WordPressUI - -/// This class groups all of the styles used by all of the ActivityListViewController. -/// -extension WPStyleGuide { - - public struct ActivityStyleGuide { - - // MARK: - Public Properties - - public static let linkColor = UIAppColor.primary - - public static var contentRegularStyle: [NSAttributedString.Key: Any] { - return [ - .paragraphStyle: contentParagraph, - .font: contentRegularFont, - .foregroundColor: UIColor.label - ] - } - - public static var contentItalicStyle: [NSAttributedString.Key: Any] { - return [ - .paragraphStyle: contentParagraph, - .font: contentItalicFont, - .foregroundColor: UIColor.label - ] - } - - public static func backgroundColor() -> UIColor { - return .secondarySystemGroupedBackground - } - - // MARK: - Private Properties - - private static var minimumLineHeight: CGFloat { - return contentFontSize * 1.3 - } - - private static let contentParagraph = NSMutableParagraphStyle( - minLineHeight: minimumLineHeight, lineBreakMode: .byWordWrapping, alignment: .natural - ) - - private static var contentFontSize: CGFloat { - return UIFont.preferredFont(forTextStyle: .body).pointSize - } - - private static var contentRegularFont: UIFont { - return WPStyleGuide.fontForTextStyle(.body) - } - - private static var contentItalicFont: UIFont { - return WPStyleGuide.fontForTextStyle(.body, symbolicTraits: .traitItalic) - } - } -} From 0c04776ae1529d9a8fc3ea6af7bf9ac0abdf0f75 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 12:50:54 -0400 Subject: [PATCH 79/90] Fix layout of ActivityFormattableContentView --- .../Details/ActivityFormattableContentView.swift | 13 +++++++++++++ .../Activity/Details/ActivityLogDetailsView.swift | 1 + 2 files changed, 14 insertions(+) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift index a311fc6c6eb2..086aa2da2e1c 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift @@ -27,6 +27,19 @@ struct ActivityFormattableContentView: UIViewRepresentable { let styles = ActivityContentStyles() let formattedContent = formattableActivity.formattedContent(using: styles) textView.attributedText = formattedContent + + // Force layout to update intrinsic content size + textView.invalidateIntrinsicContentSize() + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { + guard let width = proposal.width else { return nil } + + // Calculate the size that fits within the proposed width + let targetSize = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude) + let size = uiView.sizeThatFits(targetSize) + + return CGSize(width: width, height: size.height) } func makeCoordinator() -> Coordinator { diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index fdd8fa0ad7ba..74199d7949c4 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -136,6 +136,7 @@ private struct ActivityHeaderView: View { formattableActivity: formattableActivity, blog: blog ) + .fixedSize(horizontal: false, vertical: true) } // Date and time From 4ab4e5532be3a581c8ce27771636b08aee483301 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 13:47:45 -0400 Subject: [PATCH 80/90] Add placeholders for empty fields --- .../ViewRelated/Activity/Details/ActivityLogDetailsView.swift | 4 ++++ .../ViewRelated/Activity/List/ActivityLogRowView.swift | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 74199d7949c4..712121f518ef 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -137,6 +137,10 @@ private struct ActivityHeaderView: View { blog: blog ) .fixedSize(horizontal: false, vertical: true) + } else { + Text("—") + .font(.body) + .foregroundColor(.secondary) } // Date and time diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift index 635cd142b763..fd7250de90b6 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift @@ -20,9 +20,10 @@ struct ActivityLogRowView: View { .foregroundColor(.secondary) } - Text(viewModel.title) + Text(viewModel.title.isEmpty ? "—" : viewModel.title) .font(.subheadline) .lineLimit(2) + .foregroundColor(viewModel.title.isEmpty ? .secondary : .primary) if let actor = viewModel.activity.actor { HStack(spacing: 6) { From 7b3ccf9bf316c066546a9f6e45d52fb85f6af5b9 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 13:48:01 -0400 Subject: [PATCH 81/90] Remove build instructions from CLAUDE.md --- CLAUDE.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a3b75932d407..4381f65766c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,18 +8,6 @@ WordPress for iOS is the official mobile app for WordPress that lets users creat Minimum requires iOS version is iOS 16. -## Common Development Commands - -### Build & Dependencies -- `rake build` - Build the app -- `xcodebuild -scheme -destination 'platform=iOS Simulator,name=iPhone 16' | bundle exec xcpretty` build targets from `Modules/`. - -### Testing -- `rake test` - Run all tests - -### Code Quality -- `rake lint` - Check for SwiftLint errors - ## High-Level Architecture ### Project Structure From 348bfb49ea9c09f16bc16ce8888d0b9e7623ae93 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 13:49:02 -0400 Subject: [PATCH 82/90] Add more instructions for CLAUDE --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4381f65766c8..5bd6546fc816 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ WordPress-iOS uses a modular architecture with the main app and separate Swift p - Follow Swift API Design Guidelines - Use strict access control modifiers where possible - Use four spaces (not tabs) +- Follow the standard formatting practices enforced by SwiftLint +- Don't create `body` for `View` that are too long - Use semantics text sizes like `.headline` ## Development Workflow From 4fa01efef96c72e54aeebd11530480dec76c90aa Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 14:07:49 -0400 Subject: [PATCH 83/90] Handle a scenario where logs are from existing user --- .../Activity/Details/ActivityLogDetailsView.swift | 2 +- .../Activity/Extensions/Activity+Extensions.swift | 12 ++++++++++++ .../Activity/List/ActivityLogRowView.swift | 3 ++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 712121f518ef..0de29f5f9d72 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -47,7 +47,7 @@ struct ActivityLogDetailsView: View { // Actor info VStack(alignment: .leading, spacing: 2) { - Text(actor.displayName) + Text(actor.displayName.isEmpty ? Activity.Strings.unknownUser : actor.displayName) .font(.headline) Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) diff --git a/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift index 994c522abb86..5430ff220b7e 100644 --- a/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift +++ b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift @@ -74,3 +74,15 @@ extension Activity { "audio": .audio ] } + +// MARK: - Shared Strings + +extension Activity { + enum Strings { + static let unknownUser = NSLocalizedString( + "activity.unknownUser", + value: "Unknown User", + comment: "Placeholder text shown when the activity actor's display name is empty" + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift index fd7250de90b6..28ad83a7709a 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift @@ -1,5 +1,6 @@ import SwiftUI import WordPressUI +import WordPressKit struct ActivityLogRowView: View { let viewModel: ActivityLogRowViewModel @@ -29,7 +30,7 @@ struct ActivityLogRowView: View { HStack(spacing: 6) { ActivityActorAvatarView(actor: actor, diameter: 16) HStack(spacing: 4) { - Text(actor.displayName) + Text(actor.displayName.isEmpty ? Activity.Strings.unknownUser : actor.displayName) .font(.footnote) .foregroundColor(.secondary) if let subtitle = viewModel.actorSubtitle { From 84ad4076ff81f1fd424f93da3ef2c92350a9f32e Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 14:10:22 -0400 Subject: [PATCH 84/90] Remove obsolete tests --- .../Utility/Collection+RotateTests.swift | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 Tests/KeystoneTests/Tests/Utility/Collection+RotateTests.swift diff --git a/Tests/KeystoneTests/Tests/Utility/Collection+RotateTests.swift b/Tests/KeystoneTests/Tests/Utility/Collection+RotateTests.swift deleted file mode 100644 index f6c8f1c30f2a..000000000000 --- a/Tests/KeystoneTests/Tests/Utility/Collection+RotateTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import XCTest -@testable import WordPress - -class CollectionRotateTests: XCTestCase { - override func setUp() { - super.setUp() - } - - func testRotateOne() { - let beforeCollection = [1, 2, 3, 4, 5] - let afterCollection = beforeCollection.rotateLeft(1) - XCTAssertEqual([2, 3, 4, 5, 1], afterCollection) - } - - func testRotateSame() { - let beforeCollection = [1, 2, 3, 4, 5] - let afterCollection = beforeCollection.rotateLeft(0) - XCTAssertEqual(beforeCollection, afterCollection) - } - - func testRotateNegative() { - let beforeCollection = [1, 2, 3, 4, 5] - let afterCollection = beforeCollection.rotateLeft(-1) - XCTAssertEqual([2, 3, 4, 5, 1], afterCollection) - } - - func testRotateOutOfIndex() { - let beforeCollection = [1, 2, 3, 4, 5] - let afterCollection = beforeCollection.rotateLeft(5) - XCTAssertEqual([1, 2, 3, 4, 5], afterCollection) - } -} From 156d2a97f019f62b2a79fe4cf79d327772000935 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 14:40:47 -0400 Subject: [PATCH 85/90] Update UI tests --- .../Screens/ActivityLogScreen.swift | 27 +++---------------- .../Activity/List/ActivityLogsView.swift | 1 + 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/Modules/Sources/UITestsFoundation/Screens/ActivityLogScreen.swift b/Modules/Sources/UITestsFoundation/Screens/ActivityLogScreen.swift index 3335f9636e80..fc2804ca0128 100644 --- a/Modules/Sources/UITestsFoundation/Screens/ActivityLogScreen.swift +++ b/Modules/Sources/UITestsFoundation/Screens/ActivityLogScreen.swift @@ -2,35 +2,16 @@ import ScreenObject import XCTest public class ActivityLogScreen: ScreenObject { - private let dateRangeButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.buttons["Date Range"].firstMatch - } - - private let activityTypeButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.buttons["Activity Type"].firstMatch - } - - var activityTypeButton: XCUIElement { activityTypeButtonGetter(app) } - var dateRangeButton: XCUIElement { dateRangeButtonGetter(app) } - - // Timeout duration to overwrite value defined in XCUITestHelpers - var duration: TimeInterval = 10.0 - public init(app: XCUIApplication = XCUIApplication()) throws { - try super.init( - expectedElementGetters: [ dateRangeButtonGetter, activityTypeButtonGetter ], - app: app - ) - } - - public static func isLoaded() -> Bool { - (try? ActivityLogScreen().isLoaded) ?? false + try super.init { + $0.collectionViews["activity_logs_list"].firstMatch + } } @discardableResult public func verifyActivityLogScreen(hasActivityPartial activityTitle: String) -> Self { XCTAssertTrue( - app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] %@", activityTitle)).firstMatch.waitForIsHittable(timeout: duration), + app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] %@", activityTitle)).firstMatch.waitForIsHittable(timeout: 10), "Activity Log Screen: \"\(activityTitle)\" activity not displayed.") return self } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index 265d3ef57833..fab16f6aa413 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -46,6 +46,7 @@ private struct ActivityLogsListView: View { } } .listStyle(.plain) + .accessibilityIdentifier("activity_logs_list") .overlay { if let response = viewModel.response { if response.isEmpty { From 521ff21e4a364bc6b694279ad16c9d9dd98e112a Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 20 Jun 2025 16:29:46 -0400 Subject: [PATCH 86/90] Fix release build --- .../ViewRelated/Activity/Details/ActivityLogDetailsView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 0de29f5f9d72..e921ab95b2b1 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -278,7 +278,6 @@ private extension ActivityLogDetailsView { // MARK: - Preview Helpers -#if DEBUG extension Blog { static var mock: Blog { // For previews, we'll return a dummy blog object @@ -286,4 +285,3 @@ extension Blog { return Blog() } } -#endif From e636424a67c829068877a15a72ff396330133f8d Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 23 Jun 2025 17:28:14 -0400 Subject: [PATCH 87/90] Use Duration --- .../WordPressUI/Views/DataView/DataViewSearchView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift index 4d486b80c19a..9353aa91b496 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift @@ -13,19 +13,19 @@ public struct DataViewSearchView Content /// Delay in milliseconds before executing search (default: 500ms) - let debounceDelay: UInt64 + let delay: Duration @State private var response: Response? @State private var error: Error? public init( searchText: String, - debounceDelay: UInt64 = 500, + delay: Duration = .milliseconds(500), search: @escaping () async throws -> Response, @ViewBuilder content: @escaping (Response) -> Content ) { self.searchText = searchText - self.debounceDelay = debounceDelay + self.delay = delay self.search = search self.content = content } @@ -49,7 +49,7 @@ public struct DataViewSearchView Date: Tue, 24 Jun 2025 16:02:38 -0400 Subject: [PATCH 88/90] Simplify CardView --- Modules/Sources/WordPressUI/Views/CardView.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/WordPressUI/Views/CardView.swift b/Modules/Sources/WordPressUI/Views/CardView.swift index 34e09bf6168b..5ba5b3261d98 100644 --- a/Modules/Sources/WordPressUI/Views/CardView.swift +++ b/Modules/Sources/WordPressUI/Views/CardView.swift @@ -13,16 +13,14 @@ public struct CardView: View { public var body: some View { VStack(alignment: .leading, spacing: 16) { - Group { - if let title { - Text(title.uppercased()) - .font(.caption) - .foregroundStyle(.secondary) - } - content() + if let title { + Text(title.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, alignment: .leading) + content() } + .frame(maxWidth: .infinity, alignment: .leading) .padding() .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( From 10dde0ee8d219c6d68b59e4d2ae9b9f7c7758d97 Mon Sep 17 00:00:00 2001 From: kean Date: Tue, 24 Jun 2025 16:03:17 -0400 Subject: [PATCH 89/90] Add TODO for iOS 17 --- .../Classes/ViewRelated/Activity/List/ActivityLogsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index fab16f6aa413..a619041c18f8 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -134,6 +134,7 @@ private struct ActivityLogsPaginatedForEach: View { ActivityLogRowView(viewModel: item) .onAppear { response.onRowAppeared(item) } .background { + // TODO: Switch to NavigationStack and Button on iOS 17 NavigationLink { ActivityLogDetailsView(activity: item.activity, blog: blog) } label: { From b4a18ae0184d53901db6acc02c21a55ddc41e71c Mon Sep 17 00:00:00 2001 From: kean Date: Tue, 24 Jun 2025 16:08:20 -0400 Subject: [PATCH 90/90] Move state change to onAppear --- .../Activity/List/ActivityTypeSelectionView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift index e52d60b5a7f4..fa0a787a601c 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift @@ -5,14 +5,13 @@ import WordPressUI struct ActivityTypeSelectionView: View { @ObservedObject var viewModel: ActivityLogsViewModel @Environment(\.dismiss) private var dismiss - @State private var selectedTypes: Set + @State private var selectedTypes: Set = [] @State private var availableActivityGroups: [WordPressKit.ActivityGroup] = [] @State private var isLoading = false @State private var error: Error? init(viewModel: ActivityLogsViewModel) { self.viewModel = viewModel - self._selectedTypes = State(initialValue: viewModel.parameters.activityTypes) } var body: some View { @@ -41,7 +40,10 @@ struct ActivityTypeSelectionView: View { } } .onAppear { - Task { await fetchActivityGroups() } + selectedTypes = viewModel.parameters.activityTypes + } + .task { + await fetchActivityGroups() } .navigationTitle(Strings.title) .navigationBarTitleDisplayMode(.inline)