Add home activity filters

This commit is contained in:
Georg Bauer
2026-07-31 12:26:00 +02:00
parent 9fc9e3f13c
commit 5e00c108a4
2 changed files with 95 additions and 8 deletions

View File

@@ -115,6 +115,10 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Home shows the selected server name, contribution total, nine labeled - [ ] Home shows the selected server name, contribution total, nine labeled
months with gaps between them, and recent activity. months with gaps between them, and recent activity.
- [ ] Activity rows have the correct icon, repository, summary, and date. - [ ] 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 - [ ] Tap repository, issue, pull-request, and commit activity. Each opens the
matching native tab and destination. matching native tab and destination.
- [ ] Non-linkable server activity does not navigate or appear tappable. - [ ] Non-linkable server activity does not navigate or appear tappable.

View File

@@ -299,8 +299,37 @@ final class RepositoriesViewController: RefreshingTableViewController {
@MainActor @MainActor
final class HomeViewController: RefreshingTableViewController { 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 let context: AppContext
private var page: HomePage? private var page: HomePage?
private var filter = ActivityFilter.all
private var activities: [ActivityRow] {
page?.activities.filter(filter.includes) ?? []
}
init(context: AppContext) { init(context: AppContext) {
self.context = context self.context = context
@@ -348,9 +377,14 @@ final class HomeViewController: RefreshingTableViewController {
do { do {
page = try await context.core.home() page = try await context.core.home()
title = page?.serverName title = page?.serverName
tableView.tableHeaderView = page.map { HeatmapView(page: $0) } tableView.tableHeaderView = page.map { page in
tableView.reloadData() HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in
tableView.backgroundView = nil guard let self, let filter = ActivityFilter(rawValue: index) else { return }
self.filter = filter
self.updateActivities()
}
}
updateActivities()
} catch { } catch {
if !Task.isCancelled { show(error: error) } if !Task.isCancelled { show(error: error) }
} }
@@ -359,7 +393,7 @@ final class HomeViewController: RefreshingTableViewController {
} }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
page?.activities.count ?? 0 activities.count
} }
override func tableView( override func tableView(
@@ -368,7 +402,7 @@ final class HomeViewController: RefreshingTableViewController {
) -> UITableViewCell { ) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "activity") let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
guard let row = page?.activities[indexPath.row] else { return cell } let row = activities[indexPath.row]
configureTextCell( configureTextCell(
cell, cell,
title: row.title, title: row.title,
@@ -385,7 +419,17 @@ final class HomeViewController: RefreshingTableViewController {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) 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 { private func symbolName(for icon: String) -> String {
@@ -405,7 +449,7 @@ final class HeatmapView: UIView {
private let cells: [HeatCell] private let cells: [HeatCell]
private let calendar = Calendar(identifier: .gregorian) private let calendar = Calendar(identifier: .gregorian)
init(page: HomePage) { init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) {
let latest = page.heatCells.last.map { let latest = page.heatCells.last.map {
Date(timeIntervalSince1970: TimeInterval($0.timestamp)) Date(timeIntervalSince1970: TimeInterval($0.timestamp))
} ?? Date() } ?? Date()
@@ -420,7 +464,7 @@ final class HeatmapView: UIView {
cells = page.heatCells.filter { cells = page.heatCells.filter {
Date(timeIntervalSince1970: TimeInterval($0.timestamp)) >= firstMonth 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 backgroundColor = .systemBackground
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22)) let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
title.text = "Activity · last 9 months" title.text = "Activity · last 9 months"
@@ -432,6 +476,45 @@ final class HeatmapView: UIView {
total.font = .preferredFont(forTextStyle: .caption1) total.font = .preferredFont(forTextStyle: .caption1)
total.textColor = .tertiaryLabel total.textColor = .tertiaryLabel
addSubview(total) 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) @available(*, unavailable)