Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
488 changes: 260 additions & 228 deletions OpenASO.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

23 changes: 20 additions & 3 deletions OpenASO/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class AppServices {
let refreshCoordinator: RankingRefreshCoordinator
let appDetailRefreshService: AppDetailRefreshService?
let refreshProgressStore: AppRefreshProgressStore
let appPricingCache: AppPricingCache
let mcpServerController: OpenASOMCPServerController
private(set) var backgroundModelStore: BackgroundModelStore?
private(set) var backgroundModelStoreRevision = 0
Expand Down Expand Up @@ -100,6 +101,7 @@ final class AppServices {
let keywordInsightsService = KeywordInsightsService()
let rankingProvider = ITunesSearchFallbackProvider(httpClient: httpClient)
let refreshProgressStore = AppRefreshProgressStore()
let appPricingCache = AppPricingCache()
let storefrontCatalog = StorefrontCatalog()
let metadataEnrichmentHandler: (@Sendable ([RankingMetadataEnrichmentRequest]) async -> Void)?
if let backgroundModelStore {
Expand Down Expand Up @@ -179,9 +181,9 @@ final class AppServices {
},
metadataEnrichmentHandler: metadataEnrichmentHandler
)
let appDetailRefreshService = backgroundModelStore.map {
let appDetailRefreshService = backgroundModelStore.map { backgroundModelStore in
AppDetailRefreshService(
backgroundModelStore: $0,
backgroundModelStore: backgroundModelStore,
refreshCoordinator: refreshCoordinator,
keywordMetricsService: keywordMetricsService,
appStorefrontRatingService: appStorefrontRatingService,
Expand All @@ -190,7 +192,21 @@ final class AppServices {
progressStore: refreshProgressStore,
ratingsReviewsRefreshRecorder: { date in
await settingsStore.markRatingsReviewsRefreshed(on: date)
}
},
pricingRefresh: { appStoreID, storefronts, progress in
let service = OpenASOMCPService(
backgroundModelStore: backgroundModelStore,
appResolver: resolver,
appCatalogService: catalogService,
httpClient: httpClient
)
return try await service.compareAppPricing(
appStoreIDs: [appStoreID],
storefronts: storefronts,
progress: progress
)
},
pricingCache: appPricingCache
)
}

Expand Down Expand Up @@ -246,6 +262,7 @@ final class AppServices {
self.refreshCoordinator = refreshCoordinator
self.appDetailRefreshService = appDetailRefreshService
self.refreshProgressStore = refreshProgressStore
self.appPricingCache = appPricingCache
self.mcpServerController = OpenASOMCPServerController(portProvider: {
settingsStore.mcpServerPort
}) {
Expand Down
30 changes: 27 additions & 3 deletions OpenASO/App/RootSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ struct RootSidebarView: View {
@State private var hoveredAppID: Int64?
@State private var isPresentingMCPServer = false
@State private var isMCPHovered = false
@State private var isPresentingGlobalKeywords = false
@State private var isGlobalKeywordsHovered = false
@State private var isSettingsHovered = false

private var unfiledApps: [TrackedApp] {
Expand Down Expand Up @@ -70,6 +72,9 @@ struct RootSidebarView: View {
settingsStore: services.settingsStore
)
}
.sheet(isPresented: $isPresentingGlobalKeywords) {
GlobalKeywordsSheet()
}
.alert("Rename Folder", isPresented: $isPresentingRenameFolder) {
TextField("Folder Name", text: $folderName)

Expand Down Expand Up @@ -248,6 +253,22 @@ struct RootSidebarView: View {
.stroke(Color.secondary.opacity(0.15))
)

Button {
isPresentingGlobalKeywords = true
} label: {
SidebarUtilityRow(
title: "Global Keywords",
systemImage: "globe",
state: nil,
isHovered: isGlobalKeywordsHovered
)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.onHover { isGlobalKeywordsHovered = $0 }
.animation(.easeInOut(duration: 0.12), value: isGlobalKeywordsHovered)
.help("Manage the shared global keyword list and apply it to apps")

Button {
isPresentingMCPServer = true
} label: {
Expand Down Expand Up @@ -792,6 +813,7 @@ private struct SidebarRefreshProgressView: View {
SidebarRefreshProgressRow(title: "Keywords & Metrics", progress: refresh.keywordAndMetricsProgress)
SidebarRefreshProgressRow(title: "Ratings", progress: refresh.ratingsProgress)
SidebarRefreshProgressRow(title: "Reviews", progress: refresh.reviewsProgress)
SidebarRefreshProgressRow(title: "Pricing", progress: refresh.pricingProgress)
SidebarPendingAppRefreshesRow(appCount: pendingAppRefreshCount)
SidebarPendingKeywordAdditionsRow(trackCount: pendingKeywordTrackCount)
}
Expand Down Expand Up @@ -835,6 +857,8 @@ private struct SidebarRefreshProgressView: View {
return refresh.phase == .completed ? "Daily refresh complete" : refresh.phase == .failed ? "Daily refresh failed" : "Running daily refresh"
case "apple_ads_connection":
return refresh.phase == .completed ? "Popularity refreshed" : refresh.phase == .failed ? "Popularity refresh failed" : "Refreshing keyword popularity"
case "global_keyword_sync":
return refresh.phase == .completed ? "Global keywords synced" : refresh.phase == .failed ? "Global keyword sync failed" : "Fetching global keyword rankings"
default:
return refresh.phase.title
}
Expand All @@ -854,7 +878,7 @@ private struct SidebarRefreshProgressView: View {
return "App data ready"
case .failed:
return "App data update failed"
case .refreshingKeywords, .refreshingMetrics:
case .refreshingKeywords, .refreshingMetrics, .refreshingPricing:
return refresh.phase.title
}
}
Expand All @@ -873,7 +897,7 @@ private struct SidebarRefreshProgressView: View {
return "Keyword data ready"
case .failed:
return "Keyword data update failed"
case .refreshingRatings, .refreshingReviews:
case .refreshingRatings, .refreshingReviews, .refreshingPricing:
return refresh.phase.title
}
}
Expand All @@ -892,7 +916,7 @@ private struct SidebarRefreshProgressView: View {
return "Imported keywords ready"
case .failed:
return "Imported keyword update failed"
case .refreshingRatings, .refreshingReviews:
case .refreshingRatings, .refreshingReviews, .refreshingPricing:
return refresh.phase.title
}
}
Expand Down
10 changes: 10 additions & 0 deletions OpenASO/Features/AppDetail/AppDetailSupportTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,35 @@ enum TrendDateRange: CaseIterable, Identifiable {

enum AppDetailWorkspaceView: String, CaseIterable, Identifiable {
case keywords
case competitors
case ratings
case pricing

var id: String { rawValue }

var title: String {
switch self {
case .keywords:
return "Keywords"
case .competitors:
return "Competitors"
case .ratings:
return "Ratings"
case .pricing:
return "Pricing"
}
}

var searchPlaceholder: String {
switch self {
case .keywords:
return "Search keywords"
case .competitors:
return "Search opportunities"
case .ratings:
return "Search"
case .pricing:
return "Search plans"
}
}
}
Expand Down
159 changes: 159 additions & 0 deletions OpenASO/Features/AppDetail/AppDetailToolbarViews.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,87 @@
import SwiftData
import SwiftUI

struct AppDetailRefreshDataSelection: Equatable, Sendable {
var refreshRankings: Bool
var refreshMetrics: Bool
var refreshRatings: Bool
var refreshReviews: Bool
var refreshPricing: Bool

static let pricing = AppDetailRefreshDataSelection(
refreshRankings: false,
refreshMetrics: false,
refreshRatings: false,
refreshReviews: false,
refreshPricing: true
)
static let rankings = AppDetailRefreshDataSelection(
refreshRankings: true,
refreshMetrics: false,
refreshRatings: false,
refreshReviews: false,
refreshPricing: false
)
static let metrics = AppDetailRefreshDataSelection(
refreshRankings: false,
refreshMetrics: true,
refreshRatings: false,
refreshReviews: false,
refreshPricing: false
)
static let rankingsAndMetrics = AppDetailRefreshDataSelection(
refreshRankings: true,
refreshMetrics: true,
refreshRatings: false,
refreshReviews: false,
refreshPricing: false
)
static let ratings = AppDetailRefreshDataSelection(
refreshRankings: false,
refreshMetrics: false,
refreshRatings: true,
refreshReviews: false,
refreshPricing: false
)
static let reviews = AppDetailRefreshDataSelection(
refreshRankings: false,
refreshMetrics: false,
refreshRatings: false,
refreshReviews: true,
refreshPricing: false
)
static let ratingsAndReviews = AppDetailRefreshDataSelection(
refreshRankings: false,
refreshMetrics: false,
refreshRatings: true,
refreshReviews: true,
refreshPricing: false
)
static let allData = AppDetailRefreshDataSelection(
refreshRankings: true,
refreshMetrics: true,
refreshRatings: true,
refreshReviews: true,
refreshPricing: true
)

var title: String {
var parts: [String] = []
if refreshRankings { parts.append("rankings") }
if refreshMetrics { parts.append("metrics") }
if refreshRatings { parts.append("ratings") }
if refreshReviews { parts.append("reviews") }
if refreshPricing { parts.append("pricing") }
return parts.isEmpty ? "nothing" : parts.joined(separator: ", ")
}
}

struct AppDetailRefreshToolbarButton: View {
let isRefreshing: Bool
let isDisabled: Bool
let action: () -> Void
let refreshAllAction: () -> Void
let refreshAllSelectedDataAction: (AppDetailRefreshDataSelection) -> Void

var body: some View {
Menu {
Expand All @@ -15,6 +91,65 @@ struct AppDetailRefreshToolbarButton: View {
Label("Refresh All Apps", systemImage: "arrow.triangle.2.circlepath")
}
.disabled(isDisabled)

Divider()

Menu {
Button {
refreshAllSelectedDataAction(.pricing)
} label: {
Label("Pricing", systemImage: "tag")
}

Button {
refreshAllSelectedDataAction(.rankings)
} label: {
Label("Keyword Rankings", systemImage: "chart.line.uptrend.xyaxis")
}

Button {
refreshAllSelectedDataAction(.metrics)
} label: {
Label("Keyword Metrics", systemImage: "gauge.with.dots.needle.67percent")
}

Button {
refreshAllSelectedDataAction(.rankingsAndMetrics)
} label: {
Label("Rankings + Metrics", systemImage: "chart.xyaxis.line")
}

Divider()

Button {
refreshAllSelectedDataAction(.ratings)
} label: {
Label("Ratings", systemImage: "star")
}

Button {
refreshAllSelectedDataAction(.reviews)
} label: {
Label("Reviews", systemImage: "text.bubble")
}

Button {
refreshAllSelectedDataAction(.ratingsAndReviews)
} label: {
Label("Ratings + Reviews", systemImage: "star.bubble")
}

Divider()

Button {
refreshAllSelectedDataAction(.allData)
} label: {
Label("All Data", systemImage: "square.stack.3d.up")
}
} label: {
Label("Refresh All Apps With Selected Data", systemImage: "checklist")
}
.disabled(isDisabled)
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
} primaryAction: {
Expand Down Expand Up @@ -205,6 +340,30 @@ struct AppDetailAddKeywordsToolbarButton: View {
}
}

struct AppDetailMarketInsightsToolbarButton: View {
let action: () -> Void

var body: some View {
Button(action: action) {
Label("Market Insights", systemImage: "globe")
.labelStyle(.titleAndIcon)
}
.help("Best and worst ranking markets per keyword")
}
}

struct AppDetailKeywordListsToolbarButton: View {
let action: () -> Void

var body: some View {
Button(action: action) {
Label("Keyword Lists", systemImage: "list.bullet.rectangle")
.labelStyle(.titleAndIcon)
}
.help("View and edit keyword lists")
}
}

private struct AppDetailFilterButton: View {
@Binding var selectedPlatformFilter: PlatformFilter
@Binding var popularityFilterRange: ClosedRange<Double>
Expand Down
Loading