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