From 6decdf8f8ea061e09edb5fbaea3eceb3461fae64 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:50:48 -0400 Subject: [PATCH 01/13] Add initial restore/download backup code --- .../Details/ActivityLogDetailsView.swift | 121 ++++- .../Details/DownloadBackupSheet.swift | 417 ++++++++++++++++++ .../Details/DownloadBackupViewModel.swift | 167 +++++++ .../Activity/Details/RestoreBackupSheet.swift | 321 ++++++++++++++ .../Details/RestoreBackupViewModel.swift | 124 ++++++ 5 files changed, 1141 insertions(+), 9 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 8f7f5ba5e68e..db65e0f48052 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -5,21 +5,85 @@ import Gridicons struct ActivityLogDetailsView: View { let activity: Activity + let site: JetpackSiteRef @Environment(\.dismiss) var dismiss + @State private var showingRestoreSheet = false + @State private var showingDownloadSheet = false var body: some View { - ScrollView { - VStack(spacing: 24) { - ActivityHeaderView(activity: activity) - if let actor = activity.actor { - ActorCard(actor: actor) + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 24) { + ActivityHeaderView(activity: activity) + if let actor = activity.actor { + ActorCard(actor: actor) + } } + .padding() + } + + if activity.isRewindable { + actionButtons } - .padding() } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingRestoreSheet) { + RestoreBackupSheet(activity: activity, site: site) + } + .sheet(isPresented: $showingDownloadSheet) { + DownloadBackupSheet(activity: activity, site: site) + } + } + + @ViewBuilder + private var actionButtons: some View { + VStack(spacing: 12) { + Divider() + + HStack(spacing: 12) { + // Restore Backup - Primary Button + Button(action: { + showingRestoreSheet = true + }) { + HStack { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 16, weight: .medium)) + Text(Strings.restoreBackup) + .font(.system(size: 16, weight: .medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + + // Download Backup - Secondary Button + Button(action: { + showingDownloadSheet = true + }) { + HStack { + Image(systemName: "arrow.down.circle") + .font(.system(size: 16, weight: .regular)) + Text(Strings.downloadBackup) + .font(.system(size: 16, weight: .regular)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.clear) + .foregroundColor(.accentColor) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.accentColor, lineWidth: 1) + ) + } + } + .padding(.horizontal) + .padding(.bottom, 12) + } + .background(Color(.systemBackground)) } } @@ -146,19 +210,28 @@ private struct ActivityCard: View { #Preview("Backup Activity") { NavigationView { - ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockBackupActivity) + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockBackupActivity, + site: JetpackSiteRef.mock + ) } } #Preview("Plugin Update") { NavigationView { - ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockPluginActivity) + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockPluginActivity, + site: JetpackSiteRef.mock + ) } } #Preview("Login Succeeded") { NavigationView { - ActivityLogDetailsView(activity: ActivityLogDetailsView.Mocks.mockLoginActivity) + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockLoginActivity, + site: JetpackSiteRef.mock + ) } } @@ -176,4 +249,34 @@ private enum Strings { value: "User", comment: "Section title for user information" ) + + static let restoreBackup = NSLocalizedString( + "activityDetail.restoreBackup.button", + value: "Restore Backup", + comment: "Button title for restoring a backup" + ) + + static let downloadBackup = NSLocalizedString( + "activityDetail.downloadBackup.button", + value: "Download Backup", + comment: "Button title for downloading a backup" + ) +} + +// MARK: - Preview Helpers + +#if DEBUG +extension JetpackSiteRef { + static var mock: JetpackSiteRef { + var ref = JetpackSiteRef( + siteID: 123456789, + username: "test", + homeURL: "https://example.wordpress.com", + isSelfHostedWithoutJetpack: false, + xmlRPC: nil + ) + // Use reflection to set private properties for preview + return ref + } } +#endif diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift new file mode 100644 index 000000000000..46650b58df49 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift @@ -0,0 +1,417 @@ +import SwiftUI +import WordPressKit +import WordPressShared + +struct DownloadBackupSheet: View { + let activity: Activity + let site: JetpackSiteRef + + @StateObject private var viewModel: DownloadBackupViewModel + @Environment(\.dismiss) private var dismiss + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, site: site)) + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { + progressView + } else { + downloadOptionsView + } + } + .navigationTitle(Strings.downloadTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if viewModel.state == .idle { + ToolbarItem(placement: .navigationBarLeading) { + Button(Strings.cancel) { + dismiss() + } + } + } + } + .interactiveDismissDisabled(viewModel.state == .loading) + } + .onAppear { + WPAnalytics.track(.backupDownloadOpened, properties: ["source": "activity_detail"]) + } + } + + @ViewBuilder + private var downloadOptionsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Activity Header + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + if let icon = activity.icon { + Image(uiImage: icon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .foregroundColor(Color(activity.statusColor)) + .padding(12) + .background(Color(activity.statusColor).opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(activity.summary) + .font(.headline) + .lineLimit(2) + + Text(formattedDate) + .font(.footnote) + .foregroundColor(.secondary) + } + + Spacer() + } + + if !activity.text.isEmpty { + Text(activity.text) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // Download Options + VStack(alignment: .leading, spacing: 16) { + Text(Strings.optionsTitle) + .font(.headline) + + VStack(spacing: 0) { + DownloadOptionRow( + title: Strings.optionThemes, + isSelected: $viewModel.includeThemes + ) + Divider().padding(.leading, 44) + + DownloadOptionRow( + title: Strings.optionPlugins, + isSelected: $viewModel.includePlugins + ) + Divider().padding(.leading, 44) + + DownloadOptionRow( + title: Strings.optionUploads, + isSelected: $viewModel.includeUploads + ) + Divider().padding(.leading, 44) + + DownloadOptionRow( + title: Strings.optionContent, + subtitle: Strings.optionContentSubtitle, + isSelected: $viewModel.includeContent + ) + } + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + } + + // Info Section + VStack(alignment: .leading, spacing: 8) { + Text(Strings.infoTitle) + .font(.footnote) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .textCase(.uppercase) + + Text(Strings.infoMessage) + .font(.footnote) + .foregroundColor(.secondary) + } + } + .padding() + } + + Spacer() + + // Bottom Action Button + VStack(spacing: 16) { + Divider() + + Button(action: { + viewModel.downloadBackup() + WPAnalytics.track(.backupDownloadConfirmed, properties: ["source": "activity_detail"]) + }) { + Text(Strings.confirmDownload) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(viewModel.hasSelection ? Color.accentColor : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(!viewModel.hasSelection) + .padding(.horizontal) + .padding(.bottom, 8) + } + } + + @ViewBuilder + private var progressView: some View { + VStack(spacing: 32) { + Spacer() + + switch viewModel.state { + case .loading: + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.5) + + VStack(spacing: 8) { + Text(Strings.preparingTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(Strings.preparingMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + case .success: + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.green) + + VStack(spacing: 8) { + Text(Strings.successTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(Strings.successMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if let downloadURL = viewModel.downloadURL { + Link(destination: URL(string: downloadURL)!) { + Text(Strings.downloadLink) + .font(.subheadline) + .fontWeight(.medium) + } + .padding(.top, 8) + } + } + } + + case .failure: + VStack(spacing: 24) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + VStack(spacing: 8) { + Text(Strings.failureTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(viewModel.errorMessage ?? Strings.failureMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + default: + EmptyView() + } + + Spacer() + + if viewModel.state == .success || viewModel.state == .failure { + Button(action: { + dismiss() + }) { + Text(Strings.done) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.bottom, 24) + } + } + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: activity.published) + } +} + +// MARK: - Download Option Row + +private struct DownloadOptionRow: View { + let title: String + var subtitle: String? = nil + @Binding var isSelected: Bool + + var body: some View { + Button(action: { + isSelected.toggle() + }) { + HStack(spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 24)) + .foregroundColor(isSelected ? .accentColor : .secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body) + .foregroundColor(.primary) + + if let subtitle = subtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + } + .padding() + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let downloadTitle = NSLocalizedString( + "download.sheet.title", + value: "Download Backup", + comment: "Title for the download backup sheet" + ) + + static let cancel = NSLocalizedString( + "download.sheet.cancel", + value: "Cancel", + comment: "Cancel button for download sheet" + ) + + static let optionsTitle = NSLocalizedString( + "download.sheet.options.title", + value: "Choose items to download", + comment: "Title for download options section" + ) + + static let optionThemes = NSLocalizedString( + "download.sheet.option.themes", + value: "Themes", + comment: "Option to download themes" + ) + + static let optionPlugins = NSLocalizedString( + "download.sheet.option.plugins", + value: "Plugins", + comment: "Option to download plugins" + ) + + static let optionUploads = NSLocalizedString( + "download.sheet.option.uploads", + value: "Media uploads", + comment: "Option to download media uploads" + ) + + static let optionContent = NSLocalizedString( + "download.sheet.option.content", + value: "Content", + comment: "Option to download content" + ) + + static let optionContentSubtitle = NSLocalizedString( + "download.sheet.option.content.subtitle", + value: "Posts, pages, and comments", + comment: "Subtitle for content option" + ) + + static let infoTitle = NSLocalizedString( + "download.sheet.info.title", + value: "About backup downloads", + comment: "Info section title in download sheet" + ) + + static let infoMessage = NSLocalizedString( + "download.sheet.info.message", + value: "Your backup will be prepared as a downloadable file. You'll receive an email with the download link when it's ready.", + comment: "Information about the download process" + ) + + static let confirmDownload = NSLocalizedString( + "download.sheet.confirm.button", + value: "Create downloadable file", + comment: "Confirm button for download action" + ) + + static let preparingTitle = NSLocalizedString( + "download.sheet.preparing.title", + value: "Preparing Your Backup", + comment: "Title shown while backup is being prepared" + ) + + static let preparingMessage = NSLocalizedString( + "download.sheet.preparing.message", + value: "We're creating a downloadable backup file. This may take a few moments.", + comment: "Message shown while backup is being prepared" + ) + + static let successTitle = NSLocalizedString( + "download.sheet.success.title", + value: "Backup Ready!", + comment: "Title shown when backup is ready" + ) + + static let successMessage = NSLocalizedString( + "download.sheet.success.message", + value: "Your backup has been prepared. You'll receive an email with the download link shortly.", + comment: "Message shown when backup is ready" + ) + + static let downloadLink = NSLocalizedString( + "download.sheet.success.link", + value: "Download now", + comment: "Link to download the backup" + ) + + static let failureTitle = NSLocalizedString( + "download.sheet.failure.title", + value: "Backup Failed", + comment: "Title shown when backup preparation fails" + ) + + static let failureMessage = NSLocalizedString( + "download.sheet.failure.message", + value: "We couldn't prepare your backup. Please try again or contact support if the problem persists.", + comment: "Message shown when backup preparation fails" + ) + + static let done = NSLocalizedString( + "download.sheet.done.button", + value: "Done", + comment: "Done button to dismiss the sheet" + ) +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift new file mode 100644 index 000000000000..f8f443b8d2b2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift @@ -0,0 +1,167 @@ +import Foundation +import WordPressKit +import WordPressShared + +@MainActor +final class DownloadBackupViewModel: ObservableObject { + enum State { + case idle + case loading + case success + case failure + } + + @Published var state: State = .idle + @Published var errorMessage: String? + @Published var downloadURL: String? + + // Download options + @Published var includeThemes = true + @Published var includePlugins = true + @Published var includeUploads = true + @Published var includeContent = true + + var hasSelection: Bool { + includeThemes || includePlugins || includeUploads || includeContent + } + + private let activity: Activity + private let site: JetpackSiteRef + private let backupService: JetpackBackupService + private var downloadID: Int? + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self.backupService = JetpackBackupService(coreDataStack: ContextManager.shared.contextManager.mainContext) + } + + func downloadBackup() { + guard state == .idle, hasSelection else { return } + + state = .loading + errorMessage = nil + downloadURL = nil + + let restoreTypes = buildRestoreTypes() + + backupService.prepareBackup( + for: site, + rewindID: activity.rewindID, + restoreTypes: restoreTypes, + success: { [weak self] backup in + self?.handleBackupPrepared(backup) + }, + failure: { [weak self] error in + self?.handleBackupFailure(error) + } + ) + } + + private func buildRestoreTypes() -> JetpackRestoreTypes { + var types = JetpackRestoreTypes() + types.themes = includeThemes + types.plugins = includePlugins + types.uploads = includeUploads + types.sqls = includeContent + types.roots = includeContent + types.contents = includeContent + return types + } + + private func handleBackupPrepared(_ backup: JetpackBackup) { + downloadID = backup.downloadID + + // Check if backup is already ready + if let url = backup.url, !url.isEmpty { + downloadURL = url + state = .success + WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) + } else { + // Start polling for backup status + pollBackupStatus() + } + } + + private func pollBackupStatus() { + guard let downloadID = downloadID else { + handleBackupFailure(BackupError.missingDownloadID) + return + } + + backupService.getBackupStatus( + for: site, + downloadID: downloadID, + success: { [weak self] backup in + self?.handleBackupStatus(backup) + }, + failure: { [weak self] error in + self?.handleBackupFailure(error) + } + ) + } + + private func handleBackupStatus(_ backup: JetpackBackup) { + if let url = backup.url, !url.isEmpty { + // Backup is ready + downloadURL = url + state = .success + WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) + } else if backup.progress == nil { + // Backup failed + state = .failure + errorMessage = Strings.defaultErrorMessage + WPAnalytics.track(.backupDownloadFailed, properties: ["source": "activity_detail", "error": "no_progress"]) + } else { + // Still in progress, continue polling + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard self?.state == .loading else { return } + self?.pollBackupStatus() + } + } + } + + private func handleBackupFailure(_ error: Error) { + state = .failure + + if let networkError = error as? NSError { + errorMessage = networkError.localizedDescription + } else { + errorMessage = Strings.defaultErrorMessage + } + + WPAnalytics.track(.backupDownloadFailed, properties: [ + "source": "activity_detail", + "error": error.localizedDescription + ]) + } +} + +// MARK: - Errors + +private enum BackupError: LocalizedError { + case missingDownloadID + + var errorDescription: String? { + switch self { + case .missingDownloadID: + return Strings.missingDownloadIDError + } + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let defaultErrorMessage = NSLocalizedString( + "download.viewModel.error.default", + value: "An error occurred while preparing your backup. Please try again.", + comment: "Default error message for backup download failures" + ) + + static let missingDownloadIDError = NSLocalizedString( + "download.viewModel.error.missingID", + value: "Unable to track backup progress. Please try again.", + comment: "Error when download ID is missing" + ) +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift new file mode 100644 index 000000000000..2e44dff9ac04 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift @@ -0,0 +1,321 @@ +import SwiftUI +import WordPressKit +import WordPressShared + +struct RestoreBackupSheet: View { + let activity: Activity + let site: JetpackSiteRef + + @StateObject private var viewModel: RestoreBackupViewModel + @Environment(\.dismiss) private var dismiss + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, site: site)) + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { + progressView + } else { + confirmationView + } + } + .navigationTitle(Strings.restoreTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if viewModel.state == .idle { + ToolbarItem(placement: .navigationBarLeading) { + Button(Strings.cancel) { + dismiss() + } + } + } + } + .interactiveDismissDisabled(viewModel.state == .loading) + } + .onAppear { + WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) + } + } + + @ViewBuilder + private var confirmationView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Activity Header + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + if let icon = activity.icon { + Image(uiImage: icon) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .foregroundColor(Color(activity.statusColor)) + .padding(12) + .background(Color(activity.statusColor).opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(activity.summary) + .font(.headline) + .lineLimit(2) + + Text(formattedDate) + .font(.footnote) + .foregroundColor(.secondary) + } + + Spacer() + } + + if !activity.text.isEmpty { + Text(activity.text) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // Warning Section + VStack(alignment: .leading, spacing: 12) { + Label(Strings.warningTitle, systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundColor(.orange) + + Text(Strings.warningMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + + // Info Section + VStack(alignment: .leading, spacing: 8) { + Text(Strings.infoTitle) + .font(.footnote) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .textCase(.uppercase) + + Text(Strings.infoMessage) + .font(.footnote) + .foregroundColor(.secondary) + } + } + .padding() + } + + Spacer() + + // Bottom Action Button + VStack(spacing: 16) { + Divider() + + Button(action: { + viewModel.restore() + WPAnalytics.track(.restoreConfirmed, properties: ["source": "activity_detail"]) + }) { + Text(Strings.confirmRestore) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } + + @ViewBuilder + private var progressView: some View { + VStack(spacing: 32) { + Spacer() + + switch viewModel.state { + case .loading: + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.5) + + VStack(spacing: 8) { + Text(Strings.restoringTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(String(format: Strings.restoringMessage, formattedDate)) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + case .success: + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.green) + + VStack(spacing: 8) { + Text(Strings.successTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(Strings.successMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + case .failure: + VStack(spacing: 24) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + VStack(spacing: 8) { + Text(Strings.failureTitle) + .font(.title3) + .fontWeight(.semibold) + + Text(viewModel.errorMessage ?? Strings.failureMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + + default: + EmptyView() + } + + Spacer() + + if viewModel.state == .success || viewModel.state == .failure { + Button(action: { + dismiss() + }) { + Text(Strings.done) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.bottom, 24) + } + } + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: activity.published) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let restoreTitle = NSLocalizedString( + "restore.sheet.title", + value: "Restore Site", + comment: "Title for the restore backup sheet" + ) + + static let cancel = NSLocalizedString( + "restore.sheet.cancel", + value: "Cancel", + comment: "Cancel button for restore sheet" + ) + + static let warningTitle = NSLocalizedString( + "restore.sheet.warning.title", + value: "Warning", + comment: "Warning section title in restore sheet" + ) + + static let warningMessage = NSLocalizedString( + "restore.sheet.warning.message", + value: "Restoring your site will revert all content, settings, and configurations to this backup point. Any changes made after this backup will be lost.", + comment: "Warning message about restore consequences" + ) + + static let infoTitle = NSLocalizedString( + "restore.sheet.info.title", + value: "What happens next", + comment: "Info section title in restore sheet" + ) + + static let infoMessage = NSLocalizedString( + "restore.sheet.info.message", + value: "The restore process typically takes a few minutes. You'll receive a notification when it's complete. Your site may be temporarily unavailable during the restore.", + comment: "Information about the restore process" + ) + + static let confirmRestore = NSLocalizedString( + "restore.sheet.confirm.button", + value: "Restore to This Point", + comment: "Confirm button for restore action" + ) + + static let restoringTitle = NSLocalizedString( + "restore.sheet.restoring.title", + value: "Restoring Your Site", + comment: "Title shown while restore is in progress" + ) + + static let restoringMessage = NSLocalizedString( + "restore.sheet.restoring.message", + value: "We're restoring your site back to %1$@", + comment: "Message shown while restore is in progress. %1$@ is the backup date" + ) + + static let successTitle = NSLocalizedString( + "restore.sheet.success.title", + value: "Restore Complete!", + comment: "Title shown when restore succeeds" + ) + + static let successMessage = NSLocalizedString( + "restore.sheet.success.message", + value: "Your site has been successfully restored. It may take a few moments for all changes to appear.", + comment: "Message shown when restore succeeds" + ) + + static let failureTitle = NSLocalizedString( + "restore.sheet.failure.title", + value: "Restore Failed", + comment: "Title shown when restore fails" + ) + + static let failureMessage = NSLocalizedString( + "restore.sheet.failure.message", + value: "We couldn't restore your site. Please try again or contact support if the problem persists.", + comment: "Message shown when restore fails" + ) + + static let done = NSLocalizedString( + "restore.sheet.done.button", + value: "Done", + comment: "Done button to dismiss the sheet" + ) +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift new file mode 100644 index 000000000000..d3184680ae8d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift @@ -0,0 +1,124 @@ +import Foundation +import WordPressKit +import WordPressShared + +@MainActor +final class RestoreBackupViewModel: ObservableObject { + enum State { + case idle + case loading + case success + case failure + } + + @Published var state: State = .idle + @Published var errorMessage: String? + + private let activity: Activity + private let site: JetpackSiteRef + private let restoreService: JetpackRestoreService + private let activityStore: ActivityStore + + init(activity: Activity, site: JetpackSiteRef) { + self.activity = activity + self.site = site + self.restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) + self.activityStore = StoreContainer.shared.activity + } + + func restore() { + guard state == .idle else { return } + + state = .loading + errorMessage = nil + + restoreService.restoreSite( + site, + rewindID: activity.rewindID, + restoreTypes: nil, // nil means restore everything + success: { [weak self] restoreID, jobID in + self?.handleRestoreStarted(restoreID: restoreID, jobID: jobID) + }, + failure: { [weak self] error in + self?.handleRestoreFailure(error) + } + ) + } + + private func handleRestoreStarted(restoreID: String, jobID: Int) { + // Start monitoring the restore status + pollRestoreStatus() + } + + private func pollRestoreStatus() { + restoreService.getRewindStatus( + for: site, + success: { [weak self] rewindStatus in + self?.handleRewindStatus(rewindStatus) + }, + failure: { [weak self] error in + self?.handleRestoreFailure(error) + } + ) + } + + private func handleRewindStatus(_ rewindStatus: RewindStatus) { + guard let restoreStatus = rewindStatus.restore else { + // No active restore, check if we need to keep polling + if state == .loading { + // Continue polling after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard self?.state == .loading else { return } + self?.pollRestoreStatus() + } + } + return + } + + switch restoreStatus.status { + case .finished: + state = .success + WPAnalytics.track(.restoreSucceeded, properties: ["source": "activity_detail"]) + + case .fail: + state = .failure + errorMessage = restoreStatus.message ?? Strings.defaultErrorMessage + WPAnalytics.track(.restoreFailed, properties: ["source": "activity_detail", "error": errorMessage ?? "unknown"]) + + case .running, .queued: + // Continue polling + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard self?.state == .loading else { return } + self?.pollRestoreStatus() + } + + default: + break + } + } + + private func handleRestoreFailure(_ error: Error) { + state = .failure + + if let networkError = error as? NSError { + errorMessage = networkError.localizedDescription + } else { + errorMessage = Strings.defaultErrorMessage + } + + WPAnalytics.track(.restoreFailed, properties: [ + "source": "activity_detail", + "error": error.localizedDescription + ]) + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let defaultErrorMessage = NSLocalizedString( + "restore.viewModel.error.default", + value: "An error occurred while restoring your site. Please try again.", + comment: "Default error message for restore failures" + ) +} \ No newline at end of file From cfdd76d38ad93108279e12f5d93d6f0a697565ad Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:52:50 -0400 Subject: [PATCH 02/13] Remove JetpackSiteRef usages --- .../Details/ActivityLogDetailsView.swift | 30 ++++++++----------- .../Details/DownloadBackupSheet.swift | 8 ++--- .../Details/DownloadBackupViewModel.swift | 9 ++++-- .../Activity/Details/RestoreBackupSheet.swift | 8 ++--- .../Details/RestoreBackupViewModel.swift | 9 ++++-- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index db65e0f48052..34404a4bef82 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -5,7 +5,7 @@ import Gridicons struct ActivityLogDetailsView: View { let activity: Activity - let site: JetpackSiteRef + let blog: Blog @Environment(\.dismiss) var dismiss @State private var showingRestoreSheet = false @@ -30,10 +30,10 @@ struct ActivityLogDetailsView: View { .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showingRestoreSheet) { - RestoreBackupSheet(activity: activity, site: site) + RestoreBackupSheet(activity: activity, blog: blog) } .sheet(isPresented: $showingDownloadSheet) { - DownloadBackupSheet(activity: activity, site: site) + DownloadBackupSheet(activity: activity, blog: blog) } } @@ -212,7 +212,7 @@ private struct ActivityCard: View { NavigationView { ActivityLogDetailsView( activity: ActivityLogDetailsView.Mocks.mockBackupActivity, - site: JetpackSiteRef.mock + blog: Blog.mock ) } } @@ -221,7 +221,7 @@ private struct ActivityCard: View { NavigationView { ActivityLogDetailsView( activity: ActivityLogDetailsView.Mocks.mockPluginActivity, - site: JetpackSiteRef.mock + blog: Blog.mock ) } } @@ -230,7 +230,7 @@ private struct ActivityCard: View { NavigationView { ActivityLogDetailsView( activity: ActivityLogDetailsView.Mocks.mockLoginActivity, - site: JetpackSiteRef.mock + blog: Blog.mock ) } } @@ -266,17 +266,13 @@ private enum Strings { // MARK: - Preview Helpers #if DEBUG -extension JetpackSiteRef { - static var mock: JetpackSiteRef { - var ref = JetpackSiteRef( - siteID: 123456789, - username: "test", - homeURL: "https://example.wordpress.com", - isSelfHostedWithoutJetpack: false, - xmlRPC: nil - ) - // Use reflection to set private properties for preview - return ref +extension Blog { + static var mock: Blog { + let blog = Blog() + blog.dotComID = NSNumber(value: 123456789) + blog.url = "https://example.wordpress.com" + blog.xmlrpc = "https://example.wordpress.com/xmlrpc.php" + return blog } } #endif diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift index 46650b58df49..d0f804b582f0 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift @@ -4,15 +4,15 @@ import WordPressShared struct DownloadBackupSheet: View { let activity: Activity - let site: JetpackSiteRef + let blog: Blog @StateObject private var viewModel: DownloadBackupViewModel @Environment(\.dismiss) private var dismiss - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site - self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, site: site)) + self.blog = blog + self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, blog: blog)) } var body: some View { diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift index f8f443b8d2b2..a7b30ab7c11e 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift @@ -26,13 +26,18 @@ final class DownloadBackupViewModel: ObservableObject { } private let activity: Activity + private let blog: Blog private let site: JetpackSiteRef private let backupService: JetpackBackupService private var downloadID: Int? - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site + self.blog = blog + guard let siteRef = JetpackSiteRef(blog: blog) else { + fatalError("Invalid blog for backup download") + } + self.site = siteRef self.backupService = JetpackBackupService(coreDataStack: ContextManager.shared.contextManager.mainContext) } diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift index 2e44dff9ac04..bbea2e886216 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift @@ -4,15 +4,15 @@ import WordPressShared struct RestoreBackupSheet: View { let activity: Activity - let site: JetpackSiteRef + let blog: Blog @StateObject private var viewModel: RestoreBackupViewModel @Environment(\.dismiss) private var dismiss - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site - self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, site: site)) + self.blog = blog + self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, blog: blog)) } var body: some View { diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift index d3184680ae8d..9600a6e1f0e5 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift @@ -15,13 +15,18 @@ final class RestoreBackupViewModel: ObservableObject { @Published var errorMessage: String? private let activity: Activity + private let blog: Blog private let site: JetpackSiteRef private let restoreService: JetpackRestoreService private let activityStore: ActivityStore - init(activity: Activity, site: JetpackSiteRef) { + init(activity: Activity, blog: Blog) { self.activity = activity - self.site = site + self.blog = blog + guard let siteRef = JetpackSiteRef(blog: blog) else { + fatalError("Invalid blog for restore") + } + self.site = siteRef self.restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) self.activityStore = StoreContainer.shared.activity } From b2e3c1cbffa75165657bfbb6388fa5da51e39407 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 14:54:42 -0400 Subject: [PATCH 03/13] Add multisite handling --- .../Activity/Details/RestoreBackupSheet.swift | 124 +++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift index bbea2e886216..c40b30242e3a 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift @@ -8,6 +8,8 @@ struct RestoreBackupSheet: View { @StateObject private var viewModel: RestoreBackupViewModel @Environment(\.dismiss) private var dismiss + @State private var isMultisite: Bool = false + @State private var isCheckingRewindStatus: Bool = true init(activity: Activity, blog: Blog) { self.activity = activity @@ -18,7 +20,12 @@ struct RestoreBackupSheet: View { var body: some View { NavigationView { VStack(spacing: 0) { - if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { + if isCheckingRewindStatus { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if isMultisite { + multisiteWarningView + } else if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { progressView } else { confirmationView @@ -27,9 +34,9 @@ struct RestoreBackupSheet: View { .navigationTitle(Strings.restoreTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { - if viewModel.state == .idle { + if viewModel.state == .idle || isMultisite { ToolbarItem(placement: .navigationBarLeading) { - Button(Strings.cancel) { + Button(isMultisite ? Strings.done : Strings.cancel) { dismiss() } } @@ -39,6 +46,7 @@ struct RestoreBackupSheet: View { } .onAppear { WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) + checkRewindStatus() } } @@ -230,10 +238,102 @@ struct RestoreBackupSheet: View { formatter.timeStyle = .short return formatter.string(from: activity.published) } + + private func checkRewindStatus() { + guard let siteRef = JetpackSiteRef(blog: blog) else { + isCheckingRewindStatus = false + return + } + + let restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) + restoreService.getRewindStatus( + for: siteRef, + success: { [weak self] rewindStatus in + DispatchQueue.main.async { + self?.isMultisite = rewindStatus.isMultisite() + self?.isCheckingRewindStatus = false + } + }, + failure: { [weak self] _ in + DispatchQueue.main.async { + // On error, assume it's not multisite and proceed + self?.isCheckingRewindStatus = false + } + } + ) + } + + @ViewBuilder + private var multisiteWarningView: some View { + VStack(spacing: 32) { + Spacer() + + VStack(spacing: 24) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + VStack(spacing: 16) { + Text(Strings.multisiteTitle) + .font(.title3) + .fontWeight(.semibold) + + // Create attributed string for the multisite message + Text(multisiteMessage) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + .tint(.accentColor) + } + } + + Spacer() + + VStack(spacing: 16) { + Link(destination: URL(string: Constants.multisiteDocumentationURL)!) { + Text(Strings.learnMore) + .font(.system(size: 17, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + + Text(Strings.multisiteDownloadHint) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .padding(.bottom, 24) + } + } + + private var multisiteMessage: AttributedString { + // Use the localized string from RewindStatus.Strings + let fullString = RewindStatus.Strings.multisiteNotAvailable + let linkSubstring = RewindStatus.Strings.multisiteNotAvailableSubstring + + var attributedString = AttributedString(fullString) + + // Find and style the link portion + if let range = attributedString.range(of: linkSubstring) { + attributedString[range].foregroundColor = .accentColor + attributedString[range].underlineStyle = .single + } + + return attributedString + } } // MARK: - Localized Strings +private enum Constants { + static let multisiteDocumentationURL = "https://jetpack.com/support/backup/restoring-your-site-from-backup/#multisite-restores" +} + private enum Strings { static let restoreTitle = NSLocalizedString( "restore.sheet.title", @@ -318,4 +418,22 @@ private enum Strings { value: "Done", comment: "Done button to dismiss the sheet" ) + + static let multisiteTitle = NSLocalizedString( + "restore.sheet.multisite.title", + value: "Restore Not Available", + comment: "Title for multisite restore limitation" + ) + + static let multisiteDownloadHint = NSLocalizedString( + "restore.sheet.multisite.downloadHint", + value: "You can still download a backup of your site", + comment: "Hint that download is still available for multisite" + ) + + static let learnMore = NSLocalizedString( + "restore.sheet.multisite.learnMore", + value: "Learn More", + comment: "Button to open documentation about multisite limitations" + ) } \ No newline at end of file From e57f31c12be73c3d7787f94b4c17029771bffb31 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:06:46 -0400 Subject: [PATCH 04/13] Remove the new screens and use the existing flows --- CLAUDE.md | 1 + .../ActivityLogDetailsCoordinator.swift | 86 ++++ .../Details/ActivityLogDetailsView.swift | 42 +- .../Details/DownloadBackupSheet.swift | 417 ----------------- .../Details/DownloadBackupViewModel.swift | 172 ------- .../Activity/Details/RestoreBackupSheet.swift | 439 ------------------ .../Details/RestoreBackupViewModel.swift | 129 ----- 7 files changed, 113 insertions(+), 1173 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift delete mode 100644 WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift diff --git a/CLAUDE.md b/CLAUDE.md index e69c9ec831a3..a3b75932d407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ WordPress-iOS uses a modular architecture with the main app and separate Swift p - Follow Swift API Design Guidelines - Use strict access control modifiers where possible - Use four spaces (not tabs) +- Use semantics text sizes like `.headline` ## Development Workflow - Branch from `trunk` (main branch) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift new file mode 100644 index 000000000000..26950e674b13 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -0,0 +1,86 @@ +import UIKit +import SwiftUI +import WordPressKit + +/// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers +class ActivityLogDetailsCoordinator: UIViewRepresentable { + static weak var shared: ActivityLogDetailsCoordinator? + + let activity: Activity + let blog: Blog + + init(activity: Activity, blog: Blog) { + self.activity = activity + self.blog = blog + ActivityLogDetailsCoordinator.shared = self + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.isHidden = true + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + // No updates needed + } + + func presentRestore() { + guard let viewController = topViewController(), + let siteRef = JetpackSiteRef(blog: blog), + activity.isRewindable, + activity.rewindID != nil else { + return + } + + // Check if the store has the credentials status cached + let store = StoreContainer.shared.activity + let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) + + let restoreViewController = JetpackRestoreOptionsViewController( + site: siteRef, + activity: activity, + isAwaitingCredentials: isAwaitingCredentials + ) + + restoreViewController.presentedFrom = "activity_detail" + + let navigationController = UINavigationController(rootViewController: restoreViewController) + navigationController.modalPresentationStyle = .formSheet + + viewController.present(navigationController, animated: true) + } + + func presentBackup() { + guard let viewController = topViewController(), + let siteRef = JetpackSiteRef(blog: blog) else { + return + } + + let backupViewController = JetpackBackupOptionsViewController( + site: siteRef, + activity: activity + ) + + backupViewController.presentedFrom = "activity_detail" + + let navigationController = UINavigationController(rootViewController: backupViewController) + navigationController.modalPresentationStyle = .formSheet + + viewController.present(navigationController, animated: true) + } + + private func topViewController() -> UIViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + return nil + } + + var topController = window.rootViewController + while let presentedViewController = topController?.presentedViewController { + topController = presentedViewController + } + + return topController + } +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 34404a4bef82..22364392a553 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -8,8 +8,6 @@ struct ActivityLogDetailsView: View { let blog: Blog @Environment(\.dismiss) var dismiss - @State private var showingRestoreSheet = false - @State private var showingDownloadSheet = false var body: some View { VStack(spacing: 0) { @@ -23,18 +21,32 @@ struct ActivityLogDetailsView: View { .padding() } - if activity.isRewindable { + if shouldShowBackupActions { actionButtons } } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showingRestoreSheet) { - RestoreBackupSheet(activity: activity, blog: blog) - } - .sheet(isPresented: $showingDownloadSheet) { - DownloadBackupSheet(activity: activity, blog: blog) - } + .background( + ActivityLogDetailsCoordinator( + activity: activity, + blog: blog + ) + ) + } + + private var shouldShowBackupActions: Bool { + // Show buttons for rewindable activities that are backup-related + guard activity.isRewindable else { return false } + + // Check if this is a backup activity based on the activity name + let backupActivityNames = [ + "rewind__backup_complete_full", + "rewind__backup_complete", + "rewind__backup_error" + ] + + return backupActivityNames.contains(activity.name) } @ViewBuilder @@ -45,7 +57,7 @@ struct ActivityLogDetailsView: View { HStack(spacing: 12) { // Restore Backup - Primary Button Button(action: { - showingRestoreSheet = true + ActivityLogDetailsCoordinator.shared?.presentRestore() }) { HStack { Image(systemName: "arrow.counterclockwise") @@ -62,7 +74,7 @@ struct ActivityLogDetailsView: View { // Download Backup - Secondary Button Button(action: { - showingDownloadSheet = true + ActivityLogDetailsCoordinator.shared?.presentBackup() }) { HStack { Image(systemName: "arrow.down.circle") @@ -268,11 +280,9 @@ private enum Strings { #if DEBUG extension Blog { static var mock: Blog { - let blog = Blog() - blog.dotComID = NSNumber(value: 123456789) - blog.url = "https://example.wordpress.com" - blog.xmlrpc = "https://example.wordpress.com/xmlrpc.php" - return blog + // For previews, we'll return a dummy blog object + // In real previews, this should be provided by the parent view + return Blog() } } #endif diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift deleted file mode 100644 index d0f804b582f0..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupSheet.swift +++ /dev/null @@ -1,417 +0,0 @@ -import SwiftUI -import WordPressKit -import WordPressShared - -struct DownloadBackupSheet: View { - let activity: Activity - let blog: Blog - - @StateObject private var viewModel: DownloadBackupViewModel - @Environment(\.dismiss) private var dismiss - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - self._viewModel = StateObject(wrappedValue: DownloadBackupViewModel(activity: activity, blog: blog)) - } - - var body: some View { - NavigationView { - VStack(spacing: 0) { - if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { - progressView - } else { - downloadOptionsView - } - } - .navigationTitle(Strings.downloadTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if viewModel.state == .idle { - ToolbarItem(placement: .navigationBarLeading) { - Button(Strings.cancel) { - dismiss() - } - } - } - } - .interactiveDismissDisabled(viewModel.state == .loading) - } - .onAppear { - WPAnalytics.track(.backupDownloadOpened, properties: ["source": "activity_detail"]) - } - } - - @ViewBuilder - private var downloadOptionsView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Activity Header - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - if let icon = activity.icon { - Image(uiImage: icon) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .foregroundColor(Color(activity.statusColor)) - .padding(12) - .background(Color(activity.statusColor).opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - - VStack(alignment: .leading, spacing: 4) { - Text(activity.summary) - .font(.headline) - .lineLimit(2) - - Text(formattedDate) - .font(.footnote) - .foregroundColor(.secondary) - } - - Spacer() - } - - if !activity.text.isEmpty { - Text(activity.text) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - - // Download Options - VStack(alignment: .leading, spacing: 16) { - Text(Strings.optionsTitle) - .font(.headline) - - VStack(spacing: 0) { - DownloadOptionRow( - title: Strings.optionThemes, - isSelected: $viewModel.includeThemes - ) - Divider().padding(.leading, 44) - - DownloadOptionRow( - title: Strings.optionPlugins, - isSelected: $viewModel.includePlugins - ) - Divider().padding(.leading, 44) - - DownloadOptionRow( - title: Strings.optionUploads, - isSelected: $viewModel.includeUploads - ) - Divider().padding(.leading, 44) - - DownloadOptionRow( - title: Strings.optionContent, - subtitle: Strings.optionContentSubtitle, - isSelected: $viewModel.includeContent - ) - } - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - } - - // Info Section - VStack(alignment: .leading, spacing: 8) { - Text(Strings.infoTitle) - .font(.footnote) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .textCase(.uppercase) - - Text(Strings.infoMessage) - .font(.footnote) - .foregroundColor(.secondary) - } - } - .padding() - } - - Spacer() - - // Bottom Action Button - VStack(spacing: 16) { - Divider() - - Button(action: { - viewModel.downloadBackup() - WPAnalytics.track(.backupDownloadConfirmed, properties: ["source": "activity_detail"]) - }) { - Text(Strings.confirmDownload) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(viewModel.hasSelection ? Color.accentColor : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(!viewModel.hasSelection) - .padding(.horizontal) - .padding(.bottom, 8) - } - } - - @ViewBuilder - private var progressView: some View { - VStack(spacing: 32) { - Spacer() - - switch viewModel.state { - case .loading: - VStack(spacing: 24) { - ProgressView() - .scaleEffect(1.5) - - VStack(spacing: 8) { - Text(Strings.preparingTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(Strings.preparingMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - case .success: - VStack(spacing: 24) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.green) - - VStack(spacing: 8) { - Text(Strings.successTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(Strings.successMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - if let downloadURL = viewModel.downloadURL { - Link(destination: URL(string: downloadURL)!) { - Text(Strings.downloadLink) - .font(.subheadline) - .fontWeight(.medium) - } - .padding(.top, 8) - } - } - } - - case .failure: - VStack(spacing: 24) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.red) - - VStack(spacing: 8) { - Text(Strings.failureTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(viewModel.errorMessage ?? Strings.failureMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - default: - EmptyView() - } - - Spacer() - - if viewModel.state == .success || viewModel.state == .failure { - Button(action: { - dismiss() - }) { - Text(Strings.done) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.bottom, 24) - } - } - } - - private var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: activity.published) - } -} - -// MARK: - Download Option Row - -private struct DownloadOptionRow: View { - let title: String - var subtitle: String? = nil - @Binding var isSelected: Bool - - var body: some View { - Button(action: { - isSelected.toggle() - }) { - HStack(spacing: 12) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 24)) - .foregroundColor(isSelected ? .accentColor : .secondary) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.body) - .foregroundColor(.primary) - - if let subtitle = subtitle { - Text(subtitle) - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - } - .padding() - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } -} - -// MARK: - Localized Strings - -private enum Strings { - static let downloadTitle = NSLocalizedString( - "download.sheet.title", - value: "Download Backup", - comment: "Title for the download backup sheet" - ) - - static let cancel = NSLocalizedString( - "download.sheet.cancel", - value: "Cancel", - comment: "Cancel button for download sheet" - ) - - static let optionsTitle = NSLocalizedString( - "download.sheet.options.title", - value: "Choose items to download", - comment: "Title for download options section" - ) - - static let optionThemes = NSLocalizedString( - "download.sheet.option.themes", - value: "Themes", - comment: "Option to download themes" - ) - - static let optionPlugins = NSLocalizedString( - "download.sheet.option.plugins", - value: "Plugins", - comment: "Option to download plugins" - ) - - static let optionUploads = NSLocalizedString( - "download.sheet.option.uploads", - value: "Media uploads", - comment: "Option to download media uploads" - ) - - static let optionContent = NSLocalizedString( - "download.sheet.option.content", - value: "Content", - comment: "Option to download content" - ) - - static let optionContentSubtitle = NSLocalizedString( - "download.sheet.option.content.subtitle", - value: "Posts, pages, and comments", - comment: "Subtitle for content option" - ) - - static let infoTitle = NSLocalizedString( - "download.sheet.info.title", - value: "About backup downloads", - comment: "Info section title in download sheet" - ) - - static let infoMessage = NSLocalizedString( - "download.sheet.info.message", - value: "Your backup will be prepared as a downloadable file. You'll receive an email with the download link when it's ready.", - comment: "Information about the download process" - ) - - static let confirmDownload = NSLocalizedString( - "download.sheet.confirm.button", - value: "Create downloadable file", - comment: "Confirm button for download action" - ) - - static let preparingTitle = NSLocalizedString( - "download.sheet.preparing.title", - value: "Preparing Your Backup", - comment: "Title shown while backup is being prepared" - ) - - static let preparingMessage = NSLocalizedString( - "download.sheet.preparing.message", - value: "We're creating a downloadable backup file. This may take a few moments.", - comment: "Message shown while backup is being prepared" - ) - - static let successTitle = NSLocalizedString( - "download.sheet.success.title", - value: "Backup Ready!", - comment: "Title shown when backup is ready" - ) - - static let successMessage = NSLocalizedString( - "download.sheet.success.message", - value: "Your backup has been prepared. You'll receive an email with the download link shortly.", - comment: "Message shown when backup is ready" - ) - - static let downloadLink = NSLocalizedString( - "download.sheet.success.link", - value: "Download now", - comment: "Link to download the backup" - ) - - static let failureTitle = NSLocalizedString( - "download.sheet.failure.title", - value: "Backup Failed", - comment: "Title shown when backup preparation fails" - ) - - static let failureMessage = NSLocalizedString( - "download.sheet.failure.message", - value: "We couldn't prepare your backup. Please try again or contact support if the problem persists.", - comment: "Message shown when backup preparation fails" - ) - - static let done = NSLocalizedString( - "download.sheet.done.button", - value: "Done", - comment: "Done button to dismiss the sheet" - ) -} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift deleted file mode 100644 index a7b30ab7c11e..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/DownloadBackupViewModel.swift +++ /dev/null @@ -1,172 +0,0 @@ -import Foundation -import WordPressKit -import WordPressShared - -@MainActor -final class DownloadBackupViewModel: ObservableObject { - enum State { - case idle - case loading - case success - case failure - } - - @Published var state: State = .idle - @Published var errorMessage: String? - @Published var downloadURL: String? - - // Download options - @Published var includeThemes = true - @Published var includePlugins = true - @Published var includeUploads = true - @Published var includeContent = true - - var hasSelection: Bool { - includeThemes || includePlugins || includeUploads || includeContent - } - - private let activity: Activity - private let blog: Blog - private let site: JetpackSiteRef - private let backupService: JetpackBackupService - private var downloadID: Int? - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - guard let siteRef = JetpackSiteRef(blog: blog) else { - fatalError("Invalid blog for backup download") - } - self.site = siteRef - self.backupService = JetpackBackupService(coreDataStack: ContextManager.shared.contextManager.mainContext) - } - - func downloadBackup() { - guard state == .idle, hasSelection else { return } - - state = .loading - errorMessage = nil - downloadURL = nil - - let restoreTypes = buildRestoreTypes() - - backupService.prepareBackup( - for: site, - rewindID: activity.rewindID, - restoreTypes: restoreTypes, - success: { [weak self] backup in - self?.handleBackupPrepared(backup) - }, - failure: { [weak self] error in - self?.handleBackupFailure(error) - } - ) - } - - private func buildRestoreTypes() -> JetpackRestoreTypes { - var types = JetpackRestoreTypes() - types.themes = includeThemes - types.plugins = includePlugins - types.uploads = includeUploads - types.sqls = includeContent - types.roots = includeContent - types.contents = includeContent - return types - } - - private func handleBackupPrepared(_ backup: JetpackBackup) { - downloadID = backup.downloadID - - // Check if backup is already ready - if let url = backup.url, !url.isEmpty { - downloadURL = url - state = .success - WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) - } else { - // Start polling for backup status - pollBackupStatus() - } - } - - private func pollBackupStatus() { - guard let downloadID = downloadID else { - handleBackupFailure(BackupError.missingDownloadID) - return - } - - backupService.getBackupStatus( - for: site, - downloadID: downloadID, - success: { [weak self] backup in - self?.handleBackupStatus(backup) - }, - failure: { [weak self] error in - self?.handleBackupFailure(error) - } - ) - } - - private func handleBackupStatus(_ backup: JetpackBackup) { - if let url = backup.url, !url.isEmpty { - // Backup is ready - downloadURL = url - state = .success - WPAnalytics.track(.backupDownloadSucceeded, properties: ["source": "activity_detail"]) - } else if backup.progress == nil { - // Backup failed - state = .failure - errorMessage = Strings.defaultErrorMessage - WPAnalytics.track(.backupDownloadFailed, properties: ["source": "activity_detail", "error": "no_progress"]) - } else { - // Still in progress, continue polling - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard self?.state == .loading else { return } - self?.pollBackupStatus() - } - } - } - - private func handleBackupFailure(_ error: Error) { - state = .failure - - if let networkError = error as? NSError { - errorMessage = networkError.localizedDescription - } else { - errorMessage = Strings.defaultErrorMessage - } - - WPAnalytics.track(.backupDownloadFailed, properties: [ - "source": "activity_detail", - "error": error.localizedDescription - ]) - } -} - -// MARK: - Errors - -private enum BackupError: LocalizedError { - case missingDownloadID - - var errorDescription: String? { - switch self { - case .missingDownloadID: - return Strings.missingDownloadIDError - } - } -} - -// MARK: - Localized Strings - -private enum Strings { - static let defaultErrorMessage = NSLocalizedString( - "download.viewModel.error.default", - value: "An error occurred while preparing your backup. Please try again.", - comment: "Default error message for backup download failures" - ) - - static let missingDownloadIDError = NSLocalizedString( - "download.viewModel.error.missingID", - value: "Unable to track backup progress. Please try again.", - comment: "Error when download ID is missing" - ) -} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift deleted file mode 100644 index c40b30242e3a..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupSheet.swift +++ /dev/null @@ -1,439 +0,0 @@ -import SwiftUI -import WordPressKit -import WordPressShared - -struct RestoreBackupSheet: View { - let activity: Activity - let blog: Blog - - @StateObject private var viewModel: RestoreBackupViewModel - @Environment(\.dismiss) private var dismiss - @State private var isMultisite: Bool = false - @State private var isCheckingRewindStatus: Bool = true - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - self._viewModel = StateObject(wrappedValue: RestoreBackupViewModel(activity: activity, blog: blog)) - } - - var body: some View { - NavigationView { - VStack(spacing: 0) { - if isCheckingRewindStatus { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if isMultisite { - multisiteWarningView - } else if viewModel.state == .loading || viewModel.state == .success || viewModel.state == .failure { - progressView - } else { - confirmationView - } - } - .navigationTitle(Strings.restoreTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if viewModel.state == .idle || isMultisite { - ToolbarItem(placement: .navigationBarLeading) { - Button(isMultisite ? Strings.done : Strings.cancel) { - dismiss() - } - } - } - } - .interactiveDismissDisabled(viewModel.state == .loading) - } - .onAppear { - WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) - checkRewindStatus() - } - } - - @ViewBuilder - private var confirmationView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Activity Header - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - if let icon = activity.icon { - Image(uiImage: icon) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .foregroundColor(Color(activity.statusColor)) - .padding(12) - .background(Color(activity.statusColor).opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - - VStack(alignment: .leading, spacing: 4) { - Text(activity.summary) - .font(.headline) - .lineLimit(2) - - Text(formattedDate) - .font(.footnote) - .foregroundColor(.secondary) - } - - Spacer() - } - - if !activity.text.isEmpty { - Text(activity.text) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - - // Warning Section - VStack(alignment: .leading, spacing: 12) { - Label(Strings.warningTitle, systemImage: "exclamationmark.triangle.fill") - .font(.headline) - .foregroundColor(.orange) - - Text(Strings.warningMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding() - .background(Color.orange.opacity(0.1)) - .cornerRadius(12) - - // Info Section - VStack(alignment: .leading, spacing: 8) { - Text(Strings.infoTitle) - .font(.footnote) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .textCase(.uppercase) - - Text(Strings.infoMessage) - .font(.footnote) - .foregroundColor(.secondary) - } - } - .padding() - } - - Spacer() - - // Bottom Action Button - VStack(spacing: 16) { - Divider() - - Button(action: { - viewModel.restore() - WPAnalytics.track(.restoreConfirmed, properties: ["source": "activity_detail"]) - }) { - Text(Strings.confirmRestore) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.bottom, 8) - } - } - - @ViewBuilder - private var progressView: some View { - VStack(spacing: 32) { - Spacer() - - switch viewModel.state { - case .loading: - VStack(spacing: 24) { - ProgressView() - .scaleEffect(1.5) - - VStack(spacing: 8) { - Text(Strings.restoringTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(String(format: Strings.restoringMessage, formattedDate)) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - case .success: - VStack(spacing: 24) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.green) - - VStack(spacing: 8) { - Text(Strings.successTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(Strings.successMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - case .failure: - VStack(spacing: 24) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.red) - - VStack(spacing: 8) { - Text(Strings.failureTitle) - .font(.title3) - .fontWeight(.semibold) - - Text(viewModel.errorMessage ?? Strings.failureMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - default: - EmptyView() - } - - Spacer() - - if viewModel.state == .success || viewModel.state == .failure { - Button(action: { - dismiss() - }) { - Text(Strings.done) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.bottom, 24) - } - } - } - - private var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: activity.published) - } - - private func checkRewindStatus() { - guard let siteRef = JetpackSiteRef(blog: blog) else { - isCheckingRewindStatus = false - return - } - - let restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) - restoreService.getRewindStatus( - for: siteRef, - success: { [weak self] rewindStatus in - DispatchQueue.main.async { - self?.isMultisite = rewindStatus.isMultisite() - self?.isCheckingRewindStatus = false - } - }, - failure: { [weak self] _ in - DispatchQueue.main.async { - // On error, assume it's not multisite and proceed - self?.isCheckingRewindStatus = false - } - } - ) - } - - @ViewBuilder - private var multisiteWarningView: some View { - VStack(spacing: 32) { - Spacer() - - VStack(spacing: 24) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 60)) - .foregroundColor(.orange) - - VStack(spacing: 16) { - Text(Strings.multisiteTitle) - .font(.title3) - .fontWeight(.semibold) - - // Create attributed string for the multisite message - Text(multisiteMessage) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - .tint(.accentColor) - } - } - - Spacer() - - VStack(spacing: 16) { - Link(destination: URL(string: Constants.multisiteDocumentationURL)!) { - Text(Strings.learnMore) - .font(.system(size: 17, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - - Text(Strings.multisiteDownloadHint) - .font(.footnote) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - .padding(.bottom, 24) - } - } - - private var multisiteMessage: AttributedString { - // Use the localized string from RewindStatus.Strings - let fullString = RewindStatus.Strings.multisiteNotAvailable - let linkSubstring = RewindStatus.Strings.multisiteNotAvailableSubstring - - var attributedString = AttributedString(fullString) - - // Find and style the link portion - if let range = attributedString.range(of: linkSubstring) { - attributedString[range].foregroundColor = .accentColor - attributedString[range].underlineStyle = .single - } - - return attributedString - } -} - -// MARK: - Localized Strings - -private enum Constants { - static let multisiteDocumentationURL = "https://jetpack.com/support/backup/restoring-your-site-from-backup/#multisite-restores" -} - -private enum Strings { - static let restoreTitle = NSLocalizedString( - "restore.sheet.title", - value: "Restore Site", - comment: "Title for the restore backup sheet" - ) - - static let cancel = NSLocalizedString( - "restore.sheet.cancel", - value: "Cancel", - comment: "Cancel button for restore sheet" - ) - - static let warningTitle = NSLocalizedString( - "restore.sheet.warning.title", - value: "Warning", - comment: "Warning section title in restore sheet" - ) - - static let warningMessage = NSLocalizedString( - "restore.sheet.warning.message", - value: "Restoring your site will revert all content, settings, and configurations to this backup point. Any changes made after this backup will be lost.", - comment: "Warning message about restore consequences" - ) - - static let infoTitle = NSLocalizedString( - "restore.sheet.info.title", - value: "What happens next", - comment: "Info section title in restore sheet" - ) - - static let infoMessage = NSLocalizedString( - "restore.sheet.info.message", - value: "The restore process typically takes a few minutes. You'll receive a notification when it's complete. Your site may be temporarily unavailable during the restore.", - comment: "Information about the restore process" - ) - - static let confirmRestore = NSLocalizedString( - "restore.sheet.confirm.button", - value: "Restore to This Point", - comment: "Confirm button for restore action" - ) - - static let restoringTitle = NSLocalizedString( - "restore.sheet.restoring.title", - value: "Restoring Your Site", - comment: "Title shown while restore is in progress" - ) - - static let restoringMessage = NSLocalizedString( - "restore.sheet.restoring.message", - value: "We're restoring your site back to %1$@", - comment: "Message shown while restore is in progress. %1$@ is the backup date" - ) - - static let successTitle = NSLocalizedString( - "restore.sheet.success.title", - value: "Restore Complete!", - comment: "Title shown when restore succeeds" - ) - - static let successMessage = NSLocalizedString( - "restore.sheet.success.message", - value: "Your site has been successfully restored. It may take a few moments for all changes to appear.", - comment: "Message shown when restore succeeds" - ) - - static let failureTitle = NSLocalizedString( - "restore.sheet.failure.title", - value: "Restore Failed", - comment: "Title shown when restore fails" - ) - - static let failureMessage = NSLocalizedString( - "restore.sheet.failure.message", - value: "We couldn't restore your site. Please try again or contact support if the problem persists.", - comment: "Message shown when restore fails" - ) - - static let done = NSLocalizedString( - "restore.sheet.done.button", - value: "Done", - comment: "Done button to dismiss the sheet" - ) - - static let multisiteTitle = NSLocalizedString( - "restore.sheet.multisite.title", - value: "Restore Not Available", - comment: "Title for multisite restore limitation" - ) - - static let multisiteDownloadHint = NSLocalizedString( - "restore.sheet.multisite.downloadHint", - value: "You can still download a backup of your site", - comment: "Hint that download is still available for multisite" - ) - - static let learnMore = NSLocalizedString( - "restore.sheet.multisite.learnMore", - value: "Learn More", - comment: "Button to open documentation about multisite limitations" - ) -} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift b/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift deleted file mode 100644 index 9600a6e1f0e5..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Details/RestoreBackupViewModel.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -import WordPressKit -import WordPressShared - -@MainActor -final class RestoreBackupViewModel: ObservableObject { - enum State { - case idle - case loading - case success - case failure - } - - @Published var state: State = .idle - @Published var errorMessage: String? - - private let activity: Activity - private let blog: Blog - private let site: JetpackSiteRef - private let restoreService: JetpackRestoreService - private let activityStore: ActivityStore - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - guard let siteRef = JetpackSiteRef(blog: blog) else { - fatalError("Invalid blog for restore") - } - self.site = siteRef - self.restoreService = JetpackRestoreService(coreDataStack: ContextManager.shared.contextManager) - self.activityStore = StoreContainer.shared.activity - } - - func restore() { - guard state == .idle else { return } - - state = .loading - errorMessage = nil - - restoreService.restoreSite( - site, - rewindID: activity.rewindID, - restoreTypes: nil, // nil means restore everything - success: { [weak self] restoreID, jobID in - self?.handleRestoreStarted(restoreID: restoreID, jobID: jobID) - }, - failure: { [weak self] error in - self?.handleRestoreFailure(error) - } - ) - } - - private func handleRestoreStarted(restoreID: String, jobID: Int) { - // Start monitoring the restore status - pollRestoreStatus() - } - - private func pollRestoreStatus() { - restoreService.getRewindStatus( - for: site, - success: { [weak self] rewindStatus in - self?.handleRewindStatus(rewindStatus) - }, - failure: { [weak self] error in - self?.handleRestoreFailure(error) - } - ) - } - - private func handleRewindStatus(_ rewindStatus: RewindStatus) { - guard let restoreStatus = rewindStatus.restore else { - // No active restore, check if we need to keep polling - if state == .loading { - // Continue polling after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard self?.state == .loading else { return } - self?.pollRestoreStatus() - } - } - return - } - - switch restoreStatus.status { - case .finished: - state = .success - WPAnalytics.track(.restoreSucceeded, properties: ["source": "activity_detail"]) - - case .fail: - state = .failure - errorMessage = restoreStatus.message ?? Strings.defaultErrorMessage - WPAnalytics.track(.restoreFailed, properties: ["source": "activity_detail", "error": errorMessage ?? "unknown"]) - - case .running, .queued: - // Continue polling - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard self?.state == .loading else { return } - self?.pollRestoreStatus() - } - - default: - break - } - } - - private func handleRestoreFailure(_ error: Error) { - state = .failure - - if let networkError = error as? NSError { - errorMessage = networkError.localizedDescription - } else { - errorMessage = Strings.defaultErrorMessage - } - - WPAnalytics.track(.restoreFailed, properties: [ - "source": "activity_detail", - "error": error.localizedDescription - ]) - } -} - -// MARK: - Localized Strings - -private enum Strings { - static let defaultErrorMessage = NSLocalizedString( - "restore.viewModel.error.default", - value: "An error occurred while restoring your site. Please try again.", - comment: "Default error message for restore failures" - ) -} \ No newline at end of file From f6624ce39daea4446621d452a6b839bf879b6ba5 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:10:28 -0400 Subject: [PATCH 05/13] Add analytics --- .../ActivityLogDetailsCoordinator.swift | 68 +++++-------------- .../Details/ActivityLogDetailsView.swift | 56 ++++++++++----- 2 files changed, 56 insertions(+), 68 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift index 26950e674b13..b3b0320118ef 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -3,84 +3,50 @@ import SwiftUI import WordPressKit /// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers -class ActivityLogDetailsCoordinator: UIViewRepresentable { - static weak var shared: ActivityLogDetailsCoordinator? - - let activity: Activity - let blog: Blog - - init(activity: Activity, blog: Blog) { - self.activity = activity - self.blog = blog - ActivityLogDetailsCoordinator.shared = self - } - - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.isHidden = true - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - // No updates needed - } - - func presentRestore() { - guard let viewController = topViewController(), +enum ActivityLogDetailsCoordinator { + + static func presentRestore(activity: Activity, blog: Blog) { + guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog), activity.isRewindable, activity.rewindID != nil else { return } - + // Check if the store has the credentials status cached let store = StoreContainer.shared.activity let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) - + let restoreViewController = JetpackRestoreOptionsViewController( site: siteRef, activity: activity, isAwaitingCredentials: isAwaitingCredentials ) - + restoreViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: restoreViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - - func presentBackup() { - guard let viewController = topViewController(), + + static func presentBackup(activity: Activity, blog: Blog) { + guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog) else { return } - + let backupViewController = JetpackBackupOptionsViewController( site: siteRef, activity: activity ) - + backupViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: backupViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - - private func topViewController() -> UIViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { - return nil - } - - var topController = window.rootViewController - while let presentedViewController = topController?.presentedViewController { - topController = presentedViewController - } - - return topController - } -} \ No newline at end of file +} diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 22364392a553..a435d2f3ede5 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -1,6 +1,7 @@ import SwiftUI import WordPressKit import WordPressUI +import WordPressShared import Gridicons struct ActivityLogDetailsView: View { @@ -20,44 +21,42 @@ struct ActivityLogDetailsView: View { } .padding() } - + if shouldShowBackupActions { actionButtons } } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) - .background( - ActivityLogDetailsCoordinator( - activity: activity, - blog: blog - ) - ) + .onAppear { + trackDetailViewed() + } } - + private var shouldShowBackupActions: Bool { // Show buttons for rewindable activities that are backup-related guard activity.isRewindable else { return false } - + // Check if this is a backup activity based on the activity name let backupActivityNames = [ "rewind__backup_complete_full", "rewind__backup_complete", "rewind__backup_error" ] - + return backupActivityNames.contains(activity.name) } - + @ViewBuilder private var actionButtons: some View { VStack(spacing: 12) { Divider() - + HStack(spacing: 12) { // Restore Backup - Primary Button Button(action: { - ActivityLogDetailsCoordinator.shared?.presentRestore() + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) }) { HStack { Image(systemName: "arrow.counterclockwise") @@ -71,10 +70,11 @@ struct ActivityLogDetailsView: View { .foregroundColor(.white) .cornerRadius(8) } - + // Download Backup - Secondary Button Button(action: { - ActivityLogDetailsCoordinator.shared?.presentBackup() + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) }) { HStack { Image(systemName: "arrow.down.circle") @@ -261,13 +261,13 @@ private enum Strings { value: "User", comment: "Section title for user information" ) - + static let restoreBackup = NSLocalizedString( "activityDetail.restoreBackup.button", value: "Restore Backup", comment: "Button title for restoring a backup" ) - + static let downloadBackup = NSLocalizedString( "activityDetail.downloadBackup.button", value: "Download Backup", @@ -275,6 +275,28 @@ private enum Strings { ) } +// MARK: - Analytics + +private extension ActivityLogDetailsView { + func trackDetailViewed() { + WPAnalytics.track(.activityLogDetailViewed, withProperties: ["source": presentedFrom()]) + } + + func trackRestoreTapped() { + WPAnalytics.track(.restoreOpened, properties: ["source": "activity_detail"]) + } + + func trackBackupTapped() { + WPAnalytics.track(.backupDownloadOpened, properties: ["source": "activity_detail"]) + } + + func presentedFrom() -> String { + // Since we're in SwiftUI, we'll default to "activity_log" + // In the future, this could be passed as a parameter + return "activity_log" + } +} + // MARK: - Preview Helpers #if DEBUG From cbf1b14b46e706fe7c122d208a05d5427e418417 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:14:46 -0400 Subject: [PATCH 06/13] Cleanup --- .../ActivityLogDetailsCoordinator.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift index b3b0320118ef..be6a5693e606 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -4,7 +4,7 @@ import WordPressKit /// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers enum ActivityLogDetailsCoordinator { - + static func presentRestore(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog), @@ -12,41 +12,41 @@ enum ActivityLogDetailsCoordinator { activity.rewindID != nil else { return } - + // Check if the store has the credentials status cached let store = StoreContainer.shared.activity let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) - + let restoreViewController = JetpackRestoreOptionsViewController( site: siteRef, activity: activity, isAwaitingCredentials: isAwaitingCredentials ) - + restoreViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: restoreViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - + static func presentBackup(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog) else { return } - + let backupViewController = JetpackBackupOptionsViewController( site: siteRef, activity: activity ) - + backupViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: backupViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } } From d3d4bb6be0f6c37c8926fc7903741e2a6ef75ab3 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:26:50 -0400 Subject: [PATCH 07/13] Add missing analytics --- .../Details/ActivityLogDetailsView.swift | 5 ----- .../List/ActivityLogRowViewModel.swift | 2 +- .../Activity/List/ActivityLogsMenu.swift | 15 +++++++++++-- .../Activity/List/ActivityLogsView.swift | 7 +++--- .../Activity/List/ActivityLogsViewModel.swift | 22 +++++++++++++++++++ 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index a435d2f3ede5..33996a3c5102 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -41,7 +41,6 @@ struct ActivityLogDetailsView: View { let backupActivityNames = [ "rewind__backup_complete_full", "rewind__backup_complete", - "rewind__backup_error" ] return backupActivityNames.contains(activity.name) @@ -60,9 +59,7 @@ struct ActivityLogDetailsView: View { }) { HStack { Image(systemName: "arrow.counterclockwise") - .font(.system(size: 16, weight: .medium)) Text(Strings.restoreBackup) - .font(.system(size: 16, weight: .medium)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) @@ -78,9 +75,7 @@ struct ActivityLogDetailsView: View { }) { HStack { Image(systemName: "arrow.down.circle") - .font(.system(size: 16, weight: .regular)) Text(Strings.downloadBackup) - .font(.system(size: 16, weight: .regular)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift index 0c233a8a9c3c..d7f040c79404 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -20,7 +20,7 @@ struct ActivityLogRowViewModel: Identifiable { self.activity = activity self.id = activity.activityID if let actor = activity.actor { - if actor.role.isEmpty { + if !actor.role.isEmpty { actorSubtitle = actor.role.localizedCapitalized } else if !actor.type.isEmpty { actorSubtitle = actor.type.localizedCapitalized diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift index 7b7d26780941..b059c5816844 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsMenu.swift @@ -1,5 +1,6 @@ import SwiftUI import WordPressKit +import WordPressShared struct ActivityLogsMenu: View { @ObservedObject var viewModel: ActivityLogsViewModel @@ -29,14 +30,16 @@ struct ActivityLogsMenu: View { DatePickerSheet( title: Strings.startDate, selection: $viewModel.parameters.startDate, - isPresented: $isShowingStartDatePicker + isPresented: $isShowingStartDatePicker, + viewModel: viewModel ) } .sheet(isPresented: $isShowingEndDatePicker) { DatePickerSheet( title: Strings.endDate, selection: $viewModel.parameters.endDate, - isPresented: $isShowingEndDatePicker + isPresented: $isShowingEndDatePicker, + viewModel: viewModel ) } } @@ -45,6 +48,8 @@ struct ActivityLogsMenu: View { Group { // Start Date Button { + // Track analytics for date filter tap + WPAnalytics.track(.activitylogFilterbarRangeButtonTapped) isShowingStartDatePicker = true } label: { Text(Strings.startDate) @@ -56,6 +61,8 @@ struct ActivityLogsMenu: View { // End Date Button { + // Track analytics for date filter tap + WPAnalytics.track(.activitylogFilterbarRangeButtonTapped) isShowingEndDatePicker = true } label: { Text(Strings.endDate) @@ -69,6 +76,7 @@ struct ActivityLogsMenu: View { private var activityTypeFilter: some View { Button { + WPAnalytics.track(.activitylogFilterbarTypeButtonTapped) isShowingActivityTypePicker = true } label: { Text(Strings.activityTypes) @@ -81,6 +89,8 @@ struct ActivityLogsMenu: View { private var resetFiltersButton: some View { Button(role: .destructive) { + WPAnalytics.track(.activitylogFilterbarResetRange) + WPAnalytics.track(.activitylogFilterbarResetType) viewModel.parameters = GetActivityLogsParameters() } label: { Label(Strings.resetFilters, systemImage: "arrow.counterclockwise") @@ -92,6 +102,7 @@ private struct DatePickerSheet: View { let title: String @Binding var selection: Date? @Binding var isPresented: Bool + var viewModel: ActivityLogsViewModel? @State private var date = Date() diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift index f424e4b9cff2..c4b7f4615531 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsView.swift @@ -23,7 +23,7 @@ private struct ActivityLogsListView: View { var body: some View { List { if let response = viewModel.response { - ActivityLogsPaginatedForEach(response: response) + ActivityLogsPaginatedForEach(response: response, blog: viewModel.blog) if viewModel.isFreePlan { Text(Strings.freePlanNotice) @@ -70,13 +70,14 @@ private struct ActivityLogsSearchView: View { searchText: viewModel.searchText, search: viewModel.search ) { response in - ActivityLogsPaginatedForEach(response: response) + ActivityLogsPaginatedForEach(response: response, blog: viewModel.blog) } } } private struct ActivityLogsPaginatedForEach: View { @ObservedObject var response: ActivityLogsPaginatedResponse + let blog: Blog struct ActivityGroup: Identifiable { var id: Date { date } @@ -115,7 +116,7 @@ private struct ActivityLogsPaginatedForEach: View { .onAppear { response.onRowAppeared(item) } .background { NavigationLink { - ActivityLogDetailsView(activity: item.activity) + ActivityLogDetailsView(activity: item.activity, blog: blog) } label: { EmptyView() }.opacity(0) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift index 06fd2acd9095..a66b1547cc1a 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -1,6 +1,7 @@ import Foundation import WordPressKit import WordPressUI +import WordPressShared typealias ActivityLogsPaginatedResponse = DataViewPaginatedResponse @@ -11,6 +12,7 @@ final class ActivityLogsViewModel: ObservableObject { @Published var searchText = "" @Published var parameters = GetActivityLogsParameters() { didSet { + trackParameterChanges(oldValue: oldValue, newValue: parameters) response = nil onRefreshNeeded() } @@ -103,6 +105,26 @@ final class ActivityLogsViewModel: ObservableObject { ) } } + + // MARK: - Analytics + + private func trackParameterChanges(oldValue: GetActivityLogsParameters, newValue: GetActivityLogsParameters) { + // Track date range changes + if oldValue.startDate != newValue.startDate || oldValue.endDate != newValue.endDate { + if newValue.startDate != nil || newValue.endDate != nil { + WPAnalytics.track(.activitylogFilterbarSelectRange) + } + } + + // Track activity type changes + if oldValue.activityTypes != newValue.activityTypes { + if newValue.activityTypes.isEmpty { + WPAnalytics.track(.activitylogFilterbarResetType) + } else { + WPAnalytics.track(.activitylogFilterbarSelectType, properties: ["count": newValue.activityTypes.count]) + } + } + } } private func makeViewModels(for activities: [Activity]) async -> [ActivityLogRowViewModel] { From 053cd420321bf8c3ef21976c8d0fec1cae8d2bae Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:30:43 -0400 Subject: [PATCH 08/13] Cleanup --- .gitignore | 3 + .../Details/ActivityLogDetailsView.swift | 96 ++++++++----------- 2 files changed, 41 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 9924e15ba219..0a0b9d4bf719 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ DerivedData *.hmap *.xcscmblueprint +# Claude +.claude/settings.local.json + # Windows Thumbs.db ehthumbs.db diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 33996a3c5102..8442d046a3ab 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -11,20 +11,19 @@ struct ActivityLogDetailsView: View { @Environment(\.dismiss) var dismiss var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 24) { + ScrollView { + VStack(spacing: 24) { + VStack(spacing: 16) { ActivityHeaderView(activity: activity) - if let actor = activity.actor { - ActorCard(actor: actor) + if shouldShowBackupActions { + backupActionButtons } } - .padding() - } - - if shouldShowBackupActions { - actionButtons + if let actor = activity.actor { + ActorCard(actor: actor) + } } + .padding() } .navigationTitle(Strings.eventTitle) .navigationBarTitleDisplayMode(.inline) @@ -47,51 +46,32 @@ struct ActivityLogDetailsView: View { } @ViewBuilder - private var actionButtons: some View { - VStack(spacing: 12) { - Divider() - - HStack(spacing: 12) { - // Restore Backup - Primary Button - Button(action: { - trackRestoreTapped() - ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) - }) { - HStack { - Image(systemName: "arrow.counterclockwise") - Text(Strings.restoreBackup) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(8) - } - - // Download Backup - Secondary Button - Button(action: { - trackBackupTapped() - ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) - }) { - HStack { - Image(systemName: "arrow.down.circle") - Text(Strings.downloadBackup) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(Color.clear) - .foregroundColor(.accentColor) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.accentColor, lineWidth: 1) - ) - } + private var backupActionButtons: some View { + HStack(spacing: 12) { + Button(action: { + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) + }) { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + + Button(action: { + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) + }) { + Label(Strings.download, systemImage: "arrow.down.circle") + .fontWeight(.medium) } - .padding(.horizontal) - .padding(.bottom, 12) + .buttonStyle(.bordered) + .controlSize(.regular) + .tint(.accentColor) } - .background(Color(.systemBackground)) + .frame(maxWidth: .infinity, alignment: .leading) } + } // MARK: - Header View @@ -257,15 +237,15 @@ private enum Strings { comment: "Section title for user information" ) - static let restoreBackup = NSLocalizedString( - "activityDetail.restoreBackup.button", - value: "Restore Backup", + static let restore = NSLocalizedString( + "activityDetail.restore.button", + value: "Restore", comment: "Button title for restoring a backup" ) - static let downloadBackup = NSLocalizedString( - "activityDetail.downloadBackup.button", - value: "Download Backup", + static let download = NSLocalizedString( + "activityDetail.download.button", + value: "Download", comment: "Button title for downloading a backup" ) } From 16c4c6ed4c7bffc8d2593b053c43e8fe1c5ed740 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 15:58:04 -0400 Subject: [PATCH 09/13] Fix an issue with not all restorable acitivies shown in backups --- .../ActivityLogDetailsCoordinator.swift | 22 +++++++++---------- .../Details/ActivityLogDetailsView.swift | 17 ++------------ .../Activity/List/ActivityLogsViewModel.swift | 6 ++--- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift index be6a5693e606..b3b0320118ef 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsCoordinator.swift @@ -4,7 +4,7 @@ import WordPressKit /// Coordinator to handle navigation from SwiftUI ActivityLogDetailsView to UIKit view controllers enum ActivityLogDetailsCoordinator { - + static func presentRestore(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog), @@ -12,41 +12,41 @@ enum ActivityLogDetailsCoordinator { activity.rewindID != nil else { return } - + // Check if the store has the credentials status cached let store = StoreContainer.shared.activity let isAwaitingCredentials = store.isAwaitingCredentials(site: siteRef) - + let restoreViewController = JetpackRestoreOptionsViewController( site: siteRef, activity: activity, isAwaitingCredentials: isAwaitingCredentials ) - + restoreViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: restoreViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } - + static func presentBackup(activity: Activity, blog: Blog) { guard let viewController = UIViewController.topViewController, let siteRef = JetpackSiteRef(blog: blog) else { return } - + let backupViewController = JetpackBackupOptionsViewController( site: siteRef, activity: activity ) - + backupViewController.presentedFrom = "activity_detail" - + let navigationController = UINavigationController(rootViewController: backupViewController) navigationController.modalPresentationStyle = .formSheet - + viewController.present(navigationController, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 8442d046a3ab..d4e2a50f8a26 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -15,7 +15,7 @@ struct ActivityLogDetailsView: View { VStack(spacing: 24) { VStack(spacing: 16) { ActivityHeaderView(activity: activity) - if shouldShowBackupActions { + if activity.isRewindable { backupActionButtons } } @@ -32,19 +32,6 @@ struct ActivityLogDetailsView: View { } } - private var shouldShowBackupActions: Bool { - // Show buttons for rewindable activities that are backup-related - guard activity.isRewindable else { return false } - - // Check if this is a backup activity based on the activity name - let backupActivityNames = [ - "rewind__backup_complete_full", - "rewind__backup_complete", - ] - - return backupActivityNames.contains(activity.name) - } - @ViewBuilder private var backupActionButtons: some View { HStack(spacing: 12) { @@ -57,7 +44,7 @@ struct ActivityLogDetailsView: View { } .buttonStyle(.borderedProminent) .controlSize(.regular) - + Button(action: { trackBackupTapped() ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift index a66b1547cc1a..15058bd494fd 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogsViewModel.swift @@ -105,9 +105,9 @@ final class ActivityLogsViewModel: ObservableObject { ) } } - + // MARK: - Analytics - + private func trackParameterChanges(oldValue: GetActivityLogsParameters, newValue: GetActivityLogsParameters) { // Track date range changes if oldValue.startDate != newValue.startDate || oldValue.endDate != newValue.endDate { @@ -115,7 +115,7 @@ final class ActivityLogsViewModel: ObservableObject { WPAnalytics.track(.activitylogFilterbarSelectRange) } } - + // Track activity type changes if oldValue.activityTypes != newValue.activityTypes { if newValue.activityTypes.isEmpty { From d7bdf750237a341a2de68af3ef16153c4689ec48 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:06:18 -0400 Subject: [PATCH 10/13] Fix an issue with restore/download flow layout --- RELEASE-NOTES.txt | 2 ++ .../Restore Status/BaseRestoreStatusViewController.swift | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 77b5c4ab1c71..5903c9b2c2d6 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,8 @@ * [**] Add new “Subscribers” screen that shows both your email and Reader subscribers [#24513] * [*] Fix an issue with “Stats / Subscribers” sometimes not showing the latest email subscribers [#24513] * [*] Fix an issue with "Stats" / "Subscribers" / "Emails" showing html encoded characters [#24513] +* [*] Add search to “Jetpack Activity List” and display actors and dates [#24597] +* [*] Fix an issue with content in "Restore" and "Download Backup" flows covering the navigation bar [#24597] 25.9 ----- diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift index 7e41f7ea14f3..f03e97184f04 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressUI import WordPressShared struct JetpackRestoreStatusConfiguration { @@ -92,7 +93,7 @@ class BaseRestoreStatusViewController: UIViewController { statusView.update(progress: 0, progressTitle: configuration.placeholderProgressTitle, progressDescription: nil) view.addSubview(statusView) - view.pinSubviewToAllEdges(statusView) + statusView.pinEdges(to: view.safeAreaLayoutGuide) } @objc private func doneTapped() { From 2ece5e946950e26f18bd94dc0e7268a51c26fc68 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:19:23 -0400 Subject: [PATCH 11/13] Show restore checkpoint in section --- .../Details/ActivityLogDetailsView.swift | 99 ++++++++++++------- .../BaseRestoreCompleteViewController.swift | 3 +- .../JetpackRestoreWarningViewController.swift | 3 +- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index d4e2a50f8a26..5fa24885976e 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -13,11 +13,15 @@ struct ActivityLogDetailsView: View { var body: some View { ScrollView { VStack(spacing: 24) { - VStack(spacing: 16) { - ActivityHeaderView(activity: activity) - if activity.isRewindable { - backupActionButtons - } + ActivityHeaderView(activity: activity) + if activity.isRewindable { + RestoreSiteCard(activity: activity, onRestoreTapped: { + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) + }, onBackupTapped: { + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) + }) } if let actor = activity.actor { ActorCard(actor: actor) @@ -31,34 +35,6 @@ struct ActivityLogDetailsView: View { trackDetailViewed() } } - - @ViewBuilder - private var backupActionButtons: some View { - HStack(spacing: 12) { - Button(action: { - trackRestoreTapped() - ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) - }) { - Label(Strings.restore, systemImage: "arrow.counterclockwise") - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - - Button(action: { - trackBackupTapped() - ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) - }) { - Label(Strings.download, systemImage: "arrow.down.circle") - .fontWeight(.medium) - } - .buttonStyle(.bordered) - .controlSize(.regular) - .tint(.accentColor) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } // MARK: - Header View @@ -148,6 +124,51 @@ private struct ActorCard: View { } } +// MARK: - Restore Site Card + +private struct RestoreSiteCard: View { + let activity: Activity + let onRestoreTapped: () -> Void + let onBackupTapped: () -> Void + + var body: some View { + ActivityCard(Strings.restoreSite) { + + VStack(spacing: 16) { + // Checkpoint date info row + HStack { + Text(Strings.checkpointDate) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Text(activity.published.formatted(date: .abbreviated, time: .standard)) + .font(.subheadline) + .foregroundStyle(.primary) + } + + // Action buttons + HStack(spacing: 12) { + Button(action: onRestoreTapped) { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + + Button(action: onBackupTapped) { + Label(Strings.download, systemImage: "arrow.down.circle") + .fontWeight(.medium) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .tint(.accentColor) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + // MARK: - Shared Components private struct ActivityCard: View { @@ -224,6 +245,18 @@ private enum Strings { comment: "Section title for user information" ) + static let restoreSite = NSLocalizedString( + "activityDetail.section.restoreSite", + value: "Restore Site", + comment: "Section title for restore site actions" + ) + + static let checkpointDate = NSLocalizedString( + "activityDetail.checkpointDate", + value: "Checkpoint Date", + comment: "Label for the backup checkpoint date" + ) + static let restore = NSLocalizedString( "activityDetail.restore.button", value: "Restore", diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift index 40f7488b9482..cab7f11339c8 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift @@ -1,5 +1,6 @@ import Foundation import WordPressShared +import WordPressUI struct JetpackRestoreCompleteConfiguration { let title: String @@ -103,7 +104,7 @@ class BaseRestoreCompleteViewController: UIViewController { } view.addSubview(completeView) - view.pinSubviewToAllEdges(completeView) + completeView.pinEdges(to: view.safeAreaLayoutGuide) } @objc private func doneTapped() { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift index ad9eeeea5523..e08e32727e18 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift @@ -1,6 +1,7 @@ import UIKit import WordPressFlux import WordPressShared +import WordPressUI class JetpackRestoreWarningViewController: UIViewController { @@ -80,7 +81,7 @@ class JetpackRestoreWarningViewController: UIViewController { warningView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(warningView) - view.pinSubviewToAllEdges(warningView) + warningView.pinEdges(to: view.safeAreaLayoutGuide) } } From 4af44a5c1d9e8ec0618ad9d60f36ca8a07ab218a Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:28:22 -0400 Subject: [PATCH 12/13] Extract reusable CardView and InfoRow components from SubscribersDetailsVIew --- .../Sources/WordPressUI/Views/CardView.swift | 56 ++++++++++++ .../Sources/WordPressUI/Views/InfoRow.swift | 88 ++++++++++++++++++ .../Details/ActivityLogDetailsView.swift | 44 +-------- .../Details/SubscriberDetailsView.swift | 91 ++++--------------- 4 files changed, 163 insertions(+), 116 deletions(-) create mode 100644 Modules/Sources/WordPressUI/Views/CardView.swift create mode 100644 Modules/Sources/WordPressUI/Views/InfoRow.swift diff --git a/Modules/Sources/WordPressUI/Views/CardView.swift b/Modules/Sources/WordPressUI/Views/CardView.swift new file mode 100644 index 000000000000..21b519c7dd11 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/CardView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// A reusable card view component that provides a consistent container style +/// with optional title and customizable content. +public struct CardView: View { + let title: String? + @ViewBuilder let content: () -> Content + + public init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.content = content + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + Group { + if let title { + Text(title.uppercased()) + .font(.caption) + .foregroundStyle(.secondary) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(.separator), lineWidth: 0.5) + ) + } +} + +#Preview("With Title") { + CardView("Section Title") { + VStack(alignment: .leading, spacing: 12) { + Text("Card Content") + Text("More content here") + .foregroundStyle(.secondary) + } + } + .padding() +} + +#Preview("Without Title") { + CardView { + HStack { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + Text("Featured Item") + Spacer() + } + } + .padding() +} \ No newline at end of file diff --git a/Modules/Sources/WordPressUI/Views/InfoRow.swift b/Modules/Sources/WordPressUI/Views/InfoRow.swift new file mode 100644 index 000000000000..7ba80648c6f0 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/InfoRow.swift @@ -0,0 +1,88 @@ +import SwiftUI + +/// A reusable info row component that displays a title and customizable content. +/// Commonly used within cards or forms to display labeled information. +public struct InfoRow: View { + let title: String + @ViewBuilder let content: () -> Content + + public init(_ title: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.content = content + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + content() + .font(.subheadline.weight(.regular)) + .lineLimit(1) + .textSelection(.enabled) + } + } +} + +// MARK: - Convenience Initializer + +extension InfoRow where Content == Text { + /// Convenience initializer for displaying a simple text value. + /// If the value is nil, displays a dash placeholder. + public init(_ title: String, value: String?) { + self.init(title) { + Text(value ?? "–") + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Previews + +#Preview("Text Value") { + VStack(spacing: 16) { + InfoRow("Email", value: "user@example.com") + InfoRow("Country", value: "United States") + InfoRow("Phone", value: nil) + } + .padding() +} + +#Preview("Custom Content") { + VStack(spacing: 16) { + InfoRow("Status") { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + Text("Active") + .foregroundStyle(.green) + } + } + + InfoRow("Website") { + Link("example.com", destination: URL(string: "https://example.com")!) + } + + InfoRow("Tags") { + HStack(spacing: 4) { + Text("Swift") + Image(systemName: "chevron.forward") + .font(.caption2) + } + .foregroundStyle(.tint) + } + } + .padding() +} + +#Preview("In Card") { + CardView("User Details") { + VStack(spacing: 16) { + InfoRow("Name", value: "John Appleseed") + InfoRow("Email", value: "john@example.com") + InfoRow("Member Since", value: "January 2024") + } + } + .padding() +} \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 5fa24885976e..986f9294bc90 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -103,7 +103,7 @@ private struct ActorCard: View { let actor: ActivityActor var body: some View { - ActivityCard(Strings.user) { + CardView(Strings.user) { HStack(spacing: 12) { // Actor avatar ActivityActorAvatarView(actor: actor, diameter: 40) @@ -132,18 +132,11 @@ private struct RestoreSiteCard: View { let onBackupTapped: () -> Void var body: some View { - ActivityCard(Strings.restoreSite) { - + CardView(Strings.restoreSite) { VStack(spacing: 16) { // Checkpoint date info row - HStack { - Text(Strings.checkpointDate) - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() + InfoRow(Strings.checkpointDate) { Text(activity.published.formatted(date: .abbreviated, time: .standard)) - .font(.subheadline) - .foregroundStyle(.primary) } // Action buttons @@ -169,37 +162,6 @@ private struct RestoreSiteCard: View { } } -// MARK: - Shared Components - -private struct ActivityCard: View { - let title: String? - @ViewBuilder let content: () -> Content - - init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - if let title { - Text(title.uppercased()) - .font(.caption) - .foregroundStyle(.secondary) - } - - content() - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding() - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color(.separator), lineWidth: 0.5) - ) - } -} // MARK: - Preview diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift index d9a5a41bb6fe..9e1af2deffe1 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift @@ -51,7 +51,7 @@ struct SubscriberDetailsView: View { SubscriberDetailsHeaderView(subscriber: info) } if let detailsError { - SubscriberDetailsCardView { + CardView { EmptyStateView.failure(error: detailsError) { Task { await refresh() } } @@ -123,8 +123,8 @@ struct SubscriberDetailsView: View { // MARK: Views private func makeNewsletterSubscriptionSection(for details: SubscribersServiceRemote.GetSubscriberDetailsResponse) -> some View { - SubscriberDetailsCardView(Strings.sectionNewsletterSubscription) { - SubscriberInfoRow(Strings.fieldSubscriptionDate, value: viewModel.formattedDateSubscribed(details.dateSubscribed)) + CardView(Strings.sectionNewsletterSubscription) { + InfoRow(Strings.fieldSubscriptionDate, value: viewModel.formattedDateSubscribed(details.dateSubscribed)) let plans = details.plans ?? [] if let plan = plans.first { NavigationLink { @@ -137,7 +137,7 @@ struct SubscriberDetailsView: View { .navigationTitle(Strings.fieldPlan) .navigationBarTitleDisplayMode(.inline) } label: { - SubscriberInfoRow(Strings.fieldPlan) { + InfoRow(Strings.fieldPlan) { HStack(spacing: 4) { Text(plan.title) Image(systemName: "chevron.forward") @@ -148,13 +148,13 @@ struct SubscriberDetailsView: View { } .buttonStyle(.plain) } else { - SubscriberInfoRow(Strings.fieldPlan, value: Strings.free) + InfoRow(Strings.fieldPlan, value: Strings.free) } } } private func makePlanView(for plan: SubscribersServiceRemote.GetSubscriberDetailsResponse.Plan) -> some View { - SubscriberDetailsCardView { + CardView { HStack { Text(plan.title) .font(.headline) @@ -166,25 +166,25 @@ struct SubscriberDetailsView: View { .foregroundStyle(.secondary) } } - SubscriberInfoRow(Strings.fieldPlanStatus, value: plan.status) + InfoRow(Strings.fieldPlanStatus, value: plan.status) if plan.renewInterval != "one-time" { - SubscriberInfoRow(Strings.fieldRenewalInterval, value: plan.renewInterval) - SubscriberInfoRow(Strings.fieldRenewalPrice, value: { + InfoRow(Strings.fieldRenewalInterval, value: plan.renewInterval) + InfoRow(Strings.fieldRenewalPrice, value: { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = plan.currency return formatter.string(from: plan.renewalPrice as NSNumber) }()) } - SubscriberInfoRow(Strings.fieldPlanStartDate, value: plan.startDate.formatted(date: .abbreviated, time: .shortened)) - SubscriberInfoRow(Strings.fieldPlanEndDate, value: plan.endDate.formatted(date: .abbreviated, time: .shortened)) + InfoRow(Strings.fieldPlanStartDate, value: plan.startDate.formatted(date: .abbreviated, time: .shortened)) + InfoRow(Strings.fieldPlanEndDate, value: plan.endDate.formatted(date: .abbreviated, time: .shortened)) } } @ViewBuilder private func makeSubscriberDetailsSections(for details: SubscribersServiceRemote.GetSubscriberDetailsResponse) -> some View { - SubscriberDetailsCardView(Strings.sectionSubscriberDetails) { - SubscriberInfoRow(Strings.fieldEmail) { + CardView(Strings.sectionSubscriberDetails) { + InfoRow(Strings.fieldEmail) { if let email = details.emailAddress, let url = URL(string: "mailto://\(email)") { Link(email, destination: url) } else { @@ -192,9 +192,9 @@ struct SubscriberDetailsView: View { .foregroundStyle(.secondary) } } - SubscriberInfoRow(Strings.fieldCountry, value: details.country?.name) + InfoRow(Strings.fieldCountry, value: details.country?.name) if let site = details.siteURL { - SubscriberInfoRow(Strings.fieldSite) { + InfoRow(Strings.fieldSite) { if let siteURL = URL(string: site) { Link(site, destination: siteURL) } else { @@ -235,7 +235,7 @@ private struct SubscriberStatsView: View { let stats: SubscribersServiceRemote.GetSubscriberStatsResponse var body: some View { - SubscriberDetailsCardView { + CardView { HStack { SubsciberStatsRow( systemImage: "envelope", @@ -263,36 +263,6 @@ private struct SubscriberStatsView: View { } } -private struct SubscriberInfoRow: View { - let title: String - @ViewBuilder let content: () -> Content - - init(_ title: String, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - content() - .font(.subheadline.weight(.regular)) - .lineLimit(1) - .textSelection(.enabled) - } - } -} - -extension SubscriberInfoRow where Content == Text { - init(_ title: String, value: String?) { - self.init(title) { - Text(value ?? "–") - .foregroundColor(AppColor.secondary) - } - } -} private struct SubsciberStatsRow: View { let systemImage: String @@ -314,35 +284,6 @@ private struct SubsciberStatsRow: View { } } -private struct SubscriberDetailsCardView: View { - let title: String? - @ViewBuilder let content: () -> Content - - init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Group { - if let title { - Text(title.uppercased()) - .font(.caption) - .foregroundStyle(.secondary) - } - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding() - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color(.separator), lineWidth: 0.5) - ) - } -} private extension SubscribersServiceRemote.GetSubscriberStatsResponse { var formattedEmailsCount: String { From ea903d777d784e48d7ffcc16f72221d343578a31 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 19 Jun 2025 16:38:00 -0400 Subject: [PATCH 13/13] Cleanup --- .../Sources/WordPressUI/Views/CardView.swift | 6 +- .../Sources/WordPressUI/Views/InfoRow.swift | 13 +- .../Details/ActivityLogDetailsView.swift | 122 ++++++++---------- .../Details/SubscriberDetailsView.swift | 2 - 4 files changed, 66 insertions(+), 77 deletions(-) diff --git a/Modules/Sources/WordPressUI/Views/CardView.swift b/Modules/Sources/WordPressUI/Views/CardView.swift index 21b519c7dd11..34e09bf6168b 100644 --- a/Modules/Sources/WordPressUI/Views/CardView.swift +++ b/Modules/Sources/WordPressUI/Views/CardView.swift @@ -5,12 +5,12 @@ import SwiftUI public struct CardView: View { let title: String? @ViewBuilder let content: () -> Content - + public init(_ title: String? = nil, @ViewBuilder content: @escaping () -> Content) { self.title = title self.content = content } - + public var body: some View { VStack(alignment: .leading, spacing: 16) { Group { @@ -53,4 +53,4 @@ public struct CardView: View { } } .padding() -} \ No newline at end of file +} diff --git a/Modules/Sources/WordPressUI/Views/InfoRow.swift b/Modules/Sources/WordPressUI/Views/InfoRow.swift index 7ba80648c6f0..a8d389e4c366 100644 --- a/Modules/Sources/WordPressUI/Views/InfoRow.swift +++ b/Modules/Sources/WordPressUI/Views/InfoRow.swift @@ -1,16 +1,17 @@ import SwiftUI +import DesignSystem /// A reusable info row component that displays a title and customizable content. /// Commonly used within cards or forms to display labeled information. public struct InfoRow: View { let title: String @ViewBuilder let content: () -> Content - + public init(_ title: String, @ViewBuilder content: @escaping () -> Content) { self.title = title self.content = content } - + public var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) @@ -32,7 +33,7 @@ extension InfoRow where Content == Text { public init(_ title: String, value: String?) { self.init(title) { Text(value ?? "–") - .foregroundStyle(.secondary) + .foregroundColor(AppColor.secondary) } } } @@ -59,11 +60,11 @@ extension InfoRow where Content == Text { .foregroundStyle(.green) } } - + InfoRow("Website") { Link("example.com", destination: URL(string: "https://example.com")!) } - + InfoRow("Tags") { HStack(spacing: 4) { Text("Swift") @@ -85,4 +86,4 @@ extension InfoRow where Content == Text { } } .padding() -} \ No newline at end of file +} diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 986f9294bc90..80bf3d6e1d88 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -14,17 +14,11 @@ struct ActivityLogDetailsView: View { ScrollView { VStack(spacing: 24) { ActivityHeaderView(activity: activity) - if activity.isRewindable { - RestoreSiteCard(activity: activity, onRestoreTapped: { - trackRestoreTapped() - ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) - }, onBackupTapped: { - trackBackupTapped() - ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) - }) - } if let actor = activity.actor { - ActorCard(actor: actor) + makeActorCard(for: actor) + } + if activity.isRewindable { + restoreSiteCard } } .padding() @@ -35,6 +29,58 @@ struct ActivityLogDetailsView: View { trackDetailViewed() } } + + private func makeActorCard(for actor: ActivityActor) -> some View { + CardView(Strings.user) { + HStack(spacing: 12) { + // Actor avatar + ActivityActorAvatarView(actor: actor, diameter: 40) + + // Actor info + VStack(alignment: .leading, spacing: 2) { + Text(actor.displayName) + .font(.headline) + + Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + } + } + } + + private var restoreSiteCard: some View { + CardView(Strings.restoreSite) { + // Checkpoint date info row + InfoRow(Strings.checkpointDate) { + Text(activity.published.formatted(date: .abbreviated, time: .standard)) + } + + // Action buttons + HStack(spacing: 12) { + Button(action: { + trackRestoreTapped() + ActivityLogDetailsCoordinator.presentRestore(activity: activity, blog: blog) + }) { + Label(Strings.restore, systemImage: "arrow.counterclockwise") + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + + Button(action: { + trackBackupTapped() + ActivityLogDetailsCoordinator.presentBackup(activity: activity, blog: blog) + }) { + Label(Strings.download, systemImage: "arrow.down.circle") + .fontWeight(.medium) + } + .buttonStyle(.bordered) + .tint(.accentColor) + } + } + } } // MARK: - Header View @@ -103,66 +149,10 @@ private struct ActorCard: View { let actor: ActivityActor var body: some View { - CardView(Strings.user) { - HStack(spacing: 12) { - // Actor avatar - ActivityActorAvatarView(actor: actor, diameter: 40) - - // Actor info - VStack(alignment: .leading, spacing: 2) { - Text(actor.displayName) - .font(.headline) - - Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - } - } - } -} - -// MARK: - Restore Site Card - -private struct RestoreSiteCard: View { - let activity: Activity - let onRestoreTapped: () -> Void - let onBackupTapped: () -> Void - var body: some View { - CardView(Strings.restoreSite) { - VStack(spacing: 16) { - // Checkpoint date info row - InfoRow(Strings.checkpointDate) { - Text(activity.published.formatted(date: .abbreviated, time: .standard)) - } - - // Action buttons - HStack(spacing: 12) { - Button(action: onRestoreTapped) { - Label(Strings.restore, systemImage: "arrow.counterclockwise") - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - - Button(action: onBackupTapped) { - Label(Strings.download, systemImage: "arrow.down.circle") - .fontWeight(.medium) - } - .buttonStyle(.bordered) - .controlSize(.regular) - .tint(.accentColor) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } } } - // MARK: - Preview #Preview("Backup Activity") { diff --git a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift index 9e1af2deffe1..5a3e836e02c7 100644 --- a/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Subscribers/Details/SubscriberDetailsView.swift @@ -263,7 +263,6 @@ private struct SubscriberStatsView: View { } } - private struct SubsciberStatsRow: View { let systemImage: String let title: String @@ -284,7 +283,6 @@ private struct SubsciberStatsRow: View { } } - private extension SubscribersServiceRemote.GetSubscriberStatsResponse { var formattedEmailsCount: String { emailsSent.formatted(.number.notation(.compactName))