Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ dist/
# Xcode user state
*.xcuserdatad
*.xcuserstate

# Local reports, audits, and scratch work
/.local/
14 changes: 8 additions & 6 deletions OpenASO/App/RootSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ struct RootSidebarView: View {
}

private var keywordTrackCountsByAppStoreID: [Int64: Int] {
Dictionary(grouping: trackedKeywords, by: \.appStoreID).mapValues(\.count)
trackedKeywords.reduce(into: [:]) { counts, track in
counts[track.appStoreID, default: 0] += 1
}
}

var body: some View {
Expand Down Expand Up @@ -158,7 +160,8 @@ struct RootSidebarView: View {
}

private var appList: some View {
Group {
let keywordCounts = keywordTrackCountsByAppStoreID
return Group {
if trackedApps.isEmpty && appFolders.isEmpty {
ContentUnavailableView(
"No Apps Yet",
Expand All @@ -178,7 +181,7 @@ struct RootSidebarView: View {
)
) {
ForEach(sortedApps(in: folder)) { trackedApp in
appRow(for: trackedApp)
appRow(for: trackedApp, keywordTrackCount: keywordCounts[trackedApp.appStoreID, default: 0])
}
} label: {
folderLabel(for: folder)
Expand All @@ -200,7 +203,7 @@ struct RootSidebarView: View {
}

ForEach(unfiledApps) { trackedApp in
appRow(for: trackedApp)
appRow(for: trackedApp, keywordTrackCount: keywordCounts[trackedApp.appStoreID, default: 0])
}
}
.listStyle(.sidebar)
Expand Down Expand Up @@ -323,10 +326,9 @@ struct RootSidebarView: View {
}
}

private func appRow(for trackedApp: TrackedApp) -> some View {
private func appRow(for trackedApp: TrackedApp, keywordTrackCount: Int) -> some View {
let appStoreID = trackedApp.appStoreID
let draggableID = String(appStoreID)
let keywordTrackCount = keywordTrackCountsByAppStoreID[appStoreID, default: 0]

return HStack(spacing: 12) {
AppIconView(
Expand Down
13 changes: 12 additions & 1 deletion OpenASO/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct RootView: View {

var body: some View {
NavigationSplitView {
RootSidebarView(selectedApp: $selectedApp)
RootSidebarView(selectedApp: appSelection)
.navigationSplitViewColumnWidth(min: 280, ideal: 320)
} detail: {
RootDetailView(selectedApp: selectedApp)
Expand Down Expand Up @@ -59,6 +59,17 @@ struct RootView: View {
}
}

private var appSelection: Binding<TrackedApp?> {
Binding(
get: { selectedApp },
set: { app in
var transaction = Transaction(animation: nil)
transaction.disablesAnimations = true
withTransaction(transaction) { selectedApp = app }
}
)
}

private static var isRunningUnderTests: Bool {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}
Expand Down
31 changes: 17 additions & 14 deletions OpenASO/Features/AppDetail/AppDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,23 @@ struct AppDetailView: View {
refreshToken: ratingsRefreshToken
)
} else {
AppKeywordsView(
trackedApp: trackedApp,
searchText: searchText,
selectedStorefrontFilter: selectedStorefrontFilter,
selectedDateRange: keywordWorkspaceState.selectedDateRange,
selectedPlatformFilter: keywordWorkspaceState.selectedPlatformFilter,
popularityFilterRange: keywordWorkspaceState.popularityFilterRange,
difficultyFilterRange: keywordWorkspaceState.difficultyFilterRange,
positionFilterRange: keywordWorkspaceState.positionFilterRange,
changeFilterRange: keywordWorkspaceState.changeFilterRange,
showsOnlyChangedKeywords: keywordWorkspaceState.showsOnlyChangedKeywords,
refreshToken: keywordRefreshToken,
reportError: setErrorMessage
)
KeywordWorkspaceTabs(selectedApp: trackedApp, trackedApps: trackedApps) { app, isActive in
AppKeywordsView(
trackedApp: app,
isActive: isActive,
searchText: searchText,
selectedStorefrontFilter: selectedStorefrontFilter,
selectedDateRange: keywordWorkspaceState.selectedDateRange,
selectedPlatformFilter: keywordWorkspaceState.selectedPlatformFilter,
popularityFilterRange: keywordWorkspaceState.popularityFilterRange,
difficultyFilterRange: keywordWorkspaceState.difficultyFilterRange,
positionFilterRange: keywordWorkspaceState.positionFilterRange,
changeFilterRange: keywordWorkspaceState.changeFilterRange,
showsOnlyChangedKeywords: keywordWorkspaceState.showsOnlyChangedKeywords,
refreshToken: keywordRefreshToken,
reportError: setErrorMessage
)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Expand Down
78 changes: 69 additions & 9 deletions OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct AppKeywordsView: View {
@Query private var tracks: [TrackedAppKeyword]

let trackedApp: TrackedApp
let isActive: Bool
let searchText: String
let selectedStorefrontFilter: StorefrontFilter
let selectedDateRange: TrendDateRange
Expand All @@ -24,6 +25,7 @@ struct AppKeywordsView: View {

init(
trackedApp: TrackedApp,
isActive: Bool = true,
searchText: String,
selectedStorefrontFilter: StorefrontFilter,
selectedDateRange: TrendDateRange,
Expand All @@ -37,6 +39,7 @@ struct AppKeywordsView: View {
reportError: @escaping (String) -> Void
) {
self.trackedApp = trackedApp
self.isActive = isActive
self.searchText = searchText
self.selectedStorefrontFilter = selectedStorefrontFilter
self.selectedDateRange = selectedDateRange
Expand Down Expand Up @@ -86,7 +89,8 @@ struct AppKeywordsView: View {
dateRangeID: selectedDateRange.id,
tracks: tracks.map { track in
KeywordWorkspaceProjection.MaterializationID.TrackIdentity(
identityKey: track.identityKey
identityKey: track.identityKey,
notes: track.notes
)
}
)
Expand Down Expand Up @@ -146,6 +150,7 @@ struct AppKeywordsView: View {
let storefronts = storefrontDefinitions
KeywordWorkspaceContent(
workspaceModel: workspaceModel,
isActive: isActive,
targetMaterializationID: targetMaterializationID,
filters: filters,
hasTrackedKeywords: !queriedTracks.isEmpty,
Expand All @@ -156,7 +161,8 @@ struct AppKeywordsView: View {
reportError: reportError
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: targetMaterializationID) {
.task(id: isActive ? targetMaterializationID : nil) {
guard isActive else { return }
await materializeKeywordRows(
for: targetMaterializationID,
tracks: queriedTracks
Expand All @@ -168,6 +174,10 @@ struct AppKeywordsView: View {
for targetMaterializationID: KeywordWorkspaceProjection.MaterializationID,
tracks: [TrackedAppKeyword]
) async {
if workspaceModel.reuseMaterialization(for: targetMaterializationID, filters: filters) {
return
}

let platformTracks = tracks.filter { selectedPlatformFilter.matches($0.platform) }
let storefrontLookup = Dictionary(
uniqueKeysWithValues: storefrontDefinitions.map { ($0.code, $0) }
Expand Down Expand Up @@ -217,6 +227,7 @@ private struct KeywordWorkspaceContent: View {
@Environment(AppServices.self) private var services

let workspaceModel: KeywordWorkspaceModel
let isActive: Bool
let targetMaterializationID: KeywordWorkspaceProjection.MaterializationID
let filters: KeywordWorkspaceProjection.Filters
let hasTrackedKeywords: Bool
Expand Down Expand Up @@ -263,14 +274,17 @@ private struct KeywordWorkspaceContent: View {
}
}
.background {
KeywordDataUpdateListener(
workspaceModel: workspaceModel,
appStoreID: trackedAppStoreID,
dateRange: selectedDateRange,
reportError: reportError
)
if isActive {
KeywordDataUpdateListener(
workspaceModel: workspaceModel,
appStoreID: trackedAppStoreID,
dateRange: selectedDateRange,
reportError: reportError
)
}
}
.task(id: filterID) {
.task(id: isActive ? filterID : nil) {
guard isActive else { return }
await workspaceModel.updateFilter(id: filterID) { rows, filters in
try await KeywordWorkspaceProjection.debouncedRows(
rows,
Expand Down Expand Up @@ -534,3 +548,49 @@ private extension Array {
}
}
}

// Keep the current app and two recent apps mounted so sidebar navigation does
// not reconstruct hundreds of table cells. Evicted views release their state.
private struct KeywordWorkspacePage<Content: View>: View {
let isActive: Bool
@ViewBuilder let content: Content

var body: some View {
content
// Opaque pages stay mounted behind the active one. Opacity zero
// lets SwiftUI discard their native table views.
.background(.background)
.allowsHitTesting(isActive)
.accessibilityHidden(!isActive)
.zIndex(isActive ? 1 : 0)
}
}

struct KeywordWorkspaceTabs<Content: View>: View {
let selectedApp: TrackedApp
let trackedApps: [TrackedApp]
@ViewBuilder let content: (TrackedApp, Bool) -> Content

@State private var recentAppIDs: [Int64] = []

var body: some View {
let selectedID = selectedApp.appStoreID
let recentApps = recentAppIDs
.filter { $0 != selectedID }
.compactMap { id in trackedApps.first { $0.appStoreID == id } }
.prefix(2)
let pages = ([selectedApp] + recentApps).sorted { $0.appStoreID < $1.appStoreID }

ZStack {
ForEach(pages) { app in
let isActive = app.appStoreID == selectedID
KeywordWorkspacePage(isActive: isActive) {
content(app, isActive)
}
}
}
.onChange(of: selectedID, initial: true) {
recentAppIDs = [selectedID] + recentAppIDs.filter { $0 != selectedID }.prefix(2)
}
}
}
Loading
Loading