diff --git a/TESTING.md b/TESTING.md index 337fb4f..89bf2ba 100644 --- a/TESTING.md +++ b/TESTING.md @@ -183,8 +183,10 @@ xcrun simctl launch booted de.rfc1437.gotcha issue list/detail remains usable with Dynamic Type and VoiceOver labels. - [ ] Open both an open and a closed issue. A green open or purple closed icon appears beside the title and VoiceOver announces the state; author - metadata, Markdown body, and comments remain correct. Links and - selectable text use normal iOS interaction. + metadata, every colored label, Markdown body, and comments remain correct. + Multiple labels wrap without clipping at large Dynamic Type sizes and + VoiceOver announces each label. Links and selectable text use normal iOS + interaction. - [ ] Tap the **Add comment** icon on an issue. Enter headings, emphasis, a list, link, and fenced code block; **Write** syntax-highlights the Markdown, **Preview** renders it, and switching modes preserves the exact diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 24d09e7..03d4a68 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -77,6 +77,7 @@ pub struct IssuePage { pub state: String, pub meta: String, pub milestone: String, + pub labels: Vec, pub body: String, pub comments: Vec, pub has_more: bool, @@ -554,6 +555,14 @@ pub fn issue_page(details: IssueDetails) -> IssuePage { .unwrap_or_else(|| "unknown".into()), meta: issue_meta(&details.issue), milestone: issue_milestone(&details.issue), + labels: details + .issue + .labels + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(label_row) + .collect(), body: details .issue .body @@ -1269,6 +1278,11 @@ mod tests { let page = issue_page(IssueDetails { issue: models::Issue { state: Some("closed".into()), + labels: Some(vec![models::Label { + name: Some("bug".into()), + color: Some("ff0000".into()), + ..Default::default() + }]), ..Default::default() }, comments: vec![ @@ -1297,6 +1311,7 @@ mod tests { }); assert_eq!(page.state, "closed"); + assert_eq!(page.labels[0].name, "bug"); assert_eq!(page.comments[0].id, 7); assert!(page.comments[0].can_edit); assert!(!page.comments[1].can_edit); diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index ba25237..1b29696 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -2236,17 +2236,19 @@ public struct IssuePage: Equatable, Hashable { public var state: String public var meta: String public var milestone: String + public var labels: [LabelRow] public var body: String public var comments: [CommentRow] public var hasMore: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(title: String, state: String, meta: String, milestone: String, body: String, comments: [CommentRow], hasMore: Bool) { + public init(title: String, state: String, meta: String, milestone: String, labels: [LabelRow], body: String, comments: [CommentRow], hasMore: Bool) { self.title = title self.state = state self.meta = meta self.milestone = milestone + self.labels = labels self.body = body self.comments = comments self.hasMore = hasMore @@ -2272,6 +2274,7 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer { state: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf), milestone: FfiConverterString.read(from: &buf), + labels: FfiConverterSequenceTypeLabelRow.read(from: &buf), body: FfiConverterString.read(from: &buf), comments: FfiConverterSequenceTypeCommentRow.read(from: &buf), hasMore: FfiConverterBool.read(from: &buf) @@ -2283,6 +2286,7 @@ public struct FfiConverterTypeIssuePage: FfiConverterRustBuffer { FfiConverterString.write(value.state, into: &buf) FfiConverterString.write(value.meta, into: &buf) FfiConverterString.write(value.milestone, into: &buf) + FfiConverterSequenceTypeLabelRow.write(value.labels, into: &buf) FfiConverterString.write(value.body, into: &buf) FfiConverterSequenceTypeCommentRow.write(value.comments, into: &buf) FfiConverterBool.write(value.hasMore, into: &buf) diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 28345c3..01fb2cb 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -370,14 +370,7 @@ final class IssueCell: UITableViewCell { labels.arrangedSubviews.forEach { $0.removeFromSuperview() } labels.isHidden = row.labels.isEmpty for label in row.labels.prefix(3) { - let view = UILabel() - view.text = " \(label.name) " - view.font = .preferredFont(forTextStyle: .caption2) - view.textColor = label.light ? .black : .white - view.backgroundColor = UIColor(hex: label.color) - view.layer.cornerRadius = 9 - view.clipsToBounds = true - labels.addArrangedSubview(view) + labels.addArrangedSubview(issueLabelView(label)) } labels.addArrangedSubview(UIView()) } diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index 6cde9b2..82bee54 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -232,7 +232,8 @@ final class IssueViewController: MarkdownPageViewController { body: page.body, comments: page.comments, editComment: { [weak self] comment in self?.editComment(comment) }, - milestone: page.milestone + milestone: page.milestone, + labels: page.labels )) finishPagination(hasMore: page.hasMore) } @@ -1142,7 +1143,8 @@ private func detailViews( body: String, comments: [CommentRow], editComment: ((CommentRow) -> Void)? = nil, - milestone: String = "" + milestone: String = "", + labels: [LabelRow] = [] ) -> [UIView] { let titleLabel = UILabel() titleLabel.text = title @@ -1166,6 +1168,7 @@ private func detailViews( milestoneLabel.accessibilityLabel = "Milestone \(milestone)" views.append(milestoneLabel) } + if !labels.isEmpty { views.append(IssueLabelsView(labels)) } return views + [separator(), bodyView] + commentViews(comments, editComment: editComment) } diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index 85d434c..a8daca3 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -287,3 +287,74 @@ func configureIssueStateIcon( icon.isAccessibilityElement = true icon.accessibilityLabel = accessibilityLabel } + +func issueLabelView(_ label: LabelRow) -> UILabel { + let view = UILabel() + view.text = " \(label.name) " + view.font = .preferredFont(forTextStyle: .caption2) + view.adjustsFontForContentSizeCategory = true + view.textColor = label.light ? .black : .white + view.backgroundColor = UIColor(hex: label.color) + view.layer.cornerRadius = 9 + view.clipsToBounds = true + view.accessibilityLabel = "Label \(label.name)" + return view +} + +final class IssueLabelsView: UIView { + private let labels: [UILabel] + private var contentHeight: CGFloat + private let spacing: CGFloat = 6 + + init(_ rows: [LabelRow]) { + labels = rows.map(issueLabelView) + contentHeight = labels.first?.intrinsicContentSize.height ?? 0 + super.init(frame: .zero) + labels.forEach(addSubview) + NotificationCenter.default.addObserver( + self, + selector: #selector(contentSizeCategoryDidChange), + name: UIContentSizeCategory.didChangeNotification, + object: nil + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) + } + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0 else { return } + var origin = CGPoint.zero + var rowHeight: CGFloat = 0 + for label in labels { + let size = label.sizeThatFits( + CGSize(width: bounds.width, height: .greatestFiniteMagnitude) + ) + if origin.x > 0, origin.x + size.width > bounds.width { + origin.x = 0 + origin.y += rowHeight + spacing + rowHeight = 0 + } + label.frame = CGRect(origin: origin, size: size) + origin.x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + let height = origin.y + rowHeight + if contentHeight != height { + contentHeight = height + invalidateIntrinsicContentSize() + } + } + + @objc private func contentSizeCategoryDidChange() { + labels.forEach { $0.font = .preferredFont(forTextStyle: .caption2) } + contentHeight = labels.first?.intrinsicContentSize.height ?? 0 + invalidateIntrinsicContentSize() + setNeedsLayout() + } +}