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/.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/CLAUDE.md b/CLAUDE.md index 25ccd77c9b10..5bd6546fc816 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 @@ -54,8 +42,11 @@ 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 +## 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 e660ffbb7aac..2ca82ba055a0 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "29e01dfb9ab627b32c39ba2dee8ef4ff18afa7f8ac3ba52560cf900d6d11368c", + "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", @@ -390,7 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "cc7fd8a7ea609fc139e7b9d9f53b12c51002ddf4" + "revision" : "ae3961ce89ac0c43a90e88d4963a04aa92008443" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 53e3eadcce17..867b90fdae1a 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"), @@ -50,7 +49,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: "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. @@ -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"), 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/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/Modules/Sources/WordPressUI/Views/CardView.swift b/Modules/Sources/WordPressUI/Views/CardView.swift new file mode 100644 index 000000000000..5ba5b3261d98 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/CardView.swift @@ -0,0 +1,54 @@ +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) { + 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() +} diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift new file mode 100644 index 000000000000..7d7eb79abd31 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// 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: Response, + @ViewBuilder content: @escaping (Response.Element) -> Content + ) { + self.response = response + self.content = content + } + + public var body: some View { + ForEach(response.items) { item in + content(item) + .onAppear { + response.onRowAppeared(item) + } + } + if response.isLoading { + DataViewPagingFooterView(.loading) + } else if response.error != nil { + DataViewPagingFooterView(.failure) + .onRetry { response.loadMore() } + } + } +} diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift new file mode 100644 index 000000000000..6af5f54beec0 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift @@ -0,0 +1,122 @@ +import Foundation +import SwiftUI + +@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: DataViewPaginatedResponseProtocol { + @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 + @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? = nil, hasMore: Bool, nextPage: PageIndex?) { + self.items = items + self.total = total + self.hasMore = hasMore + self.nextPage = nextPage + } + } + + public var isEmpty: Bool { items.isEmpty } + + private var nextPage: PageIndex? + private let loadPage: (PageIndex?) async throws -> Page + + /// Creates a new paginated response handler. + /// + /// - 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(loadPage: @escaping (PageIndex?) async throws -> Page) async throws { + self.loadPage = loadPage + + let response = try await loadPage(nil) + 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 + @discardableResult + public func loadMore() -> Task? { + guard hasMore && !isLoading else { + return nil + } + error = nil + isLoading = true + return Task { + defer { isLoading = false } + do { + let response = try await loadPage(nextPage) + didLoad(response) + } catch { + self.error = error + throw error + } + } + } + + private func didLoad(_ response: Page) { + total = response.total + nextPage = response.nextPage + 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 onRowAppeared(_ row: Element) { + guard items.suffix(10).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) { + guard let index = items.firstIndex(where: { $0.id == id }) else { + return + } + items.remove(at: index) + if let total { + self.total = total - 1 + } + } +} diff --git a/Modules/Sources/WordPressUI/Views/LoadMoreFooterView.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPagingFooterView.swift similarity index 90% rename from Modules/Sources/WordPressUI/Views/LoadMoreFooterView.swift rename to Modules/Sources/WordPressUI/Views/DataView/DataViewPagingFooterView.swift index 47067a773b6c..87c59e180fc7 100644 --- a/Modules/Sources/WordPressUI/Views/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/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift new file mode 100644 index 000000000000..9353aa91b496 --- /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 delay: Duration + + @State private var response: Response? + @State private var error: Error? + + public init( + searchText: String, + delay: Duration = .milliseconds(500), + search: @escaping () async throws -> Response, + @ViewBuilder content: @escaping (Response) -> Content + ) { + self.searchText = searchText + self.delay = delay + 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: delay) + 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/Modules/Sources/WordPressUI/Views/InfoRow.swift b/Modules/Sources/WordPressUI/Views/InfoRow.swift new file mode 100644 index 000000000000..a8d389e4c366 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/InfoRow.swift @@ -0,0 +1,89 @@ +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) + .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 ?? "–") + .foregroundColor(AppColor.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() +} diff --git a/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift new file mode 100644 index 000000000000..ef53c9dac6d5 --- /dev/null +++ b/Modules/Tests/WordPressUIUnitTests/DataView/DataViewPaginatedResponseTests.swift @@ -0,0 +1,380 @@ +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 { pageIndex in + #expect(pageIndex == nil) // Initial load + return DataViewPaginatedResponse.Page( + items: expectedItems, + total: 10, + hasMore: true, + nextPage: 2 + ) + } + + // 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 { pageIndex in + pageRequests.append(pageIndex) + + switch pageIndex { + case nil: + return DataViewPaginatedResponse.Page( + items: [TestItem(id: 1, name: "Item 1")], + total: 3, + hasMore: true, + nextPage: 2 + ) + case 2: + return DataViewPaginatedResponse.Page( + items: [TestItem(id: 2, name: "Item 2")], + total: 3, + hasMore: true, + nextPage: 3 + ) + case 3: + return DataViewPaginatedResponse.Page( + items: [TestItem(id: 3, name: "Item 3")], + total: 3, + hasMore: false, + nextPage: nil + ) + default: + 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() + #expect(response.isLoading == false) + #expect(task == nil) + } + + // 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() + } + let id = pageIndex ?? 1 + return DataViewPaginatedResponse.Page( + items: [TestItem(id: id, name: "Item \(id)")], + total: 10, + hasMore: true, + nextPage: (pageIndex ?? 1) + 1 + ) + } + + // 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 { 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, + nextPage: 2 + ) + } else { + // Page 2 includes a duplicate item + 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, + nextPage: nil + ) + } + } + + // 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 { pageIndex in + loadCount += 1 + let id = pageIndex ?? 1 + return DataViewPaginatedResponse.Page( + items: [TestItem(id: id, name: "Item \(id)")], + total: 10, + hasMore: true, + nextPage: (pageIndex ?? 1) + 1 + ) + } + + // 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 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 { pageIndex in + if pageIndex == nil { + return DataViewPaginatedResponse.Page( + items: Array(items.prefix(20)), + total: 30, + hasMore: true, + nextPage: 2 + ) + } else { + return DataViewPaginatedResponse.Page( + items: Array(items.suffix(10)), + total: 30, + hasMore: false, + nextPage: nil + ) + } + } + + // WHEN row in the middle appears + response.onRowAppeared(response.items[0]) + + // THEN no load is triggered + #expect(response.isLoading == false) + + // WHEN row in the last 16 items appears + 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, + hasMore: page < 2, + nextPage: page < 2 ? page + 1 : nil + ) + } + + // 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.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 + 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, + 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 + return DataViewPaginatedResponse.Page( + items: [ + TestItem(id: 1, name: "Item 1"), + TestItem(id: 2, name: "Item 2") + ], + total: 2, + hasMore: false, + 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]) + #expect(response.total == 2) // Total remains unchanged + } +} 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)) } } diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 77b5c4ab1c71..830c338bea02 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,9 @@ * [**] 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] +* [*] Show when the downloadable backup expires in Backup List [#24597] 25.9 ----- 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 000000000000..af783225dbea Binary files /dev/null and b/Sources/Miniature/Assets.xcassets/AppIcon.appiconset/miniture-icon.png differ diff --git a/Sources/Miniature/Assets.xcassets/Contents.json b/Sources/Miniature/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/Sources/Miniature/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift new file mode 100644 index 000000000000..ed1675b112f4 --- /dev/null +++ b/Sources/Miniature/ContentView.swift @@ -0,0 +1,15 @@ +import SwiftUI +import WordPressUI +import WordPressData +import WordPressShared +import WordPressKit + +struct ContentView: View { + var body: some View { + Text("Hello, world!") + } +} + +#Preview { + ContentView() +} diff --git a/Sources/Miniature/MiniatureApp.swift b/Sources/Miniature/MiniatureApp.swift new file mode 100644 index 000000000000..6dc00877c29b --- /dev/null +++ b/Sources/Miniature/MiniatureApp.swift @@ -0,0 +1,14 @@ +import SwiftUI +import WordPressUI + +@main +struct MiniatureApp: App { + var body: some Scene { + WindowGroup { + NavigationView { + ContentView() + } + .tint(AppColor.primary) + } + } +} 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) + } +} 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/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift b/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift deleted file mode 100644 index 6c3cb341d1ab..000000000000 --- a/Tests/KeystoneTests/Tests/Stores/ActivityStoreTests.swift +++ /dev/null @@ -1,258 +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] = [], - success: @escaping (_ activities: [Activity], _ hasMore: Bool) -> Void, - failure: @escaping (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]) - } -} 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) - } -} diff --git a/Tests/MiniatureTests/MiniatureTests.swift b/Tests/MiniatureTests/MiniatureTests.swift new file mode 100644 index 000000000000..6474e23168f1 --- /dev/null +++ b/Tests/MiniatureTests/MiniatureTests.swift @@ -0,0 +1,18 @@ +// +// MiniatureTests.swift +// MiniatureTests +// +// Created by Alex on 6/19/25. +// Copyright © 2025 WordPress. All rights reserved. +// + +import Testing +@testable import Miniature + +struct MiniatureTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} 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/Stores/ActivityStore.swift b/WordPress/Classes/Stores/ActivityStore.swift deleted file mode 100644 index 5dd8f45a821a..000000000000 --- a/WordPress/Classes/Stores/ActivityStore.swift +++ /dev/null @@ -1,654 +0,0 @@ -import Foundation -import WordPressData -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 - } -} 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/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 379cb326d5c6..d6d2501c6675 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -26,7 +26,6 @@ public enum FeatureFlag: Int, CaseIterable { case pluginManagementOverhaul case nativeJetpackConnection case newsletterSubscribers - case dataViews /// Returns a boolean indicating if the feature is enabled. /// @@ -83,8 +82,6 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .newsletterSubscribers: return true - case .dataViews: - return BuildConfiguration.current == .debug } } @@ -128,7 +125,6 @@ extension FeatureFlag { case .readerGutenbergCommentComposer: "Gutenberg Comment Composer" case .nativeJetpackConnection: "Native Jetpack Connection" case .newsletterSubscribers: "Newsletter Subscribers" - case .dataViews: "Data Views" } } } diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index f1bc7226d0d1..9ad8bd8145bf 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressShared import WordPressData protocol ContentCoordinator { @@ -85,13 +86,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 = BackupsViewController(blog: blog) + backupViewController.navigationItem.largeTitleDisplayMode = .never + controller?.navigationController?.pushViewController(backupViewController, animated: true) + + WPAnalytics.track(.backupListOpened) } func displayScanWithSiteID(_ siteID: NSNumber?) throws { 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/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 182bf0ade6ab..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ /dev/null @@ -1,315 +0,0 @@ -import UIKit -import Gridicons -import WordPressData -import WordPressKit -import WordPressUI - -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 = WPStyleGuide.ActivityStyleGuide.getGridiconTypeForActivity(activity) { - imageView.contentMode = .center - imageView.backgroundColor = WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity) - 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 JetpackActivityLogViewController { - return "activity_log" - } else if presenter is BackupListViewController { - 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/ActivityListRow.swift b/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift deleted file mode 100644 index 51d77b068f3d..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift +++ /dev/null @@ -1,33 +0,0 @@ -import UIKit -import WordPressData - -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 - } -} diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift b/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift deleted file mode 100644 index a1ec2c7be0e5..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift +++ /dev/null @@ -1,397 +0,0 @@ -import WordPressFlux -import WordPressKit -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 - } -} diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift deleted file mode 100644 index 221b987b1eb3..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift +++ /dev/null @@ -1,133 +0,0 @@ -import UIKit -import Gridicons -import WordPressKit -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 = Style.getColorByActivityStatus(activity) - if let iconImage = Style.getIconForActivity(activity) { - 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! -} - -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/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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift deleted file mode 100644 index 14d65fc7363c..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift +++ /dev/null @@ -1,154 +0,0 @@ -import Foundation -import WordPressFlux -import WordPressKit -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() - } -} 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 1d8aa86b6b99..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift +++ /dev/null @@ -1,20 +0,0 @@ -import UIKit -import WordPressData - -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 5c78ba899683..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import Combine -import WordPressData -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/Activity/BaseActivityListViewController.swift b/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift deleted file mode 100644 index cfe96dc590d2..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift +++ /dev/null @@ -1,555 +0,0 @@ -import Foundation -import SVProgressHUD -import WordPressData -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) - } - } -} 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/Activity/Details/ActivityFormattableContentView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift new file mode 100644 index 000000000000..086aa2da2e1c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityFormattableContentView.swift @@ -0,0 +1,84 @@ +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.textContainer.lineBreakMode = .byWordWrapping + textView.textContainer.maximumNumberOfLines = 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 + + // 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 { + 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/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift new file mode 100644 index 000000000000..47755b08526c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -0,0 +1,50 @@ +import UIKit +import SwiftUI +import WordPressKit + +/// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers +enum ActivityLogDetailsCoordinator { + + static func presentRestore(activity: Activity, blog: Blog, rewindStatus: RewindStatus) { + guard let viewController = UIViewController.topViewController, + let siteRef = JetpackSiteRef(blog: blog), + activity.isRewindable, + activity.rewindID != nil else { + return + } + + let isAwaitingCredentials = rewindStatus.state == .awaitingCredentials + + 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+Preview.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift new file mode 100644 index 000000000000..fcb6a186d27d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.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/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift new file mode 100644 index 000000000000..e921ab95b2b1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -0,0 +1,287 @@ +import SwiftUI +import WordPressKit +import WordPressUI +import WordPressShared +import Gridicons +import UIKit + +struct ActivityLogDetailsView: View { + let activity: Activity + let blog: Blog + + @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, blog: blog, formattableActivity: formattableActivity) + if activity.isRewindable { + restoreSiteCard + } + if let actor = activity.actor { + makeActorCard(for: actor) + } + } + .padding() + } + .navigationTitle(Strings.eventTitle) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + 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.isEmpty ? Activity.Strings.unknownUser : 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: { + handleRestoreTapped() + }) { + ZStack { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + .opacity(isLoadingRewindStatus ? 0 : 1) + + if isLoadingRewindStatus { + ProgressView() + } + } + } + .buttonStyle(.borderedProminent) + .disabled(isLoadingRewindStatus) + + 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 + +private struct ActivityHeaderView: View { + let activity: Activity + let blog: Blog + let formattableActivity: FormattableActivity + + var body: some View { + 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) { + // Activity title/summary + Text(activity.summary.localizedCapitalized) + .font(.title3.weight(.medium)) + .lineLimit(2) + + // Activity details + if !activity.text.isEmpty { + ActivityFormattableContentView( + formattableActivity: formattableActivity, + blog: blog + ) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("—") + .font(.body) + .foregroundColor(.secondary) + } + + // Date and time + HStack(spacing: 4) { + Image(systemName: "calendar") + .foregroundStyle(.tertiary) + Text(activity.published.formatted(date: .abbreviated, time: .standard)) + .foregroundStyle(.secondary) + } + .font(.footnote) + .padding(.top, 8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Preview + +#Preview("Backup Activity") { + NavigationView { + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockBackupActivity, + blog: Blog.mock + ) + } +} + +#Preview("Plugin Update") { + NavigationView { + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockPluginActivity, + blog: Blog.mock + ) + } +} + +#Preview("Login Succeeded") { + NavigationView { + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockLoginActivity, + blog: Blog.mock + ) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let eventTitle = NSLocalizedString( + "activityDetail.title", + value: "Event", + comment: "Title for the activity detail view" + ) + + static let user = NSLocalizedString( + "activityDetail.section.user", + value: "User", + 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", + comment: "Button title for restoring a backup" + ) + + static let download = NSLocalizedString( + "activityDetail.download.button", + value: "Download", + comment: "Button title for downloading a backup" + ) +} + +// 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 { + 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 + +extension Blog { + static var mock: Blog { + // For previews, we'll return a dummy blog object + // In real previews, this should be provided by the parent view + return Blog() + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift new file mode 100644 index 000000000000..5430ff220b7e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Extensions/Activity+Extensions.swift @@ -0,0 +1,88 @@ +import SwiftUI +import Gridicons +import DesignSystem +import WordPressKit + +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 + ] +} + +// 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/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/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 - } -} 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() - } - } -} 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/JetpackActivityLogViewController.swift b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift deleted file mode 100644 index e41280b8fae5..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift +++ /dev/null @@ -1,65 +0,0 @@ -import UIKit -import Combine -import WordPressData - -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/Activity/List/ActivityLogRowView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift new file mode 100644 index 000000000000..28ad83a7709a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import WordPressUI +import WordPressKit + +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.isEmpty ? "—" : viewModel.title) + .font(.subheadline) + .lineLimit(2) + .foregroundColor(viewModel.title.isEmpty ? .secondary : .primary) + + if let actor = viewModel.activity.actor { + HStack(spacing: 6) { + ActivityActorAvatarView(actor: actor, diameter: 16) + HStack(spacing: 4) { + Text(actor.displayName.isEmpty ? Activity.Strings.unknownUser : actor.displayName) + .font(.footnote) + .foregroundColor(.secondary) + if let subtitle = viewModel.actorSubtitle { + Text("·") + .font(.footnote) + .foregroundColor(.secondary) + Text(subtitle) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + .padding(.top, 4) + } + } + } + } + + 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..c11cacb95c49 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftUI +import UIKit +import WordPressKit +import WordPressUI +import FormattableContentKit + +struct ActivityLogRowViewModel: Identifiable { + let id: String + var actorSubtitle: 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 + if let actor = activity.actor { + if !actor.role.isEmpty { + actorSubtitle = 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 = activity.icon + self.tintColor = Color(activity.statusColor) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift new file mode 100644 index 000000000000..dc2c433c19be --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift @@ -0,0 +1,151 @@ +import SwiftUI +import WordPressKit +import WordPressShared + +struct ActivityLogsFiltersMenu: 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 + if !viewModel.isBackupMode { + activityTypeFilter + } + if !viewModel.parameters.isEmpty { + resetFiltersButton + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .sheet(isPresented: $isShowingActivityTypePicker) { + NavigationView { + ActivityTypeSelectionView(viewModel: viewModel) + } + } + .sheet(isPresented: $isShowingStartDatePicker) { + DatePickerSheet( + title: Strings.startDate, + selection: $viewModel.parameters.startDate, + isPresented: $isShowingStartDatePicker, + viewModel: viewModel + ) + } + .sheet(isPresented: $isShowingEndDatePicker) { + DatePickerSheet( + title: Strings.endDate, + selection: $viewModel.parameters.endDate, + isPresented: $isShowingEndDatePicker, + viewModel: viewModel + ) + } + } + + private var dateFilters: some View { + Group { + // Start Date + Button { + // Track analytics for date filter tap + WPAnalytics.track(.activitylogFilterbarRangeButtonTapped) + 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 { + // Track analytics for date filter tap + WPAnalytics.track(.activitylogFilterbarRangeButtonTapped) + 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 { + WPAnalytics.track(.activitylogFilterbarTypeButtonTapped) + 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) { + WPAnalytics.track(.activitylogFilterbarResetRange) + WPAnalytics.track(.activitylogFilterbarResetType) + 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 + var viewModel: ActivityLogsViewModel? + + @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..a619041c18f8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -0,0 +1,154 @@ +import SwiftUI +import WordPressUI +import WordPressKit +import WordPressShared + +struct ActivityLogsView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + let content = Group { + if !viewModel.searchText.isEmpty { + ActivityLogsSearchView(viewModel: viewModel) + } else { + ActivityLogsListView(viewModel: viewModel) + } + } + .navigationTitle(viewModel.isBackupMode ? Strings.backupsTitle : Strings.activityTitle) + + if viewModel.isBackupMode { + content + } else { + content.searchable(text: $viewModel.searchText) + } + } +} + +private struct ActivityLogsListView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + List { + if let backupTracker = viewModel.backupTracker { + DownloadableBackupSection(backupTracker: backupTracker) + } + + if let response = viewModel.response { + ActivityLogsPaginatedForEach(response: response, blog: viewModel.blog) + + if viewModel.isFreePlan { + Text(Strings.freePlanNotice) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .listRowSeparator(.hidden) + } + } + } + .listStyle(.plain) + .accessibilityIdentifier("activity_logs_list") + .overlay { + if let response = viewModel.response { + if response.isEmpty { + EmptyStateView( + viewModel.isBackupMode ? Strings.emptyBackups : Strings.empty, + systemImage: "archivebox", + description: viewModel.isBackupMode ? Strings.emptyBackupsSubtitle : nil + ) + } + } else if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + EmptyStateView.failure(error: error) { + Task { await viewModel.refresh() } + } + } + } + .onAppear { + viewModel.onAppear() + } + .onDisappear { + viewModel.onDisappear() + } + .refreshable { + await viewModel.refresh() + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + ActivityLogsFiltersMenu(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, blog: viewModel.blog) + } + } +} + +private struct ActivityLogsPaginatedForEach: View { + @ObservedObject var response: ActivityLogsPaginatedResponse + let blog: Blog + + 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 { + // TODO: Switch to NavigationStack and Button on iOS 17 + NavigationLink { + ActivityLogDetailsView(activity: item.activity, blog: blog) + } 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") + 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 new file mode 100644 index 000000000000..b80b8cb1b367 --- /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: 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("activityLogs.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..4a32bad4a7a5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -0,0 +1,193 @@ +import Foundation +import WordPressKit +import WordPressUI +import WordPressShared + +typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse + +@MainActor +final class ActivityLogsViewModel: ObservableObject { + let blog: Blog + let isBackupMode: Bool + let backupTracker: DownloadableBackupTracker? + + @Published var searchText = "" + @Published var parameters = GetActivityLogsParameters() { + didSet { + trackParameterChanges(oldValue: oldValue, newValue: parameters) + 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, isBackupMode: Bool = false) { + self.blog = blog + self.isBackupMode = isBackupMode + self.backupTracker = isBackupMode ? DownloadableBackupTracker(blog: blog) : nil + } + + func onAppear() { + backupTracker?.startTracking() + + guard response == nil else { return } + onRefreshNeeded() + } + + func onRefreshNeeded() { + refreshTask?.cancel() + refreshTask = Task { + await refresh() + } + } + + func refresh() async { + isLoading = true + error = nil + + backupTracker?.refreshBackupStatus() + + 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 onDisappear() { + backupTracker?.stopTracking() + } + + 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, 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: pageSize, + searchText: searchText, + parameters: parameters, + rewindable: isBackupMode ? true : nil + ) + let viewModels = await makeViewModels(for: activities) + return ActivityLogsPaginatedResponse.Page( + items: viewModels, + hasMore: hasMore, + nextPage: hasMore ? offset + activities.count : nil + ) + } + } + + // 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] { + 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(), rewindable: Bool? = nil) 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), + rewindable: rewindable, + 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..fa0a787a601c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift @@ -0,0 +1,171 @@ +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 + } + + var body: some View { + Group { + if isLoading && availableActivityGroups.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let 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 { + selectedTypes = viewModel.parameters.activityTypes + } + .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/Activity/List/BackupsViewController.swift b/WordPress/Classes/ViewRelated/Activity/List/BackupsViewController.swift new file mode 100644 index 000000000000..cc38b84e8d9c --- /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/Activity/List/DownloadableBackupSection.swift b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift new file mode 100644 index 000000000000..57b98a73a532 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupSection.swift @@ -0,0 +1,218 @@ +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 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( + backup: backup, + url: url, + validUntil: validUntil, + backupTracker: backupTracker + ) + } + } + .padding(.horizontal) + .padding(.top, 8) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } +} + +// MARK: - Private Views + +private struct BackupInProgressView: View { + let backup: JetpackBackup + let progress: Int + + private var progressFloat: Float { + max(Float(progress) / 100, 0.05) // Show at least 5% for UX + } + + var body: some View { + 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() + } + } + } +} + +private struct BackupDownloadHeaderView: View { + 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 { + 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) + } + + 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() + } + } +} + +// MARK: - Strings + +private enum Strings { + enum InProgress { + static let title = NSLocalizedString( + "backup.inProgress.title", + value: "Creating downloadable backup", + comment: "Title shown when a downloadable backup is being created" + ) + + static let message = NSLocalizedString( + "backup.inProgress.message", + 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 to download", + comment: "Title shown when a backup is ready to download" + ) + + static let message = NSLocalizedString( + "backup.download.header.message", + 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 new file mode 100644 index 000000000000..809d81a4534d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/DownloadableBackupTracker.swift @@ -0,0 +1,136 @@ +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 backup: JetpackBackup? + + private let blog: Blog + private var refreshTask: Task? + + 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 + } + + /// Refreshes backup status and starts continuous polling with adaptive delays. + func refreshBackupStatus() { + guard let siteRef = JetpackSiteRef(blog: blog), siteRef.hasBackup else { + return + } + + refreshTask?.cancel() + refreshTask = Task { + var pollCount = 0 + + // Fetch status immediately + await fetchBackupStatus(siteRef: siteRef) + + // Continue polling while on the screen + while !Task.isCancelled { + let delay: UInt64 + + // 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) + delay = UInt64(seconds) * 1_000_000_000 + pollCount += 1 + } + + try? await Task.sleep(nanoseconds: delay) + + guard !Task.isCancelled else { break } + + await fetchBackupStatus(siteRef: siteRef) + } + } + } + + private func fetchBackupStatus(siteRef: JetpackSiteRef) async { + do { + let backupService = JetpackBackupService(coreDataStack: ContextManager.shared) + let statuses = try await backupService.getAllBackupStatus(for: siteRef) + + guard !Task.isCancelled else { return } + + // Get the most recently started backup + 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 } + 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 backup else { + return + } + + let downloadID = backup.downloadID + + // Clear local state immediately for better UX + self.backup = nil + DDLogInfo("[DownloadableBackup] Dismissing backup notice for download ID: \(downloadID)") + + // 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/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.") - } -} 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 - } - -} 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift b/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift deleted file mode 100644 index 3db18bd89126..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift +++ /dev/null @@ -1,122 +0,0 @@ -import UIKit -import Gridicons -import WordPressKit -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 - } - - public static func getGridiconTypeForActivity(_ activity: Activity) -> GridiconType? { - return stringToGridiconTypeMapping[activity.gridicon] - } - - public static func getIconForActivity(_ activity: Activity) -> UIImage? { - guard let gridiconType = stringToGridiconTypeMapping[activity.gridicon] else { - return nil - } - - return UIImage.gridicon(gridiconType).imageWithTintColor(.white) - } - - 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) - } - } - - // 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) - } - - // 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 - ] - } -} 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 49b751137843..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import WordPressKit -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" - } -} 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 bccaddfbddf2..67020910982c 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,22 +1,14 @@ import UIKit +import SwiftUI import WordPressData 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? - - let store = StoreContainer.shared.activity + private var hostingController: UIHostingController? // MARK: - Views @@ -28,17 +20,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) { @@ -59,7 +40,9 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { override func prepareForReuse() { super.prepareForReuse() - tableView.dataSource = nil + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil } // MARK: - View setup @@ -67,9 +50,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 @@ -83,8 +63,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) @@ -94,6 +74,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) @@ -125,53 +143,10 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { // MARK: - Navigation private func showActivityLog(for blog: Blog, tapSource: String) { - guard let activityLogController = JetpackActivityLogViewController(blog: blog) else { - return - } + let activityLogController = ActivityLogsViewController(blog: blog) presentingViewController?.navigationController?.pushViewController(activityLogController, animated: true) - WPAnalytics.track(.activityLogViewed, - withProperties: [ - WPAppAnalyticsKeyTapSource: tapSource - ]) - } - -} - -// 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) + WPAnalytics.track(.activityLogViewed, withProperties: [WPAppAnalyticsKeyTapSource: tapSource]) } } 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) + } + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 93c0a121b08f..9c9d6a9c58ae 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -149,9 +149,7 @@ extension BlogDetailsViewController { } @objc public func showActivity() { - guard let controller = JetpackActivityLogViewController(blog: blog) else { - return wpAssertionFailure("failed to instantiate") - } + let controller = ActivityLogsViewController(blog: blog) controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) @@ -175,10 +173,12 @@ extension BlogDetailsViewController { } @objc public func showBackup() { - guard let backupListVC = BackupListViewController.withJPBannerForBlog(blog) else { - return wpAssertionFailure("failed to instantiate") - } - presentationDelegate?.presentBlogDetailsViewController(backupListVC) + let controller = BackupsViewController(blog: blog) + controller.navigationItem.largeTitleDisplayMode = .never + + presentationDelegate?.presentBlogDetailsViewController(controller) + + WPAnalytics.track(.backupListOpened) } @objc public func showThemes() { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift index 349af32f4c5a..5a3e836e02c7 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,9 +16,7 @@ struct SubscriberDetailsView: View { @Environment(\.dismiss) var dismiss - private var onDeleted: ((Int) -> Void)? - - init(viewModel: SubsriberDetailsViewModel) { + init(viewModel: SubscriberDetailsViewModel) { self.viewModel = viewModel } @@ -53,7 +51,7 @@ struct SubscriberDetailsView: View { SubscriberDetailsHeaderView(subscriber: info) } if let detailsError { - SubscriberDetailsCardView { + CardView { EmptyStateView.failure(error: detailsError) { Task { await refresh() } } @@ -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,17 +120,11 @@ 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 { - 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 { @@ -146,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") @@ -157,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) @@ -175,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 { @@ -201,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 { @@ -244,7 +235,7 @@ private struct SubscriberStatsView: View { let stats: SubscribersServiceRemote.GetSubscriberStatsResponse var body: some View { - SubscriberDetailsCardView { + CardView { HStack { SubsciberStatsRow( systemImage: "envelope", @@ -272,37 +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 let title: String @@ -323,36 +283,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 { emailsSent.formatted(.number.notation(.compactName)) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift similarity index 57% rename from WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubsriberDetailsViewModel.swift rename to WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsViewModel.swift index 76d97ecac703..c5de3055d079 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,22 +27,22 @@ 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 { - try await blog.getSubscribersService() + try await blog.makeSubscribersService() .getSubsciberDetails(siteID: blog.dotComSiteID, subscriberID: subscriberID) } func getStats() async throws -> SubscribersServiceRemote.GetSubscriberStatsResponse { - try await blog.getSubscribersService() + try await blog.makeSubscribersService() .getSubsciberStats(siteID: blog.dotComSiteID, subscriberID: subscriberID) } func delete(_ subscriber: SubscribersServiceRemote.SubsciberBasicInfoResponse) async throws { - try await blog.getSubscribersService() + try await blog.makeSubscribersService() .deleteSubscriber(subscriber, siteID: blog.dotComSiteID) } @@ -52,24 +52,3 @@ struct SubsriberDetailsViewModel { return absolute + " (\(relative))" } } - -extension SubscribersServiceRemote { - 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) - }) - } - } - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift index 78cca9391f85..52fa7cd76fe1 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Helpers/SubscribersBlog.swift @@ -26,7 +26,7 @@ struct SubscribersBlog { SubscribersBlog(dotComSiteID: 1, getRestAPI: { nil }) } - func getSubscribersService() 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/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..b1ffec7715ae 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.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 80a67b8de9ff..4939f250aa34 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 @@ -88,18 +86,17 @@ final class SubscriberRowViewModel: Identifiable { self.details = subscriber.dateSubscribed.toShortString() } - func makeDetailsViewModel() -> SubsriberDetailsViewModel { - SubsriberDetailsViewModel(blog: blog, subscriber: subscriber) + func makeDetailsViewModel() -> SubscriberDetailsViewModel { + SubscriberDetailsViewModel(blog: blog, subscriber: subscriber) } func delete() { isDeleting = true Task { do { - try await blog.getSubscribersService() + try await blog.makeSubscribersService() .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/SubscribersMenu.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift index 30e90aef141d..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 { @@ -10,11 +10,11 @@ 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") + Image(systemName: "line.3.horizontal.decrease.circle") } } 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 8074d2e81bbc..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) { @@ -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 { - LoadMoreFooterView(.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) } } } @@ -126,32 +101,29 @@ private struct SubscribersPaginatedForEach: View { @ObservedObject var response: SubscribersPaginatedResponse var body: some View { - ForEach(response.items) { - makeRow(with: $0) - } - if response.isLoading { - LoadMoreFooterView(.loading) - } else if response.error != nil { - LoadMoreFooterView(.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..39fcc4c3ca26 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,17 +60,42 @@ 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)" + func makeFormattedSubscribersCount(for response: SubscribersPaginatedResponse) -> String? { + guard let count = response.total else { + return nil + } + guard !parameters.filters.isEmpty, let totalCount else { + return "\(count)" } - guard let totalCount else { - return "\(response.total)" + return String(format: Strings.nOutOf, count.description, totalCount.description) + } + + private func makeResponse( + parameters: SubscribersServiceRemote.GetSubscribersParameters, + search: String? = nil + ) async throws -> SubscribersPaginatedResponse { + return try await SubscribersPaginatedResponse { [blog] page in + let service = try blog.makeSubscribersService() + let response = try await service.getSubscribers( + siteID: blog.dotComSiteID, + page: page ?? 1, + 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 + ) } - return String(format: Strings.nOutOf, response.total.description, totalCount.description) } } 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 64f00a09c589..61e32a358619 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift @@ -1,6 +1,7 @@ import Foundation import WordPressKit import WordPressShared +import WordPressUI struct JetpackRestoreCompleteConfiguration { let title: String @@ -103,8 +104,9 @@ class BaseRestoreCompleteViewController: UIViewController { self?.secondaryButtonTapped(from: sender) } + view.backgroundColor = .systemBackground 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 Status/BaseRestoreStatusViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift index a26d461f8c36..7dc858d4baff 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 WordPressData import WordPressShared @@ -92,8 +93,9 @@ class BaseRestoreStatusViewController: UIViewController { statusView.update(progress: 0, progressTitle: configuration.placeholderProgressTitle, progressDescription: nil) + view.backgroundColor = .systemBackground view.addSubview(statusView) - view.pinSubviewToAllEdges(statusView) + statusView.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 c2e752c0d8ca..f11eee5da70e 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift @@ -2,6 +2,7 @@ import UIKit import WordPressFlux import WordPressKit import WordPressShared +import WordPressUI class JetpackRestoreWarningViewController: UIViewController { @@ -79,9 +80,9 @@ class JetpackRestoreWarningViewController: UIViewController { self?.dismiss(animated: true) } - warningView.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemBackground view.addSubview(warningView) - view.pinSubviewToAllEdges(warningView) + warningView.pinEdges(to: view.safeAreaLayoutGuide) } } 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.. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +