import UIKit @MainActor final class HomeViewController: RefreshingTableViewController { private enum ActivityFilter: Int { case all case issues case pulls var coreValue: HomeActivityFilter { switch self { case .all: return .all case .issues: return .issues case .pulls: return .pullRequests } } } private let context: AppContext private var page: HomePage? private var filter = ActivityFilter.all private var nextPage: UInt32? private var activities: [ActivityRow] { 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() 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) guard page == nil else { return } loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { guard context.core.activeServerIndex() != nil else { tableView.backgroundView = EmptyBackgroundView( title: "No server selected", detail: "Open Issues or Repos to select a server or add your first one." ) refreshControl?.endRefreshing() return } loadPage(1, refreshing: refreshing) } override func loadMoreContent() { guard let nextPage else { return } loadPage(nextPage, refreshing: false) } private func loadPage(_ 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: filter.coreValue ) if requestedPage == 1 { page = result } else { page?.activities.append(contentsOf: result.activities) page?.nextPage = result.nextPage } nextPage = result.nextPage finishPagination(hasMore: result.nextPage != nil) title = page?.serverName if requestedPage == 1 { 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 } guard filter != self.filter else { return } self.filter = filter self.loadPage(1, refreshing: false) } } } updateActivities() } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { endLoading() } } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { activities.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { 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(symbolName(for: row.icon)) ) cell.accessoryType = row.target == .none ? .none : .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 92 } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) 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: 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) init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> 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 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"), ] 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) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } 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() } } }