From 5e00c108a4cd895ec9d66da95506c2035db7995a Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 12:26:00 +0200 Subject: [PATCH] Add home activity filters --- TESTING.md | 4 ++ ios/Sources/ListScreens.swift | 99 ++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/TESTING.md b/TESTING.md index e1e35f2..4bc4396 100644 --- a/TESTING.md +++ b/TESTING.md @@ -115,6 +115,10 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] Home shows the selected server name, contribution total, nine labeled months with gaps between them, and recent activity. - [ ] Activity rows have the correct icon, repository, summary, and date. +- [ ] The compact icon row below the activity graph sits flat on the normal + background with no border or pill and defaults to the clock. Select the + issue, pull-request, and milestone icons in turn; each shows only matching + recent activity or the native empty state, then the clock restores all rows. - [ ] Tap repository, issue, pull-request, and commit activity. Each opens the matching native tab and destination. - [ ] Non-linkable server activity does not navigate or appear tappable. diff --git a/ios/Sources/ListScreens.swift b/ios/Sources/ListScreens.swift index c5b848b..ad8d6ee 100644 --- a/ios/Sources/ListScreens.swift +++ b/ios/Sources/ListScreens.swift @@ -299,8 +299,37 @@ final class RepositoriesViewController: RefreshingTableViewController { @MainActor final class HomeViewController: RefreshingTableViewController { + private enum ActivityFilter: Int { + case all + case issues + case pulls + case milestones + + func includes(_ row: ActivityRow) -> Bool { + switch self { + case .all: + return true + case .issues: + return row.icon.contains("issue") + case .pulls: + return row.icon.contains("pull") + case .milestones: + // ponytail: Gitea has no milestone event type; use related issue text until it does. + return row.icon.contains("milestone") + || row.icon.contains("issue") + && (row.title.localizedCaseInsensitiveContains("milestone") + || row.detail.localizedCaseInsensitiveContains("milestone")) + } + } + } + private let context: AppContext private var page: HomePage? + private var filter = ActivityFilter.all + + private var activities: [ActivityRow] { + page?.activities.filter(filter.includes) ?? [] + } init(context: AppContext) { self.context = context @@ -348,9 +377,14 @@ final class HomeViewController: RefreshingTableViewController { do { page = try await context.core.home() title = page?.serverName - tableView.tableHeaderView = page.map { HeatmapView(page: $0) } - tableView.reloadData() - tableView.backgroundView = nil + tableView.tableHeaderView = page.map { page in + HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in + guard let self, let filter = ActivityFilter(rawValue: index) else { return } + self.filter = filter + self.updateActivities() + } + } + updateActivities() } catch { if !Task.isCancelled { show(error: error) } } @@ -359,7 +393,7 @@ final class HomeViewController: RefreshingTableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - page?.activities.count ?? 0 + activities.count } override func tableView( @@ -368,7 +402,7 @@ final class HomeViewController: RefreshingTableViewController { ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "activity") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity") - guard let row = page?.activities[indexPath.row] else { return cell } + let row = activities[indexPath.row] configureTextCell( cell, title: row.title, @@ -385,7 +419,17 @@ final class HomeViewController: RefreshingTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if let row = page?.activities[indexPath.row] { context.route(row) } + context.route(activities[indexPath.row]) + } + + private func updateActivities() { + tableView.reloadData() + tableView.backgroundView = activities.isEmpty + ? EmptyBackgroundView( + title: "No matching activity", + detail: "This server has no recent activity of the selected type." + ) + : nil } private func symbolName(for icon: String) -> String { @@ -405,7 +449,7 @@ final class HeatmapView: UIView { private let cells: [HeatCell] private let calendar = Calendar(identifier: .gregorian) - init(page: HomePage) { + init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) { let latest = page.heatCells.last.map { Date(timeIntervalSince1970: TimeInterval($0.timestamp)) } ?? Date() @@ -420,7 +464,7 @@ final class HeatmapView: UIView { cells = page.heatCells.filter { Date(timeIntervalSince1970: TimeInterval($0.timestamp)) >= firstMonth } - super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 132)) + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180)) backgroundColor = .systemBackground let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22)) title.text = "Activity ยท last 9 months" @@ -432,6 +476,45 @@ final class HeatmapView: UIView { total.font = .preferredFont(forTextStyle: .caption1) total.textColor = .tertiaryLabel addSubview(total) + let filters = UIStackView() + filters.axis = .horizontal + filters.spacing = 4 + filters.translatesAutoresizingMaskIntoConstraints = false + let filterItems = [ + ("clock", "clock.fill", "All activity"), + ("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"), + ("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"), + ("flag", "flag.fill", "Milestones"), + ] + for (index, item) in filterItems.enumerated() { + let button = UIButton(type: .custom, primaryAction: UIAction { action in + guard + let button = action.sender as? UIButton, + let stack = button.superview as? UIStackView + else { return } + for case let item as UIButton in stack.arrangedSubviews { + item.isSelected = item === button + item.tintColor = item.isSelected ? .tintColor : .secondaryLabel + item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button + } + onFilter(button.tag) + }) + button.tag = index + button.setImage(UIImage(systemName: item.0), for: .normal) + button.setImage(UIImage(systemName: item.1), for: .selected) + button.isSelected = index == selectedFilter + button.tintColor = button.isSelected ? .tintColor : .secondaryLabel + button.accessibilityLabel = item.2 + button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button + button.widthAnchor.constraint(equalToConstant: 44).isActive = true + filters.addArrangedSubview(button) + } + addSubview(filters) + NSLayoutConstraint.activate([ + filters.centerXAnchor.constraint(equalTo: centerXAnchor), + filters.topAnchor.constraint(equalTo: topAnchor, constant: 132), + filters.heightAnchor.constraint(equalToConstant: 44), + ]) } @available(*, unavailable)