Files
Gotcha/ios/Sources/HomeScreen.swift
2026-08-15 20:06:52 +02:00

539 lines
21 KiB
Swift

import UIKit
@MainActor
final class HomeViewController: RefreshingTableViewController {
private enum Timeline: Int {
case user
case allUsers
case notifications
}
private let context: AppContext
private var page: HomePage?
private var timeline = Timeline.user
private var userNextPage: UInt32?
private var serverActivities: [ActivityRow] = []
private var serverNextPage: UInt32?
private let notificationStatusControl = UISegmentedControl(items: ["Open", "Closed"])
private lazy var notificationHeaderView: UIView = {
let header = UIView()
notificationStatusControl.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(notificationStatusControl)
NSLayoutConstraint.activate([
notificationStatusControl.centerXAnchor.constraint(equalTo: header.centerXAnchor),
notificationStatusControl.centerYAnchor.constraint(equalTo: header.centerYAnchor),
])
return header
}()
private var notificationStatus = NotificationStatus.open
private var notificationRows: [NotificationRow] = []
private var notificationPage: UInt32 = 0
private var notificationsHaveMore = false
private var activities: [ActivityRow] {
timeline == .allUsers ? serverActivities : page?.activities ?? []
}
init(context: AppContext) {
self.context = context
super.init()
title = "Home"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification")
notificationStatusControl.selectedSegmentIndex = 0
notificationStatusControl.accessibilityLabel = "Notification status"
notificationStatusControl.addTarget(
self,
action: #selector(notificationStatusChanged),
for: .valueChanged
)
let servers = UIBarButtonItem(
image: context.symbol("server.rack"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
ServersViewController(context: self.context),
animated: true
)
}
)
servers.accessibilityLabel = "Servers"
navigationItem.leftBarButtonItem = servers
let settings = UIBarButtonItem(
image: context.symbol("gearshape"),
primaryAction: UIAction { [weak self] _ in
guard let self else { return }
self.navigationController?.pushViewController(
SettingsViewController(context: self.context),
animated: true
)
}
)
settings.accessibilityLabel = "Settings"
navigationItem.rightBarButtonItem = settings
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if page == nil {
loadContent(refreshing: false)
} else if timeline == .notifications {
loadNotifications(page: 1, refreshing: false)
}
}
override func loadContent(refreshing: Bool) {
guard context.core.activeServerIndex() != nil else {
tableView.backgroundView = EmptyBackgroundView(
title: "No server selected",
detail: "Use the server button to select a server or add your first one."
)
refreshControl?.endRefreshing()
return
}
switch timeline {
case .user:
loadUserPage(1, refreshing: refreshing)
case .allUsers:
loadServerActivityPage(1, refreshing: refreshing)
case .notifications:
loadNotifications(page: 1, refreshing: refreshing)
}
}
override func loadMoreContent() {
switch timeline {
case .user:
guard let userNextPage else { return }
loadUserPage(userNextPage, refreshing: false)
case .allUsers:
guard let serverNextPage else { return }
loadServerActivityPage(serverNextPage, refreshing: false)
case .notifications:
guard notificationsHaveMore else { return }
loadNotifications(page: notificationPage + 1, refreshing: false)
}
}
private func loadUserPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.home(
page: requestedPage,
filter: .all
)
guard !Task.isCancelled, timeline == .user else { return }
if requestedPage == 1 {
page = result
} else {
page?.activities.append(contentsOf: result.activities)
page?.nextPage = result.nextPage
}
userNextPage = result.nextPage
finishPagination(hasMore: result.nextPage != nil)
setPanelTitle(self, "Home", server: page?.serverName)
if requestedPage == 1 { refreshPrimaryDestinations() }
updateRows()
} catch {
if !Task.isCancelled, timeline == .user {
show(error: error)
failPagination()
}
}
if requestedPage == 1, timeline == .user { endLoading() }
}
}
private func loadServerActivityPage(_ requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.serverActivity(page: requestedPage)
guard !Task.isCancelled, timeline == .allUsers else { return }
if requestedPage == 1 {
serverActivities = result.rows
} else {
serverActivities.append(contentsOf: result.rows)
}
serverNextPage = result.hasMore ? requestedPage + 1 : nil
finishPagination(hasMore: result.hasMore)
updateRows()
} catch {
if !Task.isCancelled, timeline == .allUsers {
show(error: error)
failPagination()
}
}
if requestedPage == 1, timeline == .allUsers { endLoading() }
}
}
private func loadNotifications(page requestedPage: UInt32, refreshing: Bool) {
if requestedPage == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.notifications(
status: notificationStatus,
page: requestedPage
)
guard !Task.isCancelled, timeline == .notifications else { return }
if requestedPage == 1 {
notificationRows = result.rows
} else {
notificationRows.append(contentsOf: result.rows)
}
notificationPage = requestedPage
notificationsHaveMore = result.hasMore
finishPagination(hasMore: result.hasMore)
updateRows()
} catch {
if !Task.isCancelled, timeline == .notifications {
show(error: error)
failPagination()
}
}
if requestedPage == 1, timeline == .notifications { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
timeline == .notifications ? notificationRows.count : activities.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
if timeline == .notifications {
let cell = tableView.dequeueReusableCell(
withIdentifier: "notification",
for: indexPath
) as! NotificationCell
cell.configure(notificationRows[indexPath.row])
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
let row = activities[indexPath.row]
configureTextCell(
cell,
title: row.title,
detail: "\(row.detail)\n\(row.meta)",
image: context.symbol(activitySymbolName(for: row.icon))
)
cell.accessoryType = row.target == .none ? .none : .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
timeline == .notifications ? UITableView.automaticDimension : 92
}
override func tableView(
_ tableView: UITableView,
viewForHeaderInSection section: Int
) -> UIView? {
guard timeline == .notifications else { return nil }
return notificationHeaderView
}
override func tableView(
_ tableView: UITableView,
heightForHeaderInSection section: Int
) -> CGFloat {
timeline == .notifications ? 44 : .leastNormalMagnitude
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if timeline == .notifications {
openNotification(notificationRows[indexPath.row])
} else {
context.route(activities[indexPath.row])
}
}
private func updateRows() {
tableView.reloadData()
let isEmpty = timeline == .notifications ? notificationRows.isEmpty : activities.isEmpty
guard isEmpty else {
tableView.backgroundView = nil
return
}
let message: (String, String) = switch timeline {
case .user:
("No activity", "This user has no recent activity.")
case .allUsers:
("No server activity", "No activity is visible to this server account.")
case .notifications:
notificationStatus == .open
? ("No open notifications", "This server has no open notifications.")
: ("No closed notifications", "This server has no closed notifications.")
}
tableView.backgroundView = EmptyBackgroundView(title: message.0, detail: message.1)
}
private func selectTimeline(_ rawValue: Int) {
guard let selected = Timeline(rawValue: rawValue), selected != timeline else { return }
timeline = selected
setPanelTitle(self, "Home", server: page?.serverName)
refreshPrimaryDestinations()
tableView.reloadData()
loadContent(refreshing: false)
}
@objc private func notificationStatusChanged() {
notificationStatus = notificationStatusControl.selectedSegmentIndex == 0 ? .open : .closed
loadNotifications(page: 1, refreshing: false)
}
private func openNotification(_ row: NotificationRow) {
guard row.target != .none else { return }
guard row.unread else {
context.route(row)
return
}
loadingTask?.cancel()
loadingTask = Task {
do {
try await context.core.markNotificationRead(serverId: row.serverId, id: row.id)
guard !Task.isCancelled else { return }
context.route(row)
} catch {
if !Task.isCancelled { show(error: error) }
}
}
}
func refreshPrimaryDestinations() {
tableView.tableHeaderView = page.map { page in
HeatmapView(
page: page,
selectedTimeline: timeline.rawValue,
destinations: context.secondaryDestinations.filter { $0 != .serverActivity },
onTimeline: { [weak self] timeline in self?.selectTimeline(timeline) },
onDestination: { [weak self] destination in
guard let self else { return }
self.context.show(destination, from: self.navigationController)
}
)
}
}
}
func activitySymbolName(for icon: ActivityIcon) -> String {
switch icon {
case .pullRequest: return "arrow.triangle.pull"
case .issue: return "exclamationmark.circle"
case .branch: return "arrow.triangle.branch"
case .tag: return "tag"
case .push: return "arrow.up.circle"
case .release: return "shippingbox"
case .repository: return "books.vertical"
}
}
final class HeatmapView: UIView {
private let cells: [HeatCell]
private let calendar = Calendar(identifier: .gregorian)
private let controlsScroller = UIScrollView()
init(
page: HomePage,
selectedTimeline: Int,
destinations: [PrimaryDestination],
onTimeline: @escaping (Int) -> Void,
onDestination: @escaping (PrimaryDestination) -> Void
) {
cells = page.heatCells
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"
title.font = .preferredFont(forTextStyle: .subheadline)
title.textColor = .secondaryLabel
addSubview(title)
let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18))
total.text = "\(page.contributionCount) contributions"
total.font = .preferredFont(forTextStyle: .caption1)
total.textColor = .tertiaryLabel
addSubview(total)
let timelinesView = UIStackView()
timelinesView.axis = .horizontal
let timelineItems = [
("person.crop.circle", "person.crop.circle.fill", "Your activity"),
("person.3", "person.3.fill", "All users activity"),
("bell", "bell.fill", "Notifications"),
]
for (index, item) in timelineItems.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
}
onTimeline(button.tag)
})
button.tag = index
button.setImage(UIImage(systemName: item.0), for: .normal)
button.setImage(UIImage(systemName: item.1), for: .selected)
button.isSelected = index == selectedTimeline
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
button.accessibilityLabel = item.2
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
timelinesView.addArrangedSubview(button)
}
let destinationsView = UIStackView()
destinationsView.axis = .horizontal
for destination in destinations {
let button = UIButton(
type: .custom,
primaryAction: UIAction { _ in onDestination(destination) }
)
let symbol = destination == .milestones ? "flag.fill" : destination.symbolName
button.setImage(UIImage(systemName: symbol), for: .normal)
button.tintColor = .secondaryLabel
button.accessibilityLabel = destination.title
button.accessibilityHint = "Opens from Home"
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
destinationsView.addArrangedSubview(button)
}
let controls = UIStackView(arrangedSubviews: [timelinesView])
controls.axis = .horizontal
controls.alignment = .center
controls.translatesAutoresizingMaskIntoConstraints = false
if !destinations.isEmpty {
controls.addArrangedSubview(destinationsView)
}
controlsScroller.showsHorizontalScrollIndicator = false
controlsScroller.translatesAutoresizingMaskIntoConstraints = false
controlsScroller.addSubview(controls)
addSubview(controlsScroller)
NSLayoutConstraint.activate([
controlsScroller.leadingAnchor.constraint(equalTo: leadingAnchor),
controlsScroller.trailingAnchor.constraint(equalTo: trailingAnchor),
controlsScroller.topAnchor.constraint(equalTo: topAnchor, constant: 132),
controlsScroller.heightAnchor.constraint(equalToConstant: 44),
controls.leadingAnchor.constraint(
equalTo: controlsScroller.contentLayoutGuide.leadingAnchor,
constant: 8
),
controls.trailingAnchor.constraint(
equalTo: controlsScroller.contentLayoutGuide.trailingAnchor,
constant: -8
),
controls.topAnchor.constraint(equalTo: controlsScroller.contentLayoutGuide.topAnchor),
controls.bottomAnchor.constraint(
equalTo: controlsScroller.contentLayoutGuide.bottomAnchor
),
controls.heightAnchor.constraint(equalTo: controlsScroller.frameLayoutGuide.heightAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func layoutSubviews() {
super.layoutSubviews()
let inset = max(0, (controlsScroller.bounds.width - controlsScroller.contentSize.width) / 2)
controlsScroller.contentInset.left = inset
controlsScroller.contentInset.right = inset
}
override func draw(_ rect: CGRect) {
guard
let first = cells.first,
let last = cells.last
else { return }
let firstDate = Date(timeIntervalSince1970: TimeInterval(first.timestamp))
let lastDate = Date(timeIntervalSince1970: TimeInterval(last.timestamp))
guard let firstWeek = calendar.dateInterval(of: .weekOfYear, for: firstDate)?.start else {
return
}
let monthFormatter = DateFormatter()
monthFormatter.dateFormat = "MMM"
let months = cells.reduce(into: [Date]()) { result, cell in
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
if result.last != month { result.append(month) }
}
let weekCount = CGFloat(
(calendar.dateComponents([.day], from: firstWeek, to: lastDate).day ?? 0) / 7 + 1
)
let monthGap: CGFloat = 4
let monthGaps = CGFloat(max(months.count - 1, 0)) * monthGap
let width = max(4, min(6, (bounds.width - 32 - monthGaps) / weekCount - 1))
let gap = width + 1
let graphWidth = (weekCount - 1) * gap + width + monthGaps
let graphOrigin = max(0, (bounds.width - graphWidth) / 2)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .caption2),
.foregroundColor: UIColor.secondaryLabel,
]
for (index, month) in months.enumerated() {
let days = calendar.dateComponents([.day], from: firstWeek, to: month).day ?? 0
monthFormatter.string(from: month).draw(
at: CGPoint(
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(index) * monthGap,
y: 34
),
withAttributes: labelAttributes
)
}
for cell in cells {
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let days = calendar.dateComponents([.day], from: firstWeek, to: date).day ?? 0
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
let monthIndex = months.firstIndex(of: month) ?? 0
let colors: [UIColor] = [
.systemGray5,
UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1),
UIColor(red: 0.45, green: 0.71, blue: 0.91, alpha: 1),
UIColor(red: 0.15, green: 0.55, blue: 0.83, alpha: 1),
UIColor(red: 0.04, green: 0.41, blue: 0.72, alpha: 1),
]
colors[Int(min(cell.level, 4))].setFill()
UIBezierPath(
roundedRect: CGRect(
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
width: width,
height: width
),
cornerRadius: 1
).fill()
}
}
}