Add home activity filters
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user