diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000000..3ea346da52b2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(ls:*)", + "Bash(rg:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(tree:*)", + "Bash(git:log,status,diff,branch)", + ], + "deny": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 25ccd77c9b10..e69c9ec831a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ WordPress-iOS uses a modular architecture with the main app and separate Swift p - Use strict access control modifiers where possible - Use four spaces (not tabs) -### Development Workflow +## Development Workflow - Branch from `trunk` (main branch) - PR target should be `trunk` - When writing commit messages, never include references to Claude diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 1ded252e6fb7..22d66276f8bd 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "29e01dfb9ab627b32c39ba2dee8ef4ff18afa7f8ac3ba52560cf900d6d11368c", + "originHash" : "1aecad5b79a89459675bbe1705ca2f091cedc0a3874c3ce3e07aef3d60f2b061", "pins" : [ { "identity" : "alamofire", @@ -390,7 +390,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "cc7fd8a7ea609fc139e7b9d9f53b12c51002ddf4" + "revision" : "30dadcab01a980eb16976340c1e9e8a9527ddc05" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 11396b5c0e03..c5ef1152b7a9 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "cc7fd8a7ea609fc139e7b9d9f53b12c51002ddf4" // see wpios-edition branch + revision: "30dadcab01a980eb16976340c1e9e8a9527ddc05" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift index 0589aadb2a49..7d7eb79abd31 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedForEach.swift @@ -29,9 +29,7 @@ public struct DataViewPaginatedForEach: DataViewPaginatedResponseProtocol { - @Published public private(set) var total = 0 + @Published public private(set) var total: Int? @Published public private(set) var items: [Element] = [] @Published public private(set) var hasMore = true @Published public private(set) var isLoading = false @@ -26,11 +26,11 @@ public final class DataViewPaginatedResponse: /// Result of a paginated load operation. public struct Page { public let items: [Element] - public let total: Int + public let total: Int? public let hasMore: Bool public let nextPage: PageIndex? - public init(items: [Element], total: Int, hasMore: Bool, nextPage: PageIndex?) { + public init(items: [Element], total: Int? = nil, hasMore: Bool, nextPage: PageIndex?) { self.items = items self.total = total self.hasMore = hasMore @@ -115,6 +115,8 @@ public final class DataViewPaginatedResponse: return } items.remove(at: index) - total -= 1 + if let total { + self.total = total - 1 + } } } diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift new file mode 100644 index 000000000000..4d486b80c19a --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewSearchView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// A generic search view that works with DataViewPaginatedResponse. +/// Provides search functionality with debouncing, loading states, and error handling. +public struct DataViewSearchView: View { + /// The search text to monitor for changes + let searchText: String + + /// The async function to perform the search + let search: () async throws -> Response + + /// Content builder for the paginated list + let content: (Response) -> Content + + /// Delay in milliseconds before executing search (default: 500ms) + let debounceDelay: UInt64 + + @State private var response: Response? + @State private var error: Error? + + public init( + searchText: String, + debounceDelay: UInt64 = 500, + search: @escaping () async throws -> Response, + @ViewBuilder content: @escaping (Response) -> Content + ) { + self.searchText = searchText + self.debounceDelay = debounceDelay + self.search = search + self.content = content + } + + public var body: some View { + List { + if let response { + content(response) + } else if error == nil { + DataViewPagingFooterView(.loading) + } + } + .listStyle(.plain) + .overlay { + if let response, response.items.isEmpty { + EmptyStateView.search() + } else if let error { + EmptyStateView.failure(error: error) + } + } + .task(id: searchText) { + error = nil + do { + try await Task.sleep(for: .milliseconds(debounceDelay)) + let response = try await search() + guard !Task.isCancelled else { return } + self.response = response + } catch { + guard !Task.isCancelled else { return } + self.response = nil + self.error = error + } + } + } +} diff --git a/Modules/Sources/WordPressUI/Views/EmptyStateView.swift b/Modules/Sources/WordPressUI/Views/EmptyStateView.swift index fe0df57afb0a..6317c3025caa 100644 --- a/Modules/Sources/WordPressUI/Views/EmptyStateView.swift +++ b/Modules/Sources/WordPressUI/Views/EmptyStateView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressShared public struct EmptyStateView: View { @ViewBuilder let label: () -> Label @@ -73,6 +74,30 @@ private struct EmptyStateViewLabelStyle: LabelStyle { } } +extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == EmptyView { + public static func search() -> Self { + EmptyStateView( + AppLocalizedString("emptyStateView.noSearchResult.title", value: "No Results", comment: "Shared empty state view"), + systemImage: "magnifyingglass", + description: AppLocalizedString("emptyStateView.noSearchResult.description", value: "Try a new search", comment: "Shared empty state view") + ) + } +} + +extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == Button? { + public static func failure(error: Error, onRetry: (() -> Void)? = nil) -> Self { + EmptyStateView { + Label(AppLocalizedString("shared.error.generic", value: "Something went wrong", comment: "A generic error message"), systemImage: "exclamationmark.circle") + } description: { + Text(error.localizedDescription) + } actions: { + if let onRetry { + Button(AppLocalizedString("shared.button.retry", value: "Retry", comment: "A shared button title used in different contexts"), action: onRetry) + } + } + } +} + #Preview("Standard") { EmptyStateView("You don't have any tags", systemImage: "magnifyingglass", description: "Tags created here can be easily added to new posts") } diff --git a/WordPress/Classes/Extensions/EmptyStateView+Extensions.swift b/WordPress/Classes/Extensions/EmptyStateView+Extensions.swift deleted file mode 100644 index 5fe00fa7502f..000000000000 --- a/WordPress/Classes/Extensions/EmptyStateView+Extensions.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import SwiftUI -import WordPressUI - -extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == EmptyView { - static func search() -> Self { - EmptyStateView( - NSLocalizedString("emptyStateView.noSearchResult.title", value: "No Results", comment: "Shared empty state view"), - systemImage: "magnifyingglass", - description: NSLocalizedString("emptyStateView.noSearchResult.description", value: "Try a new search", comment: "Shared empty state view") - ) - } -} - -extension EmptyStateView where Label == SwiftUI.Label, Description == Text?, Actions == Button? { - static func failure(error: Error, onRetry: (() -> Void)? = nil) -> Self { - EmptyStateView { - Label(SharedStrings.Error.generic, systemImage: "exclamationmark.circle") - } description: { - Text(error.localizedDescription) - } actions: { - if let onRetry { - Button(SharedStrings.Button.retry, action: onRetry) - } - } - } -} diff --git a/WordPress/Classes/Utility/SharedStrings.swift b/WordPress/Classes/Utility/SharedStrings.swift index 423770a10f3e..853128af78b6 100644 --- a/WordPress/Classes/Utility/SharedStrings.swift +++ b/WordPress/Classes/Utility/SharedStrings.swift @@ -20,6 +20,7 @@ enum SharedStrings { static let copyLink = NSLocalizedString("shared.button.copyLink", value: "Copy Link", comment: "A shared button title used in different contexts") static let `continue` = NSLocalizedString("shared.button.continue", value: "Continue", comment: "A shared button title used in different contexts") static let undo = NSLocalizedString("shared.button.undo", value: "Undo", comment: "A shared button title used in different contexts") + static let clear = NSLocalizedString("shared.button.clear", value: "Clear", comment: "A shared button title used in different contexts") } enum Misc { @@ -36,7 +37,7 @@ enum SharedStrings { } enum Error { - static let generic = NSLocalizedString("shared.error.geneirc", value: "Something went wrong", comment: "A generic error message") + static let generic = NSLocalizedString("shared.error.generic", value: "Something went wrong", comment: "A generic error message") static let refreshFailed = NSLocalizedString("shared.error.failiedToReloadData", value: "Failed to update data", comment: "A generic error title indicating that a screen failed to fetch the latest data") } diff --git a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift index 72b47d9268c4..eef433e3fa7b 100644 --- a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift @@ -38,7 +38,6 @@ class JetpackActivityLogViewController: BaseActivityListViewController { guard let siteRef = JetpackSiteRef(blog: blog) else { return nil } - let isFreeWPCom = blog.isHostedAtWPcom && !blog.hasPaidPlan self.init(site: siteRef, store: StoreContainer.shared.activity, isFreeWPCom: isFreeWPCom) } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift new file mode 100644 index 000000000000..1a31068c70ae --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import WordPressUI + +struct ActivityLogRowView: View { + let viewModel: ActivityLogRowViewModel + + var body: some View { + HStack(alignment: .center, spacing: 12) { + icon + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(viewModel.subtitle) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Spacer() + Text(viewModel.time) + .font(.caption2) + .foregroundColor(.secondary) + } + + Text(viewModel.title) + .font(.subheadline) + .lineLimit(2) + + if let actor = viewModel.actor { + HStack(spacing: 6) { + avatar + HStack(spacing: 4) { + Text(actor) + .font(.footnote) + .foregroundColor(.secondary) + if let role = viewModel.actorRole { + Text("ยท") + .font(.footnote) + .foregroundColor(.secondary) + Text(role) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + .padding(.top, 4) + } + } + } + } + + private var avatar: some View { + Group { + if let avatarURL = viewModel.actorAvatarURL { + AvatarView(style: .single(avatarURL), diameter: 16) + } else if viewModel.actor?.lowercased() == "jetpack" { + Image("icon-jetpack") + .resizable() + } else { + Circle() + .fill(Color(.secondarySystemBackground)) + .overlay( + Text((viewModel.actor ?? "").prefix(1).uppercased()) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.secondary) + ) + } + } + .frame(width: 16, height: 16) + } + + private var icon: some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(viewModel.tintColor.opacity(0.15)) + .frame(width: 36, height: 36) + + if let iconImage = viewModel.icon { + Image(uiImage: iconImage) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundColor(viewModel.tintColor) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift new file mode 100644 index 000000000000..8b82267914fb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -0,0 +1,39 @@ +import Foundation +import SwiftUI +import UIKit +import WordPressKit +import WordPressUI +import FormattableContentKit + +struct ActivityLogRowViewModel: Identifiable { + let id: String + let actorAvatarURL: URL? + var actor: String? + var actorRole: String? + let title: String + let subtitle: String + let date: Date + let time: String + let icon: UIImage? + let tintColor: Color + let activity: Activity + + init(activity: Activity) { + self.activity = activity + self.id = activity.activityID + self.actorAvatarURL = activity.actor.flatMap { URL(string: $0.avatarURL) } + if let actor = activity.actor { + self.actor = actor.displayName + if !actor.role.isEmpty { + self.actorRole = actor.role.localizedCapitalized + } + } + self.date = activity.published + self.time = activity.published.formatted(date: .omitted, time: .shortened) + self.title = activity.text + self.subtitle = activity.summary.localizedCapitalized + + self.icon = WPStyleGuide.ActivityStyleGuide.getIconForActivity(activity) + self.tintColor = Color(WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity)) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift new file mode 100644 index 000000000000..7b7d26780941 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift @@ -0,0 +1,138 @@ +import SwiftUI +import WordPressKit + +struct ActivityLogsMenu: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + @State private var isShowingActivityTypePicker = false + @State private var isShowingStartDatePicker = false + @State private var isShowingEndDatePicker = false + + var body: some View { + Menu { + Section { + dateFilters + activityTypeFilter + if !viewModel.parameters.isEmpty { + resetFiltersButton + } + } + } label: { + Image(systemName: "ellipsis") + } + .sheet(isPresented: $isShowingActivityTypePicker) { + NavigationView { + ActivityTypeSelectionView(viewModel: viewModel) + } + } + .sheet(isPresented: $isShowingStartDatePicker) { + DatePickerSheet( + title: Strings.startDate, + selection: $viewModel.parameters.startDate, + isPresented: $isShowingStartDatePicker + ) + } + .sheet(isPresented: $isShowingEndDatePicker) { + DatePickerSheet( + title: Strings.endDate, + selection: $viewModel.parameters.endDate, + isPresented: $isShowingEndDatePicker + ) + } + } + + private var dateFilters: some View { + Group { + // Start Date + Button { + isShowingStartDatePicker = true + } label: { + Text(Strings.startDate) + if let date = viewModel.parameters.startDate { + Text(date.formatted(date: .abbreviated, time: .shortened)) + } + Image(systemName: "calendar") + } + + // End Date + Button { + isShowingEndDatePicker = true + } label: { + Text(Strings.endDate) + if let date = viewModel.parameters.endDate { + Text(date.formatted(date: .abbreviated, time: .shortened)) + } + Image(systemName: "calendar") + } + } + } + + private var activityTypeFilter: some View { + Button { + isShowingActivityTypePicker = true + } label: { + Text(Strings.activityTypes) + if !viewModel.parameters.activityTypes.isEmpty { + Text("\(viewModel.parameters.activityTypes.count)") + } + Image(systemName: "list.bullet") + } + } + + private var resetFiltersButton: some View { + Button(role: .destructive) { + viewModel.parameters = GetActivityLogsParameters() + } label: { + Label(Strings.resetFilters, systemImage: "arrow.counterclockwise") + } + } +} + +private struct DatePickerSheet: View { + let title: String + @Binding var selection: Date? + @Binding var isPresented: Bool + + @State private var date = Date() + + var body: some View { + NavigationView { + picker + .frame(maxHeight: .infinity, alignment: .top) + } + .onAppear { + date = selection ?? Date() + } + } + + private var picker: some View { + DatePicker(title, selection: $date, displayedComponents: [.date, .hourAndMinute]) + .datePickerStyle(.graphical) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(SharedStrings.Button.done) { + selection = date + isPresented = false + } + .fontWeight(.semibold) + } + ToolbarItem(placement: .bottomBar) { + Button(role: .destructive) { + selection = nil + isPresented = false + } label: { + Text(SharedStrings.Button.clear) + } + } + } + } +} + +private enum Strings { + static let startDate = NSLocalizedString("activityLogs.filter.startDate", value: "Start Date", comment: "Start date filter label") + static let endDate = NSLocalizedString("activityLogs.filter.endDate", value: "End Date", comment: "End date filter label") + static let activityTypes = NSLocalizedString("activityLogs.filter.activityTypes", value: "Activity Types", comment: "Activity types filter label") + static let resetFilters = NSLocalizedString("activityLogs.filter.reset", value: "Reset Filters", comment: "Reset filters button label") +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift new file mode 100644 index 000000000000..89535025f845 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -0,0 +1,129 @@ +import SwiftUI +import WordPressUI +import WordPressKit + +struct ActivityLogsView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + Group { + if !viewModel.searchText.isEmpty { + ActivityLogsSearchView(viewModel: viewModel) + } else { + ActivityLogsListView(viewModel: viewModel) + } + } + .searchable(text: $viewModel.searchText) + } +} + +private struct ActivityLogsListView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + List { + if let response = viewModel.response { + ActivityLogsPaginatedForEach(response: response) + + if viewModel.isFreePlan { + Text(Strings.freePlanNotice) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .listRowSeparator(.hidden) + } + } + } + .listStyle(.plain) + .overlay { + if let response = viewModel.response { + if response.isEmpty { + EmptyStateView(Strings.empty, systemImage: "archivebox") + } + } else if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + EmptyStateView.failure(error: error) { + Task { await viewModel.refresh() } + } + } + } + .onAppear { + viewModel.onAppear() + } + .refreshable { + await viewModel.refresh() + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + ActivityLogsMenu(viewModel: viewModel) + } + } + } +} + +private struct ActivityLogsSearchView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + + var body: some View { + DataViewSearchView( + searchText: viewModel.searchText, + search: viewModel.search + ) { response in + ActivityLogsPaginatedForEach(response: response) + } + } +} + +private struct ActivityLogsPaginatedForEach: View { + @ObservedObject var response: ActivityLogsPaginatedResponse + + struct ActivityGroup: Identifiable { + var id: Date { date } + let date: Date + var title: String { date.formatted(date: .long, time: .omitted) } + let items: [ActivityLogRowViewModel] + } + + private var groupedItems: [ActivityGroup] { + let grouped = Dictionary(grouping: response.items) { item in + Calendar.current.startOfDay(for: item.date) + } + return grouped.map { ActivityGroup(date: $0.key, items: $0.value) } + .sorted { $0.date > $1.date } + } + + var body: some View { + ForEach(groupedItems) { group in + Section(group.title) { + ForEach(group.items) { + makeRow(with: $0) + .listRowSeparator($0.id == group.items.first?.id ? .hidden : .automatic, edges: .top) + } + } + } + if response.isLoading { + DataViewPagingFooterView(.loading) + } else if response.error != nil { + DataViewPagingFooterView(.failure) + .onRetry { response.loadMore() } + } + } + + private func makeRow(with item: ActivityLogRowViewModel) -> some View { + ActivityLogRowView(viewModel: item) + .onAppear { response.onRowAppeared(item) } + .background { + NavigationLink { + // TODO: Update to show ActivityDetailViewController + } label: { + EmptyView() + }.opacity(0) + } + } +} + +private enum Strings { + static let empty = NSLocalizedString("activityLogs.empty", value: "No Activity", comment: "Empty state message for activity logs") + static let freePlanNotice = NSLocalizedString("activityLogs.freePlan.notice", value: "Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Notice shown to free plan users about limited activity log events") +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift new file mode 100644 index 000000000000..6b5905cda685 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewController.swift @@ -0,0 +1,22 @@ +import UIKit +import SwiftUI +import WordPressUI +import WordPressKit + +final class ActivityLogsViewController: UIHostingController { + private let viewModel: ActivityLogsViewModel + + init(blog: Blog) { + self.viewModel = ActivityLogsViewModel(blog: blog) + super.init(rootView: AnyView(ActivityLogsView(viewModel: viewModel))) + self.title = Strings.title + } + + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private enum Strings { + static let title = NSLocalizedString("activity.logs.title", value: "Activity", comment: "Title for the activity logs screen") +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift new file mode 100644 index 000000000000..06fd2acd9095 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -0,0 +1,154 @@ +import Foundation +import WordPressKit +import WordPressUI + +typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse + +@MainActor +final class ActivityLogsViewModel: ObservableObject { + let blog: Blog + + @Published var searchText = "" + @Published var parameters = GetActivityLogsParameters() { + didSet { + response = nil + onRefreshNeeded() + } + } + @Published var response: ActivityLogsPaginatedResponse? + @Published var isLoading = false + @Published var error: Error? + + private var refreshTask: Task? + + var isFreePlan: Bool { + blog.isHostedAtWPcom && !blog.hasPaidPlan + } + + init(blog: Blog) { + self.blog = blog + } + + func onAppear() { + guard response == nil else { return } + onRefreshNeeded() + } + + func onRefreshNeeded() { + refreshTask?.cancel() + refreshTask = Task { + await refresh() + } + } + + func refresh() async { + isLoading = true + error = nil + Task { + do { + let response = try await makeResponse(searchText: searchText, parameters: parameters) + guard !Task.isCancelled else { return } + self.isLoading = false + self.response = response + } catch { + guard !Task.isCancelled else { return } + self.isLoading = false + self.error = error + if response != nil { + Notice(error: error).post() + } + } + } + } + + func search() async throws -> ActivityLogsPaginatedResponse { + try await makeResponse(searchText: searchText, parameters: parameters) + } + + func fetchActivityGroups(after: Date? = nil, before: Date? = nil) async throws -> [WordPressKit.ActivityGroup] { + guard let siteID = blog.dotComID?.intValue, + let api = blog.wordPressComRestApi else { + throw NSError(domain: "ActivityLogs", code: 0, userInfo: [NSLocalizedDescriptionKey: "Site ID or API not available"]) + } + + let service = ActivityServiceRemote(wordPressComRestApi: api) + let groups = try await service.getActivityGroups( + siteID: siteID, + after: after, + before: before + ) + return groups.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private func makeResponse(searchText: String?, parameters: GetActivityLogsParameters) async throws -> ActivityLogsPaginatedResponse { + try await ActivityLogsPaginatedResponse { [blog] offset in + guard let siteID = blog.dotComID?.intValue, + let api = blog.wordPressComRestApi else { + throw NSError(domain: "ActivityLogs", code: 0, userInfo: [NSLocalizedDescriptionKey: SharedStrings.Error.generic]) + } + let service = ActivityServiceRemote(wordPressComRestApi: api) + let offset = offset ?? 0 + let (activities, hasMore) = try await service.getActivities( + siteID: siteID, + offset: offset, + pageSize: 32, + searchText: searchText, + parameters: parameters + ) + let viewModels = await makeViewModels(for: activities) + return ActivityLogsPaginatedResponse.Page( + items: viewModels, + hasMore: hasMore, + nextPage: hasMore ? offset + activities.count : nil + ) + } + } +} + +private func makeViewModels(for activities: [Activity]) async -> [ActivityLogRowViewModel] { + activities.map(ActivityLogRowViewModel.init) +} + +struct GetActivityLogsParameters: Hashable { + var startDate: Date? + var endDate: Date? + var activityTypes: Set = [] + + var isEmpty: Bool { + startDate == nil && endDate == nil && activityTypes.isEmpty + } +} + +private extension ActivityServiceRemote { + func getActivities(siteID: Int, offset: Int, pageSize: Int, searchText: String? = nil, parameters: GetActivityLogsParameters = .init()) async throws -> ([Activity], hasMore: Bool) { + return try await withCheckedThrowingContinuation { continuation in + getActivityForSite( + siteID, + offset: offset, + count: pageSize, + after: parameters.startDate, + before: parameters.endDate, + group: Array(parameters.activityTypes), + searchText: searchText + ) { activities, hasMore in + continuation.resume(returning: (activities, hasMore)) + } failure: { error in + continuation.resume(throwing: error) + } + } + } + + func getActivityGroups(siteID: Int, after: Date? = nil, before: Date? = nil) async throws -> [WordPressKit.ActivityGroup] { + try await withCheckedThrowingContinuation { continuation in + getActivityGroupsForSite( + siteID, + after: after, + before: before + ) { groups in + continuation.resume(returning: groups) + } failure: { error in + continuation.resume(throwing: error) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift new file mode 100644 index 000000000000..753c52396d2a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityTypeSelectionView.swift @@ -0,0 +1,169 @@ +import SwiftUI +import WordPressKit +import WordPressUI + +struct ActivityTypeSelectionView: View { + @ObservedObject var viewModel: ActivityLogsViewModel + @Environment(\.dismiss) private var dismiss + @State private var selectedTypes: Set + @State private var availableActivityGroups: [WordPressKit.ActivityGroup] = [] + @State private var isLoading = false + @State private var error: Error? + + init(viewModel: ActivityLogsViewModel) { + self.viewModel = viewModel + self._selectedTypes = State(initialValue: viewModel.parameters.activityTypes) + } + + var body: some View { + Group { + if isLoading && availableActivityGroups.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = error, availableActivityGroups.isEmpty { + EmptyStateView.failure(error: error) { + Task { await fetchActivityGroups() } + } + } else if availableActivityGroups.isEmpty { + EmptyStateView( + Strings.emptyActivityTypes, + systemImage: "list.bullet" + ) + } else { + List { + Section { + selectionControlsSection + } + Section { + activityTypesSection + } + } + } + } + .onAppear { + Task { await fetchActivityGroups() } + } + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + cancelButton + } + ToolbarItem(placement: .confirmationAction) { + doneButton + } + } + } + + // MARK: - View Components + + private var selectionControlsSection: some View { + HStack { + Button(Strings.selectAll) { + selectedTypes = Set(availableActivityGroups.map { $0.key }) + } + .disabled(selectedTypes.count == availableActivityGroups.count) + + Spacer() + + Button(Strings.deselectAll) { + selectedTypes.removeAll() + } + .disabled(selectedTypes.isEmpty) + } + .font(.subheadline) + } + + private var activityTypesSection: some View { + ForEach(availableActivityGroups, id: \.key) { group in + ActivityTypeRow( + group: group, + isSelected: selectedTypes.contains(group.key), + onToggle: { toggleSelection(for: group.key) } + ) + } + } + + private var cancelButton: some View { + Button(SharedStrings.Button.cancel) { + dismiss() + } + } + + private var doneButton: some View { + Button(SharedStrings.Button.done) { + viewModel.parameters.activityTypes = selectedTypes + dismiss() + } + .fontWeight(.semibold) + } + + // MARK: - Helper Methods + + private func toggleSelection(for key: String) { + if selectedTypes.contains(key) { + selectedTypes.remove(key) + } else { + selectedTypes.insert(key) + } + } + + private func fetchActivityGroups() async { + isLoading = true + error = nil + + do { + let groups = try await viewModel.fetchActivityGroups( + after: viewModel.parameters.startDate, + before: viewModel.parameters.endDate + ) + availableActivityGroups = groups + isLoading = false + } catch { + self.error = error + isLoading = false + } + } +} + +// MARK: - Activity Type Row + +private struct ActivityTypeRow: View { + let group: WordPressKit.ActivityGroup + let isSelected: Bool + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + HStack { + groupInfo + Spacer() + selectionIndicator + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var groupInfo: some View { + HStack { + Text(group.name) + Spacer() + Text("\(group.count)") + .foregroundColor(.secondary) + } + } + + private var selectionIndicator: some View { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .accentColor : Color(.separator)) + .imageScale(.large) + } +} + +private enum Strings { + static let title = NSLocalizedString("activityLogs.activityTypes.title", value: "Activity Types", comment: "Activity type selection screen title") + static let selectAll = NSLocalizedString("activityLogs.activityTypes.selectAll", value: "Select All", comment: "Select all button") + static let deselectAll = NSLocalizedString("activityLogs.activityTypes.deselectAll", value: "Deselect All", comment: "Deselect all button") + static let emptyActivityTypes = NSLocalizedString("activityLogs.activityTypes.empty", value: "No activity types available", comment: "Empty state message when no activity types are available") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift index c86090074a2c..b6bc1f58fbd9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift @@ -124,9 +124,16 @@ final class DashboardActivityLogCardCell: DashboardCollectionViewCell { // MARK: - Navigation private func showActivityLog(for blog: Blog, tapSource: String) { - guard let activityLogController = JetpackActivityLogViewController(blog: blog) else { + let activityLogController: UIViewController + + if FeatureFlag.dataViews.enabled { + activityLogController = ActivityLogsViewController(blog: blog) + } else if let jetpackController = JetpackActivityLogViewController(blog: blog) { + activityLogController = jetpackController + } else { return } + presentingViewController?.navigationController?.pushViewController(activityLogController, animated: true) WPAnalytics.track(.activityLogViewed, diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 93c0a121b08f..321c90ee284d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -149,9 +149,16 @@ extension BlogDetailsViewController { } @objc public func showActivity() { - guard let controller = JetpackActivityLogViewController(blog: blog) else { + let controller: UIViewController + + if FeatureFlag.dataViews.enabled { + controller = ActivityLogsViewController(blog: blog) + } else if let jetpackController = JetpackActivityLogViewController(blog: blog) { + controller = jetpackController + } else { return wpAssertionFailure("failed to instantiate") } + controller.navigationItem.largeTitleDisplayMode = .never presentationDelegate?.presentBlogDetailsViewController(controller) diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift index 30e90aef141d..efb5afcaf906 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersMenu.swift @@ -10,8 +10,8 @@ struct SubscribersMenu: View { filterByEmailSubscriptionType filterByPaymenetType } - if let response = viewModel.response { - Text("\(Strings.subscribers) \(viewModel.makeFormattedSubscribersCount(for: response))") + if let response = viewModel.response, let count = viewModel.makeFormattedSubscribersCount(for: response) { + Text("\(Strings.subscribers) \(count)") } } label: { Image(systemName: "ellipsis.circle") diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift index ab0abb7da4ce..4840aab8ff8d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersView.swift @@ -87,37 +87,12 @@ private struct SubscribersListView: View { private struct SubscribersSearchView: View { @ObservedObject var viewModel: SubscribersViewModel - @State private var response: SubscribersPaginatedResponse? - @State private var error: Error? - var body: some View { - List { - if let response { - SubscribersPaginatedForEach(response: response) - } else if error == nil { - DataViewPagingFooterView(.loading) - } - } - .listStyle(.plain) - .overlay { - if let response, response.isEmpty { - EmptyStateView.search() - } else if let error { - EmptyStateView.failure(error: error) - } - } - .task(id: viewModel.searchText) { - error = nil - do { - try await Task.sleep(for: .milliseconds(500)) - let response = try await viewModel.search() - guard !Task.isCancelled else { return } - self.response = response - } catch { - guard !Task.isCancelled else { return } - self.response = nil - self.error = error - } + DataViewSearchView( + searchText: viewModel.searchText, + search: viewModel.search + ) { response in + SubscribersPaginatedForEach(response: response) } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift index d18e758a1411..39fcc4c3ca26 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/List/SubscribersViewModel.swift @@ -63,11 +63,14 @@ final class SubscribersViewModel: ObservableObject { try await makeResponse(parameters: parameters, search: searchText) } - func makeFormattedSubscribersCount(for response: SubscribersPaginatedResponse) -> String { + func makeFormattedSubscribersCount(for response: SubscribersPaginatedResponse) -> String? { + guard let count = response.total else { + return nil + } guard !parameters.filters.isEmpty, let totalCount else { - return "\(response.total)" + return "\(count)" } - return String(format: Strings.nOutOf, response.total.description, totalCount.description) + return String(format: Strings.nOutOf, count.description, totalCount.description) } private func makeResponse(