Commit 9e6ec71086
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 60; | |
| 518 | CURRENT_PROJECT_VERSION = 61; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 2.21.1; | |
| 535 | MARKETING_VERSION = 3.0.0; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 60; | |
| 555 | CURRENT_PROJECT_VERSION = 61; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 2.21.1; | |
| 572 | MARKETING_VERSION = 3.0.0; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 60; | |
| 635 | CURRENT_PROJECT_VERSION = 61; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 2.21.1; | |
| 645 | MARKETING_VERSION = 3.0.0; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 60; | |
| 664 | CURRENT_PROJECT_VERSION = 61; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 2.21.1; | |
| 674 | MARKETING_VERSION = 3.0.0; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/Views/Builds/BuildListView.swift +3 −1
| @@ -108,8 +108,10 @@ struct BuildListView: View { | ||
| 108 | 108 | vm.repoFilter = savedRepoFilter |
| 109 | 109 | viewModel = vm |
| 110 | 110 | await vm.loadJobs() |
| 111 | vm.startAutoRefresh(interval: autoRefreshInterval) | |
| 112 | 111 | } |
| 112 | // Restart auto-refresh every time the view (re)appears, since | |
| 113 | // onDisappear stops it when navigating away. | |
| 114 | viewModel?.startAutoRefresh(interval: autoRefreshInterval) | |
| 113 | 115 | } |
| 114 | 116 | .onDisappear { |
| 115 | 117 | viewModel?.stopAutoRefresh() |
Hutch/Views/Home/HomeView.swift +1 −1
| @@ -48,7 +48,7 @@ struct HomeView: View { | ||
| 48 | 48 | loadRecentActivity() |
| 49 | 49 | } |
| 50 | 50 | .onChange(of: scenePhase) { _, newPhase in |
| 51 | guard newPhase == .active, let viewModel else { return } | |
| 51 | guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return } | |
| 52 | 52 | Task { |
| 53 | 53 | await viewModel.loadDashboard() |
| 54 | 54 | loadRecentActivity() |
Hutch/Views/Home/HomeViewModel.swift +8
| @@ -188,6 +188,7 @@ final class HomeViewModel { | ||
| 188 | 188 | private(set) var projectsError: String? |
| 189 | 189 | private(set) var assignedTicketsError: String? |
| 190 | 190 | private(set) var recentBuildsError: String? |
| 191 | private(set) var lastRefreshed: Date? | |
| 191 | 192 | |
| 192 | 193 | private let currentUser: User |
| 193 | 194 | private let client: SRHTClient |
| @@ -405,10 +406,17 @@ final class HomeViewModel { | ||
| 405 | 406 | systemStatusErrorMessage = error.userFacingMessage |
| 406 | 407 | } |
| 407 | 408 | isLoadingSystemStatus = false |
| 409 | lastRefreshed = Date() | |
| 408 | 410 | persistNeedsAttentionSnapshot() |
| 409 | 411 | persistSystemStatusWidgetSnapshot() |
| 410 | 412 | } |
| 411 | 413 | |
| 414 | /// Returns true if sufficient time has elapsed since the last dashboard refresh. | |
| 415 | func needsRefresh(after interval: TimeInterval = 60) -> Bool { | |
| 416 | guard let lastRefreshed else { return true } | |
| 417 | return Date().timeIntervalSince(lastRefreshed) > interval | |
| 418 | } | |
| 419 | ||
| 412 | 420 | var hasDashboardContent: Bool { |
| 413 | 421 | !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil |
| 414 | 422 | } |
Hutch/Views/Repositories/RepositoryListView.swift +1 −4
| @@ -88,9 +88,6 @@ struct RepositoryListView: View { | ||
| 88 | 88 | ) |
| 89 | 89 | } |
| 90 | 90 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } |
| 91 | .task { | |
| 92 | await viewModel.loadMoreIfNeeded(currentItem: repo) | |
| 93 | } | |
| 94 | 91 | } |
| 95 | 92 | |
| 96 | 93 | if viewModel.isLoadingMore { |
| @@ -156,7 +153,7 @@ struct RepositoryListView: View { | ||
| 156 | 153 | } |
| 157 | 154 | .srhtErrorBanner(error: $vm.error) |
| 158 | 155 | .refreshable { |
| 159 | await viewModel.loadRepositories() | |
| 156 | await viewModel.loadRepositories(forceRefresh: true) | |
| 160 | 157 | } |
| 161 | 158 | .task { |
| 162 | 159 | await viewModel.loadRepositories() |
Hutch/Views/Repositories/RepositoryListViewModel.swift +12 −3
| @@ -46,6 +46,7 @@ final class RepositoryListViewModel { | ||
| 46 | 46 | private let client: SRHTClient |
| 47 | 47 | private let defaults: UserDefaults |
| 48 | 48 | private var buildStatusTask: Task<Void, Never>? |
| 49 | private var lastBuildStatusRefresh: Date? | |
| 49 | 50 | |
| 50 | 51 | private static let gitCacheKey = "git.repositories" |
| 51 | 52 | private static let hgCacheKey = "hg.repositories" |
| @@ -147,7 +148,8 @@ final class RepositoryListViewModel { | ||
| 147 | 148 | /// Fetch the first page of repositories. Shows cached data instantly if available, |
| 148 | 149 | /// then refreshes from the network in the background. |
| 149 | 150 | /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`. |
| 150 | func loadRepositories(search: String? = nil) async { | |
| 151 | /// - Parameter forceRefresh: When true, bypass the build-status TTL (e.g. pull-to-refresh). | |
| 152 | func loadRepositories(search: String? = nil, forceRefresh: Bool = false) async { | |
| 151 | 153 | let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines) |
| 152 | 154 | let isSearch = !query.isEmpty |
| 153 | 155 | |
| @@ -193,7 +195,7 @@ final class RepositoryListViewModel { | ||
| 193 | 195 | } |
| 194 | 196 | |
| 195 | 197 | repositories = filteredResults.sorted(by: repositorySortOrder) |
| 196 | scheduleBuildStatusRefresh() | |
| 198 | scheduleBuildStatusRefresh(force: forceRefresh) | |
| 197 | 199 | } catch { |
| 198 | 200 | // Only show error if we have no cached data to fall back on |
| 199 | 201 | if repositories.isEmpty { |
| @@ -583,7 +585,13 @@ final class RepositoryListViewModel { | ||
| 583 | 585 | } |
| 584 | 586 | } |
| 585 | 587 | |
| 586 | private func scheduleBuildStatusRefresh() { | |
| 588 | private func scheduleBuildStatusRefresh(force: Bool = false) { | |
| 589 | // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh | |
| 590 | // passes force: true to bypass this check. | |
| 591 | if !force, let last = lastBuildStatusRefresh, | |
| 592 | Date().timeIntervalSince(last) < 120 { | |
| 593 | return | |
| 594 | } | |
| 587 | 595 | let repositoriesSnapshot = repositories |
| 588 | 596 | buildStatusTask?.cancel() |
| 589 | 597 | buildStatusTask = Task { [weak self] in |
| @@ -635,6 +643,7 @@ final class RepositoryListViewModel { | ||
| 635 | 643 | await MainActor.run { |
| 636 | 644 | guard repositories == self.repositories else { return } |
| 637 | 645 | latestBuildStatuses = finalStatuses |
| 646 | lastBuildStatusRefresh = Date() | |
| 638 | 647 | } |
| 639 | 648 | } catch { |
| 640 | 649 | // Build status is auxiliary data for the list. Leave the default gray state on failure. |
Hutch/Views/Work/WorkView.swift +1 −1
| @@ -30,7 +30,7 @@ struct WorkView: View { | ||
| 30 | 30 | await ensureViewModel(currentUser: currentUser).loadDashboard() |
| 31 | 31 | } |
| 32 | 32 | .onChange(of: scenePhase) { _, newPhase in |
| 33 | guard newPhase == .active, let viewModel else { return } | |
| 33 | guard newPhase == .active, let viewModel, viewModel.needsRefresh() else { return } | |
| 34 | 34 | Task { |
| 35 | 35 | await viewModel.loadDashboard() |
| 36 | 36 | } |