diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 13:09:57 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 13:09:57 -0500 |
| commit | f5f5757d0e1b78470429ae7cb9f742c95662849b (patch) | |
| tree | 758f67965a985f4f1f52a456d9b672f8256851de /Hutch/Views/Home | |
| parent | 0e461a382257979037e8927e5f6b468635b0a7fe (diff) | |
| download | hutch-3.0.0.tar.gz hutch-3.0.0.tar.bz2 hutch-3.0.0.zip | |
release: v3.0.0 — navigation overhaul, Work view, and multi-pin supportv3.0.0
Reworks the app's core navigation and home screen for v3.0.0:
- Replace Inbox with Work view: unified dashboard showing unread threads
and assigned tickets, with All/Unread/Assigned scope picker
- Overhaul Home screen: redesigned with pinned items grid (trackers,
repos, mailing lists, users), recent activity section, and system
status banner; backed by HomePinStore supporting all resource types
- Simplify navigation bars across list screens: default toolbar shows
only the primary action (+) and an overflow menu (…); pin, share, and
select moved into the overflow menu; selection mode gets its own nav
bar with Cancel / "N Selected" / All
- Consolidate repo detail toolbars: pin and share moved into the
existing actions menu for both Git and Mercurial detail views
- Add recent activity tracking via RecentActivityStore
- Add work and inbox deep link aliases (hutch://work, hutch://inbox)
Implements: https://todo.sr.ht/~ccleberg/hutch/55
Diffstat (limited to 'Hutch/Views/Home')
| -rw-r--r-- | Hutch/Views/Home/HomePinStore.swift | 207 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomePrototypeView.swift | 7 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 1116 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 6 | ||||
| -rw-r--r-- | Hutch/Views/Home/RecentActivityStore.swift | 148 |
5 files changed, 746 insertions, 738 deletions
diff --git a/Hutch/Views/Home/HomePinStore.swift b/Hutch/Views/Home/HomePinStore.swift new file mode 100644 index 0000000..e9bffd1 --- /dev/null +++ b/Hutch/Views/Home/HomePinStore.swift @@ -0,0 +1,207 @@ +import Foundation + +enum HomePinKind: String, Codable, Sendable { + case project + case repository + case tracker + case mailingList + case user +} + +struct HomePinRecord: Codable, Hashable, Identifiable, Sendable { + let kind: HomePinKind + let value: String + let title: String + let subtitle: String + let ownerUsername: String? + let service: SRHTService? + + var id: String { + switch kind { + case .project: + return "\(kind.rawValue):\(value)" + case .repository: + return "\(kind.rawValue):\(service?.rawValue ?? "git"):\(ownerUsername ?? "")/\(value)" + case .tracker, .mailingList: + return "\(kind.rawValue):\(ownerUsername ?? "")/\(value)" + case .user: + return "\(kind.rawValue):\(ownerUsername ?? value)" + } + } + + static func project(_ project: Project) -> HomePinRecord { + HomePinRecord( + kind: .project, + value: project.id, + title: project.displayName, + subtitle: "Project", + ownerUsername: nil, + service: nil + ) + } + + static func repository(_ repository: RepositorySummary) -> HomePinRecord { + HomePinRecord( + kind: .repository, + value: repository.name, + title: repository.name, + subtitle: repository.service == .hg ? "Mercurial Repo" : "Git Repo", + ownerUsername: repository.owner.canonicalName.srhtUsername, + service: repository.service + ) + } + + static func tracker(_ tracker: TrackerSummary) -> HomePinRecord { + HomePinRecord( + kind: .tracker, + value: tracker.name, + title: tracker.name, + subtitle: "Tracker", + ownerUsername: tracker.owner.canonicalName.srhtUsername, + service: nil + ) + } + + static func mailingList(_ mailingList: InboxMailingListReference) -> HomePinRecord { + HomePinRecord( + kind: .mailingList, + value: mailingList.rid, + title: mailingList.name, + subtitle: "Mailing List", + ownerUsername: mailingList.owner.canonicalName.srhtUsername, + service: nil + ) + } + + static func user(_ user: User) -> HomePinRecord { + HomePinRecord( + kind: .user, + value: user.username, + title: user.canonicalName, + subtitle: "User", + ownerUsername: user.username, + service: nil + ) + } +} + +enum HomePinStore { + static func loadPins( + for userKey: String, + defaults: UserDefaults = .standard + ) -> [HomePinRecord] { + let normalizedUserKey = normalizedUserKey(userKey) + guard !normalizedUserKey.isEmpty else { return [] } + + let storedPins = loadAll(defaults: defaults) + if let storedPinsForUser = storedPins[normalizedUserKey] { + return normalizedPins(storedPinsForUser) + } + + let legacyProjectIDs = ProjectPinStore.loadLegacyPinnedProjectIDs(for: normalizedUserKey, defaults: defaults) + guard !legacyProjectIDs.isEmpty else { return [] } + + let migratedPins = legacyProjectIDs.map { + HomePinRecord( + kind: .project, + value: $0, + title: "Pinned Project", + subtitle: "Project", + ownerUsername: nil, + service: nil + ) + } + savePins(migratedPins, for: normalizedUserKey, defaults: defaults) + return migratedPins + } + + static func isPinned( + _ pin: HomePinRecord, + for userKey: String, + defaults: UserDefaults = .standard + ) -> Bool { + loadPins(for: userKey, defaults: defaults).contains(pin) + } + + static func togglePin( + _ pin: HomePinRecord, + for userKey: String, + defaults: UserDefaults = .standard + ) { + let normalizedUserKey = normalizedUserKey(userKey) + guard !normalizedUserKey.isEmpty else { return } + + var pinsByUser = loadAll(defaults: defaults) + var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults)) + + if let index = pins.firstIndex(of: pin) { + pins.remove(at: index) + } else { + pins.append(pin) + } + + pinsByUser[normalizedUserKey] = pins + saveAll(pinsByUser, defaults: defaults) + } + + static func pinnedProjectIDs( + for userKey: String, + defaults: UserDefaults = .standard + ) -> [String] { + loadPins(for: userKey, defaults: defaults) + .filter { $0.kind == .project } + .map(\.value) + } + + private static func loadAll(defaults: UserDefaults) -> [String: [HomePinRecord]] { + guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeItems) else { + return [:] + } + + let decoded = (try? JSONDecoder().decode([String: [HomePinRecord]].self, from: data)) ?? [:] + return decoded.reduce(into: [String: [HomePinRecord]]()) { result, entry in + let key = normalizedUserKey(entry.key) + guard !key.isEmpty else { return } + let pins = normalizedPins(entry.value) + if !pins.isEmpty { + result[key] = pins + } + } + } + + private static func savePins(_ pins: [HomePinRecord], for userKey: String, defaults: UserDefaults) { + var allPins = loadAll(defaults: defaults) + allPins[userKey] = normalizedPins(pins) + saveAll(allPins, defaults: defaults) + } + + private static func saveAll(_ pinsByUser: [String: [HomePinRecord]], defaults: UserDefaults) { + guard let data = try? JSONEncoder().encode(pinsByUser) else { return } + defaults.set(data, forKey: AppStorageKeys.pinnedHomeItems) + } + + private static func normalizedPins(_ pins: [HomePinRecord]) -> [HomePinRecord] { + var seen = Set<String>() + return pins.compactMap { pin in + let title = pin.title.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = pin.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + let value = pin.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty, !subtitle.isEmpty, !value.isEmpty else { return nil } + + let normalized = HomePinRecord( + kind: pin.kind, + value: value, + title: title, + subtitle: subtitle, + ownerUsername: pin.ownerUsername?.trimmingCharacters(in: .whitespacesAndNewlines), + service: pin.service + ) + guard seen.insert(normalized.id).inserted else { return nil } + return normalized + } + } + + private static func normalizedUserKey(_ userKey: String) -> String { + userKey.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Hutch/Views/Home/HomePrototypeView.swift b/Hutch/Views/Home/HomePrototypeView.swift new file mode 100644 index 0000000..77c360a --- /dev/null +++ b/Hutch/Views/Home/HomePrototypeView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct HomePrototypeView: View { + var body: some View { + HomeView() + } +} diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 69e47da..1a68498 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -1,15 +1,13 @@ import SwiftUI struct HomeView: View { - @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true - @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true - @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true - @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true @Environment(AppState.self) private var appState @Environment(\.scenePhase) private var scenePhase @State private var viewModel: HomeViewModel? - private let previewLimit = 4 - private let projectPreviewLimit = 3 + @State private var recentItems: [RecentActivityEntry] = [] + @State private var isOpeningRecentItem = false + @State private var selectedPinnedProject: Project? + @State private var selectedPinnedUser: User? var body: some View { Group { @@ -20,39 +18,40 @@ struct HomeView: View { } } .navigationTitle("Home") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - NavigationLink { - InboxView() - } label: { - HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true) + .navigationDestination(isPresented: Binding( + get: { selectedPinnedProject != nil }, + set: { isPresented in + if !isPresented { + selectedPinnedProject = nil } } + )) { + if let selectedPinnedProject { + ProjectDetailView(project: selectedPinnedProject) + } + } + .navigationDestination(isPresented: Binding( + get: { selectedPinnedUser != nil }, + set: { isPresented in + if !isPresented { + selectedPinnedUser = nil + } + } + )) { + if let selectedPinnedUser { + UserProfileView(user: selectedPinnedUser) + } } .task { guard let currentUser = appState.currentUser else { return } - - let vm: HomeViewModel - if let viewModel { - vm = viewModel - } else { - let newViewModel = HomeViewModel( - currentUser: currentUser, - client: appState.client, - systemStatusRepository: appState.systemStatusRepository, - defaults: appState.accountDefaults, - accountID: appState.activeAccountID - ) - viewModel = newViewModel - vm = newViewModel - } - - await vm.loadDashboard() + await ensureViewModel(currentUser: currentUser).loadDashboard() + loadRecentActivity() } .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active, let viewModel else { return } Task { await viewModel.loadDashboard() + loadRecentActivity() } } } @@ -60,850 +59,497 @@ struct HomeView: View { @ViewBuilder private func content(_ viewModel: HomeViewModel) -> some View { List { - attentionSection(viewModel) - systemStatusBannerSection(viewModel) - inboxSection(viewModel) - projectsSection(viewModel) - assignedTicketsSection(viewModel) - recentBuildsSection(viewModel) + systemStatusSection(viewModel) + workSection(viewModel) + recentSection + buildsSection(viewModel) + pinnedSection(viewModel) } .themedList() .listStyle(.insetGrouped) - .overlay { - if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && - viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && - viewModel.unreadInboxThreads.isEmpty { - SRHTLoadingStateView(message: "Loading Home…") - } - } + .listSectionSpacing(.compact) .refreshable { await viewModel.loadDashboard() } - .connectivityOverlay(hasContent: viewModel.hasDashboardContent) { + .connectivityOverlay(hasContent: hasHomeContent(viewModel)) { await viewModel.loadDashboard() } - } - - @ViewBuilder - private func attentionSection(_ viewModel: HomeViewModel) -> some View { - Section("Needs Attention") { - HomeAttentionSummaryRow( - title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention", - summary: viewModel.attentionSummaryText - ) - - HomeAttentionLinkRow( - title: "Inbox", - summary: viewModel.inboxSummaryText, - countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?" - ) { - InboxView() - } - - HomeAttentionLinkRow( - title: "Assigned Tickets", - summary: viewModel.ticketsSummaryText, - countText: String(viewModel.assignedTickets.count) - ) { - HomeAssignedTicketsListView(viewModel: viewModel) - } - - HomeAttentionLinkRow( - title: "Builds", - summary: viewModel.buildsSummaryText, - countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), - action: { - appState.navigateToBuildsList() - } - ) + .onAppear { + loadRecentActivity() } } @ViewBuilder - private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { - Section { - NavigationLink { - SystemStatusView() - } label: { - SystemStatusSummaryRow( - snapshot: viewModel.systemStatusSnapshot, - isLoading: viewModel.isLoadingSystemStatus, - errorMessage: viewModel.systemStatusErrorMessage, - isShowingStaleData: viewModel.isShowingStaleSystemStatus - ) + private func systemStatusSection(_ viewModel: HomeViewModel) -> some View { + if viewModel.systemStatusSnapshot?.hasDisruption == true { + Section { + NavigationLink { + SystemStatusView() + } label: { + SystemStatusSummaryRow( + snapshot: viewModel.systemStatusSnapshot, + isLoading: viewModel.isLoadingSystemStatus, + errorMessage: viewModel.systemStatusErrorMessage, + isShowingStaleData: viewModel.isShowingStaleSystemStatus + ) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } - @ViewBuilder - private func inboxSection(_ viewModel: HomeViewModel) -> some View { - Section { - if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 { - HomeSectionMessageRow( - text: "No unread inbox threads.", - systemImage: "tray" - ) - } else if viewModel.unreadInboxThreads.isEmpty { - HomeSectionMessageRow( - text: viewModel.inboxSummaryText, - systemImage: "tray" + private func workSection(_ viewModel: HomeViewModel) -> some View { + Section("Work") { + NavigationLink(value: HomeRoute.work) { + HomeSummaryRow( + title: workTitle(viewModel), + summary: workSummary(viewModel), + systemImage: "tray.full", + tint: workCount(viewModel) > 0 ? .blue : .secondary, + emphasis: .action ) - } else { - ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in - NavigationLink { - ThreadDetailView( - thread: thread, - onViewed: { viewModel.markInboxThreadRead(thread) }, - onMarkRead: { viewModel.markInboxThreadRead(thread) }, - onMarkUnread: { viewModel.markInboxThreadUnread(thread) } - ) - } label: { - HomeInboxThreadRow(thread: thread) - } - } - } - } header: { - HomeSectionHeader("Inbox") { - InboxView() } } } @ViewBuilder - private func projectsSection(_ viewModel: HomeViewModel) -> some View { - if viewModel.hasPinnedProjects { - HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) { - NavigationLink { - ProjectsListView() - } label: { - Text("See All") - .font(.caption.weight(.medium)) - } - .buttonStyle(.plain) - } content: { - if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty { - HomeSectionLoadingRow(label: "Loading pinned projects") - } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty { - HomeSectionMessageRow( - text: "Couldn’t load pinned projects.", - systemImage: "exclamationmark.triangle", - emphasized: true, - accessibilityHint: error - ) - } else if viewModel.pinnedProjects.isEmpty { - HomeSectionMessageRow( - text: "Pinned projects will appear here when they’re available.", - systemImage: "pin" - ) - } else { - ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in - NavigationLink { - ProjectDetailView(project: project) - } label: { - HomeProjectRow(project: project) - } + private var recentSection: some View { + if !recentItems.isEmpty { + Section("Recent") { + ForEach(recentItems) { item in + Button { + openRecentItem(item) + } label: { + HomeRecentRow(item: item) } + .buttonStyle(.plain) + .disabled(isOpeningRecentItem) + .listRowSeparator(.hidden) } } } } - @ViewBuilder - private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { - HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) { + private func buildsSection(_ viewModel: HomeViewModel) -> some View { + Section("Builds") { NavigationLink { - HomeAssignedTicketsListView(viewModel: viewModel) + BuildListView() } label: { - Text("See All") - .font(.caption.weight(.medium)) - } - .buttonStyle(.plain) - } content: { - if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { - HomeSectionLoadingRow(label: "Loading assigned tickets") - } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { - HomeSectionMessageRow( - text: "Couldn’t load assigned tickets.", - systemImage: "exclamationmark.triangle", - emphasized: true, - accessibilityHint: error - ) - } else if viewModel.assignedTickets.isEmpty { - HomeSectionMessageRow( - text: "No open tickets assigned to you.", - systemImage: "person.crop.circle.badge.checkmark" + HomeSummaryRow( + title: buildsTitle(viewModel), + summary: buildsSummary(viewModel), + systemImage: "hammer", + tint: viewModel.failedBuildCount > 0 ? .orange : .secondary, + emphasis: .monitoring ) - } else { - ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in - NavigationLink { - TicketDetailView( - ownerUsername: ticket.ownerUsername, - trackerName: ticket.trackerName, - trackerId: ticket.trackerId, - trackerRid: ticket.trackerRid, - ticketId: ticket.ticket.id - ) - } label: { - HomeAssignedTicketRow(ticket: ticket) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if swipeActionsEnabled { - ticketLeadingSwipeAction(ticket, viewModel: viewModel) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if swipeActionsEnabled { - Button { - Task { - await viewModel.unassignFromMe(ticket) - } - } label: { - Label("Unassign Me", systemImage: "person.badge.minus") - } - .tint(.orange) - } - } - } } } } - @ViewBuilder - private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { - HomeSectionView("Builds", isExpanded: $buildsExpanded) { - Button("See All") { - appState.navigateToBuildsList() - } - .font(.caption.weight(.medium)) - .buttonStyle(.plain) - } content: { - if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { - HomeSectionLoadingRow(label: "Loading recent builds") - } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { - HomeSectionMessageRow( - text: "Couldn’t load recent builds.", - systemImage: "exclamationmark.triangle", - emphasized: true, - accessibilityHint: error - ) - } else if viewModel.recentBuilds.isEmpty { - HomeSectionMessageRow( - text: "No recent builds.", - systemImage: "clock" - ) - } else { - ForEach(buildGroups(for: viewModel.recentBuilds)) { group in - if let repositoryDisplayName = group.repositoryDisplayName { - HomeBuildGroupHeader( - repositoryDisplayName: repositoryDisplayName, - buildCount: group.builds.count, - latestStatus: group.latestStatus - ) - } + private func pinnedSection(_ viewModel: HomeViewModel) -> some View { + let items = pinnedItems(viewModel) - ForEach(group.builds) { build in - NavigationLink { - BuildDetailView(jobId: build.job.id) + return Section("Pinned") { + if items.isEmpty { + NavigationLink { + ProjectsListView() + } label: { + HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin") + } + } else { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + ], + spacing: 10 + ) { + ForEach(items) { item in + Button { + openPinnedItem(item, viewModel: viewModel) } label: { - HomeBuildRow( - build: build, - showsRepositoryLink: group.repositoryDisplayName == nil - ) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if swipeActionsEnabled, build.job.status.isCancellable { - Button { - Task { - await viewModel.cancelBuild(build) - } - } - label: { - Label("Cancel", systemImage: "xmark.circle") - } - .tint(.red) - } + HomePinnedCard(item: item) } + .buttonStyle(.plain) } } + .padding(.vertical, 2) } } } - private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] { - let previewBuilds = Array(builds.prefix(previewLimit)) - guard let firstBuild = previewBuilds.first else { return [] } - - var groups: [HomeBuildGroup] = [] - var currentIdentity = HomeBuildGroup.Identity(build: firstBuild) - var currentBuilds: [HomeBuildItem] = [] - - for build in previewBuilds { - let identity = HomeBuildGroup.Identity(build: build) - if identity == currentIdentity { - currentBuilds.append(build) - } else { - groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) - currentIdentity = identity - currentBuilds = [build] - } - } - - if !currentBuilds.isEmpty { - groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) - } + private func workCount(_ viewModel: HomeViewModel) -> Int { + unreadCount(viewModel) + viewModel.assignedTickets.count + } - return groups + private func unreadCount(_ viewModel: HomeViewModel) -> Int { + viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count } - @ViewBuilder - private func ticketLeadingSwipeAction( - _ ticket: HomeAssignedTicket, - viewModel: HomeViewModel - ) -> some View { - if ticket.ticket.status.isOpen { - Button { - Task { - await viewModel.resolveTicket(ticket) - } - } label: { - Label("Resolve", systemImage: "checkmark.circle") - } - .tint(.green) - } else { - Button { - Task { - await viewModel.reopenTicket(ticket) - } - } label: { - Label("Reopen", systemImage: "arrow.uturn.backward") - } - .tint(.blue) + private func workTitle(_ viewModel: HomeViewModel) -> String { + let count = workCount(viewModel) + if count == 0 { + return "Queue clear" } + return "\(count) item\(count == 1 ? "" : "s") need attention" } -} - -private struct HomeInboxToolbarIcon: View { - let hasUnreadThreads: Bool - - var body: some View { - Image(systemName: hasUnreadThreads ? "tray.fill" : "tray") - .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox") + private func workSummary(_ viewModel: HomeViewModel) -> String { + let unread = unreadCount(viewModel) + let assigned = viewModel.assignedTickets.count + return "\(unread) unread • \(assigned) assigned" } -} - -private struct HomeProjectRow: View { - let project: Project - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - Text(project.displayName) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.primary) - .lineLimit(1) - - if let description = project.displayDescription { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - Spacer(minLength: 8) + private func buildsTitle(_ viewModel: HomeViewModel) -> String { + let failed = viewModel.failedBuildCount + let running = viewModel.activeBuildCount - VisibilityBadge(visibility: project.visibility) - } - - Text(project.metadataLine) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + if failed == 0 && running == 0 { + return "Build monitoring clear" } - .contentShape(Rectangle()) - .padding(.vertical, 4) - } -} - -private struct HomeBuildRow: View { - @Environment(AppState.self) private var appState - let build: HomeBuildItem - var showsRepositoryLink = true - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 12) { - JobStatusIcon(status: build.job.status) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 4) { - Text(build.job.displayLabel) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - - HStack(spacing: 8) { - Text("Job #\(build.job.id)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) - - Text(build.job.status.displayTitle) - .font(.caption) - .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) - - Text(build.job.created.relativeDescription) - .font(.caption) - .foregroundStyle(.tertiary) - - Spacer() - } - } - } - - if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName { - Button { - openRepository() - } label: { - Label(repositoryDisplayName, systemImage: "book.closed") - .font(.caption) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } + if failed > 0 { + return "\(failed) failed build\(failed == 1 ? "" : "s")" } - .padding(.vertical, 2) + return "\(running) running build\(running == 1 ? "" : "s")" } - private func openRepository() { - guard let repositoryName = build.repositoryName, - let repositoryOwner = build.repositoryOwner else { return } - Task { - do { - let repository = try await appState.resolveRepository( - owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner, - name: repositoryName - ) - appState.navigateToRepository(repository) - } catch { - appState.presentRepositoryDeepLinkError() - } + private func buildsSummary(_ viewModel: HomeViewModel) -> String { + let failed = viewModel.failedBuildCount + let running = viewModel.activeBuildCount + if failed == 0 && running == 0 { + return "No failures • \(buildTimeframeLabel(viewModel))" + } + if failed > 0 && running > 0 { + return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))" } + if failed > 0 { + return "\(failed) failed • \(buildTimeframeLabel(viewModel))" + } + return "\(running) running • \(buildTimeframeLabel(viewModel))" } -} -private struct HomeBuildGroup: Identifiable { - enum Identity: Hashable { - case repository(owner: String?, name: String) - case standalone(Int) + private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] { + let currentUserKey = appState.currentUser?.canonicalName ?? "" + let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults) + let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) }) - init(build: HomeBuildItem) { - if let repositoryName = build.repositoryName { - self = .repository(owner: build.repositoryOwner, name: repositoryName) - } else { - self = .standalone(build.id) + return pins.compactMap { pin in + switch pin.kind { + case .project: + guard let project = projectsByID[pin.value] else { return nil } + return HomePinnedItem(pin: pin, project: project) + case .repository, .tracker, .mailingList, .user: + return HomePinnedItem(pin: pin, project: nil) } } } - let identity: Identity - let builds: [HomeBuildItem] + private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String { + let calendar = Calendar.current + let buildDates = viewModel.recentBuilds.map(\.job.updated) - var id: String { - switch identity { - case .repository(let owner, let name): - return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)" - case .standalone(let jobId): - return "job-\(jobId)" + guard !buildDates.isEmpty else { + return "today" } - } - var repositoryDisplayName: String? { - builds.first?.repositoryDisplayName + return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week" } - var latestStatus: JobStatus { - builds.first?.job.status ?? .pending + private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool { + viewModel.systemStatusSnapshot?.hasDisruption == true || + workCount(viewModel) > 0 || + !recentItems.isEmpty || + !pinnedItems(viewModel).isEmpty } -} - -private struct HomeBuildGroupHeader: View { - let repositoryDisplayName: String - let buildCount: Int - let latestStatus: JobStatus - var body: some View { - HStack(spacing: 12) { - Label(repositoryDisplayName, systemImage: "book.closed") - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer(minLength: 8) - - Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")") - .font(.caption2.weight(.medium)) - .foregroundStyle(.tertiary) - - JobStatusBadge(status: latestStatus) - } - .padding(.top, 4) - .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20)) - .listRowSeparator(.hidden) - .accessibilityElement(children: .combine) + private func loadRecentActivity() { + recentItems = RecentActivityStore.load(defaults: appState.accountDefaults) } -} -private struct HomeAssignedTicketRow: View { - @Environment(AppState.self) private var appState - let ticket: HomeAssignedTicket + private func openRecentItem(_ item: RecentActivityEntry) { + guard !isOpeningRecentItem else { return } - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 12) { - TicketStatusIcon(status: ticket.ticket.status) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 4) { - Text(ticket.ticket.title) - .font(.subheadline.weight(.medium)) - .lineLimit(2) - - Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - - Spacer(minLength: 8) - - Text(ticket.ticket.status.displayName) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize() + switch item.kind { + case .build: + guard let jobId = item.buildJobId else { return } + appState.navigateToBuild(jobId: jobId) + case .ticket: + guard + let ownerUsername = item.ticketOwnerUsername, + let trackerName = item.ticketTrackerName, + let ticketId = item.ticketId + else { + return } - - Button { - openTracker() - } label: { - Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist") - .font(.caption) - .foregroundStyle(.secondary) + appState.navigateToTicket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId) + case .repository: + guard + let owner = item.repositoryOwner, + let name = item.repositoryName + else { + return } - .buttonStyle(.plain) - } - .padding(.vertical, 2) - } - private func openTracker() { - Task { - do { - let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName) - appState.navigateToTracker(tracker) - } catch { - appState.presentTicketDeepLinkError() + isOpeningRecentItem = true + Task { + defer { isOpeningRecentItem = false } + do { + let repository = try await appState.resolveRepository( + owner: owner, + name: name, + service: item.repositoryService ?? .git + ) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } } } } -} -private struct HomeInboxThreadRow: View { - @Environment(AppState.self) private var appState - let thread: InboxThreadSummary - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 10) { - Circle() - .fill(.blue) - .frame(width: 8, height: 8) - .padding(.top, 6) - - VStack(alignment: .leading, spacing: 4) { - Text(thread.displaySubject) - .font(.subheadline.weight(.medium)) - .lineLimit(2) - - Text(thread.metadataLine) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) { + switch item.pin.kind { + case .project: + guard let project = item.project else { return } + selectedPinnedProject = project + case .repository: + guard + let owner = item.pin.ownerUsername, + let service = item.pin.service + else { + return + } + isOpeningRecentItem = true + Task { + defer { isOpeningRecentItem = false } + do { + let repository = try await appState.resolveRepository(owner: owner, name: item.pin.value, service: service) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() } } - - HStack(spacing: 10) { - Button { - appState.navigateToMailingList( - InboxMailingListReference( - id: thread.listID, - rid: thread.listRID, - name: thread.listName, - owner: thread.listOwner - ) - ) - } label: { - Label(thread.listName, systemImage: "list.bullet") - .font(.caption) - .foregroundStyle(.secondary) + case .tracker: + guard let owner = item.pin.ownerUsername else { return } + isOpeningRecentItem = true + Task { + defer { isOpeningRecentItem = false } + do { + let tracker = try await appState.resolveTracker(owner: owner, name: item.pin.value) + appState.navigateToTracker(tracker) + } catch { + appState.presentTicketDeepLinkError() } - .buttonStyle(.plain) - - if let repo = thread.repo { - Button { - openRepository(named: repo) - } label: { - Label(repo, systemImage: "book.closed") - .font(.caption) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) + } + case .mailingList: + guard let ownerUsername = item.pin.ownerUsername else { return } + appState.openMailingList( + InboxMailingListReference( + id: 0, + rid: item.pin.value, + name: item.pin.title, + owner: Entity(canonicalName: "~\(ownerUsername)") + ) + ) + case .user: + guard let ownerUsername = item.pin.ownerUsername else { return } + isOpeningRecentItem = true + Task { + defer { isOpeningRecentItem = false } + if let user = try? await resolvePinnedUser(username: ownerUsername) { + selectedPinnedUser = user } } } - .padding(.vertical, 2) } - private func openRepository(named repositoryName: String) { - Task { - do { - let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~") - ? String(thread.listOwner.canonicalName.dropFirst()) - : thread.listOwner.canonicalName - let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) - appState.navigateToRepository(repository) - } catch { - appState.presentRepositoryDeepLinkError() + private func resolvePinnedUser(username: String) async throws -> User { + struct Response: Decodable, Sendable { + let user: User + } + + let query = """ + query userLookup($username: String!) { + user: userByName(username: $username) { + id + created + updated + canonicalName + username + email + url + location + bio + avatar + pronouns + userType } } - } -} + """ -private struct HomeSectionLoadingRow: View { - let label: String + let result = try await appState.client.execute( + service: .meta, + query: query, + variables: ["username": username], + responseType: Response.self + ) + return result.user + } - var body: some View { - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text(label) - .foregroundStyle(.secondary) + @MainActor + private func ensureViewModel(currentUser: User) -> HomeViewModel { + if let viewModel { + return viewModel } - .frame(maxWidth: .infinity, alignment: .leading) + + let newViewModel = HomeViewModel( + currentUser: currentUser, + client: appState.client, + systemStatusRepository: appState.systemStatusRepository, + defaults: appState.accountDefaults, + accountID: appState.activeAccountID + ) + viewModel = newViewModel + return newViewModel } } -private struct HomeSectionHeader<Destination: View>: View { - let title: String - let destination: Destination +enum HomeRoute: Hashable { + case work +} - init(_ title: String, @ViewBuilder destination: () -> Destination) { - self.title = title - self.destination = destination() - } +private enum HomeSummaryEmphasis { + case action + case monitoring +} - var body: some View { - HStack { - Text(title) - Spacer() - NavigationLink { - destination - } label: { - Text("See All") - .font(.caption.weight(.medium)) - } - .buttonStyle(.plain) - } - .textCase(nil) - } +private struct HomePinnedItem: Identifiable { + let pin: HomePinRecord + let project: Project? + + var id: String { pin.id } + var title: String { project?.displayName ?? pin.title } + var detail: String { pin.subtitle } } -private struct HomeAttentionSummaryRow: View { +private struct HomeSummaryRow: View { let title: String let summary: String + let systemImage: String + let tint: Color + let emphasis: HomeSummaryEmphasis var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) + HStack(spacing: 10) { + Image(systemName: systemImage) .font(.subheadline.weight(.semibold)) - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + .foregroundStyle(iconColor) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) } - .padding(.vertical, 2) + .padding(.vertical, verticalPadding) } -} -private struct HomeAttentionLinkRow<Destination: View>: View { - let title: String - let summary: String - let countText: String - let destination: Destination? - let action: (() -> Void)? - - init( - title: String, - summary: String, - countText: String, - @ViewBuilder destination: () -> Destination - ) { - self.title = title - self.summary = summary - self.countText = countText - self.destination = destination() - self.action = nil + private var iconColor: Color { + switch emphasis { + case .action: + return tint + case .monitoring: + return tint.opacity(0.9) + } } - init( - title: String, - summary: String, - countText: String, - action: @escaping () -> Void - ) where Destination == EmptyView { - self.title = title - self.summary = summary - self.countText = countText - self.destination = nil - self.action = action + private var verticalPadding: CGFloat { + switch emphasis { + case .action: + return 3 + case .monitoring: + return 2 + } } +} + +private struct HomeRecentRow: View { + let item: RecentActivityEntry var body: some View { - Group { - if let destination { - NavigationLink { - destination - } label: { - content - } - } else if let action { - Button(action: action) { - content - } - .buttonStyle(.plain) - } - } - } + HStack(spacing: 10) { + Image(systemName: iconName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 16) - private var content: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(title) + VStack(alignment: .leading, spacing: 1) { + Text(item.title) .font(.subheadline.weight(.medium)) - Text(summary) + .lineLimit(1) + Text(item.detailText) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } - Spacer() - Text(countText) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color(.secondarySystemFill), in: Capsule()) - if action != nil { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } + Spacer(minLength: 8) + } + .padding(.vertical, 1) + } + + private var iconName: String { + switch item.kind { + case .repository: + return "book.closed" + case .ticket: + return "number" + case .build: + return "hammer" } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .padding(.vertical, 2) } } -private struct HomeAssignedTicketsListView: View { - let viewModel: HomeViewModel - @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true +private struct HomePinnedCard: View { + let item: HomePinnedItem var body: some View { - List { - ForEach(viewModel.assignedTickets) { ticket in - NavigationLink { - TicketDetailView( - ownerUsername: ticket.ownerUsername, - trackerName: ticket.trackerName, - trackerId: ticket.trackerId, - trackerRid: ticket.trackerRid, - ticketId: ticket.ticket.id - ) - } label: { - HomeAssignedTicketRow(ticket: ticket) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if swipeActionsEnabled { - if ticket.ticket.status.isOpen { - Button { - Task { await viewModel.resolveTicket(ticket) } - } label: { - Label("Resolve", systemImage: "checkmark.circle") - } - .tint(.green) - } else { - Button { - Task { await viewModel.reopenTicket(ticket) } - } label: { - Label("Reopen", systemImage: "arrow.uturn.backward") - } - .tint(.blue) - } - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if swipeActionsEnabled { - Button { - Task { await viewModel.unassignFromMe(ticket) } - } label: { - Label("Unassign Me", systemImage: "person.badge.minus") - } - .tint(.orange) - } - } + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "square.stack.3d.up") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(item.detail) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) } - if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { - HomeSectionMessageRow( - text: "No open tickets assigned to you.", - systemImage: "person.crop.circle.badge.checkmark" - ) - } - } - .navigationTitle("Assigned Tickets") - .navigationBarTitleDisplayMode(.inline) - .refreshable { - await viewModel.loadDashboard() - } - .overlay { - if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { - SRHTLoadingStateView(message: "Loading assigned tickets…") - } + Text(item.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + + Spacer(minLength: 0) } + .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) } } -private struct HomeSectionMessageRow: View { +private struct HomeCompactMessageRow: View { let text: String let systemImage: String - var emphasized = false - var accessibilityHint: String? = nil var body: some View { Label(text, systemImage: systemImage) - .font(.subheadline) - .foregroundStyle(emphasized ? .secondary : .tertiary) - .accessibilityHint(accessibilityHint ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 2) } } diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index a28f731..f0c14dd 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -393,7 +393,7 @@ final class HomeViewModel { let inboxUnreadSnapshot = await inboxUnreadTask unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount - unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? []) + unreadInboxThreads = inboxUnreadSnapshot?.threads ?? [] hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 let systemStatusResult = await systemStatusTask switch systemStatusResult { @@ -414,7 +414,7 @@ final class HomeViewModel { } var pinnedProjects: [Project] { - let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults) + let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults) guard !pinnedIDs.isEmpty else { return [] } let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) @@ -422,7 +422,7 @@ final class HomeViewModel { } var hasPinnedProjects: Bool { - !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty + !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty } var failedBuildCount: Int { diff --git a/Hutch/Views/Home/RecentActivityStore.swift b/Hutch/Views/Home/RecentActivityStore.swift new file mode 100644 index 0000000..464a2c1 --- /dev/null +++ b/Hutch/Views/Home/RecentActivityStore.swift @@ -0,0 +1,148 @@ +import Foundation + +enum RecentActivityKind: String, Codable, Sendable { + case repository + case ticket + case build +} + +struct RecentActivityEntry: Codable, Hashable, Identifiable, Sendable { + let kind: RecentActivityKind + let title: String + let viewedAt: Date + let repositoryOwner: String? + let repositoryName: String? + let repositoryService: SRHTService? + let ticketOwnerUsername: String? + let ticketTrackerName: String? + let ticketId: Int? + let buildJobId: Int? + + var id: String { + switch kind { + case .repository: + let service = repositoryService?.rawValue ?? SRHTService.git.rawValue + return "repo:\(service):\(repositoryOwner ?? "")/\(repositoryName ?? "")" + case .ticket: + return "ticket:\(ticketOwnerUsername ?? "")/\(ticketTrackerName ?? "")#\(ticketId ?? 0)" + case .build: + return "build:\(buildJobId ?? 0)" + } + } + + var detailText: String { + switch kind { + case .repository: + let serviceName = repositoryService?.displayName ?? "Repository" + return "\(serviceName) • Viewed \(viewedAt.relativeDescription)" + case .ticket: + return "Ticket • Viewed \(viewedAt.relativeDescription)" + case .build: + return "Build • Viewed \(viewedAt.relativeDescription)" + } + } +} + +enum RecentActivityStore { + private static let maximumEntries = 5 + + static func load(defaults: UserDefaults) -> [RecentActivityEntry] { + guard let data = defaults.data(forKey: AppStorageKeys.recentActivity) else { + return [] + } + + do { + return try JSONDecoder().decode([RecentActivityEntry].self, from: data) + } catch { + defaults.removeObject(forKey: AppStorageKeys.recentActivity) + return [] + } + } + + static func recordRepository( + _ repository: RepositorySummary, + defaults: UserDefaults, + now: Date = .now + ) { + record( + RecentActivityEntry( + kind: .repository, + title: "\(repository.owner.canonicalName)/\(repository.name)", + viewedAt: now, + repositoryOwner: repository.owner.canonicalName.srhtUsername, + repositoryName: repository.name, + repositoryService: repository.service, + ticketOwnerUsername: nil, + ticketTrackerName: nil, + ticketId: nil, + buildJobId: nil + ), + defaults: defaults + ) + } + + static func recordTicket( + ownerUsername: String, + trackerName: String, + ticketId: Int, + title: String, + defaults: UserDefaults, + now: Date = .now + ) { + record( + RecentActivityEntry( + kind: .ticket, + title: "#\(ticketId) \(title)", + viewedAt: now, + repositoryOwner: nil, + repositoryName: nil, + repositoryService: nil, + ticketOwnerUsername: ownerUsername, + ticketTrackerName: trackerName, + ticketId: ticketId, + buildJobId: nil + ), + defaults: defaults + ) + } + + static func recordBuild( + jobId: Int, + title: String, + defaults: UserDefaults, + now: Date = .now + ) { + record( + RecentActivityEntry( + kind: .build, + title: title, + viewedAt: now, + repositoryOwner: nil, + repositoryName: nil, + repositoryService: nil, + ticketOwnerUsername: nil, + ticketTrackerName: nil, + ticketId: nil, + buildJobId: jobId + ), + defaults: defaults + ) + } + + private static func record(_ entry: RecentActivityEntry, defaults: UserDefaults) { + var entries = load(defaults: defaults) + entries.removeAll { $0.id == entry.id } + entries.insert(entry, at: 0) + + if entries.count > maximumEntries { + entries = Array(entries.prefix(maximumEntries)) + } + + save(entries, defaults: defaults) + } + + private static func save(_ entries: [RecentActivityEntry], defaults: UserDefaults) { + guard let data = try? JSONEncoder().encode(entries) else { return } + defaults.set(data, forKey: AppStorageKeys.recentActivity) + } +} |
