Show issue state icons in lists

This commit is contained in:
Georg Bauer
2026-07-31 17:07:13 +02:00
parent f38c3c4939
commit e39695fc60
6 changed files with 48 additions and 21 deletions

View File

@@ -145,6 +145,9 @@ xcrun simctl launch booted de.rfc1437.gotcha
date, and current favorite state. date, and current favorite state.
- [ ] Toggle a favorite and confirm it remains after refresh and relaunch. - [ ] Toggle a favorite and confirm it remains after refresh and relaunch.
- [ ] Open a repository; the issue list defaults to the saved Open/Closed filter. - [ ] Open a repository; the issue list defaults to the saved Open/Closed filter.
- [ ] Repository issue rows and issue rows inside a milestone show a green open
or purple closed icon beside the title; mixed milestone results use the
correct icon per row and VoiceOver announces each state.
- [ ] Change the native filter menu between Open and Closed; the checkmark, - [ ] Change the native filter menu between Open and Closed; the checkmark,
rows, and persisted selection update. rows, and persisted selection update.
- [ ] Select one and then multiple labels in the issue filter. Checkmarks and - [ ] Select one and then multiple labels in the issue filter. Checkmarks and

View File

@@ -38,6 +38,7 @@ pub struct LabelRow {
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct IssueRow { pub struct IssueRow {
pub number: i64, pub number: i64,
pub state: String,
pub title: String, pub title: String,
pub summary: String, pub summary: String,
pub meta: String, pub meta: String,
@@ -288,6 +289,7 @@ pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
.iter() .iter()
.map(|issue| IssueRow { .map(|issue| IssueRow {
number: issue.number.unwrap_or_default(), number: issue.number.unwrap_or_default(),
state: issue.state.clone().unwrap_or_else(|| "unknown".into()),
title: issue title: issue
.title .title
.clone() .clone()
@@ -1251,10 +1253,13 @@ mod tests {
assert_eq!(milestone_row.progress, 0.4); assert_eq!(milestone_row.progress, 0.4);
assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open"); assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open");
let issue = models::Issue { let issue = models::Issue {
state: Some("closed".into()),
milestone: Some(Box::new(milestone)), milestone: Some(Box::new(milestone)),
..Default::default() ..Default::default()
}; };
assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1"); let row = &issue_rows(&[issue])[0];
assert_eq!(row.state, "closed");
assert_eq!(row.milestone, "Version 1");
} }
#[test] #[test]

View File

@@ -2288,6 +2288,7 @@ public func FfiConverterTypeIssuePage_lower(_ value: IssuePage) -> RustBuffer {
public struct IssueRow: Equatable, Hashable { public struct IssueRow: Equatable, Hashable {
public var number: Int64 public var number: Int64
public var state: String
public var title: String public var title: String
public var summary: String public var summary: String
public var meta: String public var meta: String
@@ -2296,8 +2297,9 @@ public struct IssueRow: Equatable, Hashable {
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // declare one manually.
public init(number: Int64, title: String, summary: String, meta: String, milestone: String, labels: [LabelRow]) { public init(number: Int64, state: String, title: String, summary: String, meta: String, milestone: String, labels: [LabelRow]) {
self.number = number self.number = number
self.state = state
self.title = title self.title = title
self.summary = summary self.summary = summary
self.meta = meta self.meta = meta
@@ -2322,6 +2324,7 @@ public struct FfiConverterTypeIssueRow: FfiConverterRustBuffer {
return return
try IssueRow( try IssueRow(
number: FfiConverterInt64.read(from: &buf), number: FfiConverterInt64.read(from: &buf),
state: FfiConverterString.read(from: &buf),
title: FfiConverterString.read(from: &buf), title: FfiConverterString.read(from: &buf),
summary: FfiConverterString.read(from: &buf), summary: FfiConverterString.read(from: &buf),
meta: FfiConverterString.read(from: &buf), meta: FfiConverterString.read(from: &buf),
@@ -2332,6 +2335,7 @@ public struct FfiConverterTypeIssueRow: FfiConverterRustBuffer {
public static func write(_ value: IssueRow, into buf: inout [UInt8]) { public static func write(_ value: IssueRow, into buf: inout [UInt8]) {
FfiConverterInt64.write(value.number, into: &buf) FfiConverterInt64.write(value.number, into: &buf)
FfiConverterString.write(value.state, into: &buf)
FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.title, into: &buf)
FfiConverterString.write(value.summary, into: &buf) FfiConverterString.write(value.summary, into: &buf)
FfiConverterString.write(value.meta, into: &buf) FfiConverterString.write(value.meta, into: &buf)
@@ -4061,4 +4065,4 @@ public func uniffiEnsureGotchaCoreInitialized() {
} }
} }
// swiftlint:enable all // swiftlint:enable all

View File

@@ -282,6 +282,7 @@ final class IssuesViewController: RefreshingTableViewController {
} }
final class IssueCell: UITableViewCell { final class IssueCell: UITableViewCell {
private let stateIcon = UIImageView()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let summaryLabel = UILabel() private let summaryLabel = UILabel()
private let labels = UIStackView() private let labels = UIStackView()
@@ -293,6 +294,9 @@ final class IssueCell: UITableViewCell {
accessoryType = .disclosureIndicator accessoryType = .disclosureIndicator
titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 2 titleLabel.numberOfLines = 2
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline
titleStack.spacing = 8
summaryLabel.font = .preferredFont(forTextStyle: .subheadline) summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
summaryLabel.textColor = .secondaryLabel summaryLabel.textColor = .secondaryLabel
summaryLabel.numberOfLines = 2 summaryLabel.numberOfLines = 2
@@ -304,7 +308,7 @@ final class IssueCell: UITableViewCell {
milestoneLabel.font = .preferredFont(forTextStyle: .caption1) milestoneLabel.font = .preferredFont(forTextStyle: .caption1)
milestoneLabel.adjustsFontForContentSizeCategory = true milestoneLabel.adjustsFontForContentSizeCategory = true
let stack = UIStackView( let stack = UIStackView(
arrangedSubviews: [titleLabel, summaryLabel, labels, metaLabel, milestoneLabel] arrangedSubviews: [titleStack, summaryLabel, labels, metaLabel, milestoneLabel]
) )
stack.axis = .vertical stack.axis = .vertical
stack.spacing = 6 stack.spacing = 6
@@ -322,6 +326,7 @@ final class IssueCell: UITableViewCell {
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
func configure(_ row: IssueRow) { func configure(_ row: IssueRow) {
configureIssueStateIcon(stateIcon, state: row.state, textStyle: .headline)
titleLabel.text = row.title titleLabel.text = row.title
summaryLabel.text = row.summary summaryLabel.text = row.summary
metaLabel.text = row.meta metaLabel.text = row.meta

View File

@@ -1130,23 +1130,8 @@ private func detailViews(
} }
private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView { private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
let symbol: String let icon = UIImageView()
let color: UIColor configureIssueStateIcon(icon, state: state, textStyle: .title1)
let accessibilityLabel: String
switch state {
case "open":
(symbol, color, accessibilityLabel) = ("exclamationmark.circle.fill", .systemGreen, "Open issue")
case "closed":
(symbol, color, accessibilityLabel) = ("checkmark.circle.fill", .systemPurple, "Closed issue")
default:
(symbol, color, accessibilityLabel) = ("questionmark.circle.fill", .systemGray, "Unknown issue state")
}
let icon = UIImageView(image: UIImage(systemName: symbol))
icon.tintColor = color
icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .title1)
icon.setContentHuggingPriority(.required, for: .horizontal)
icon.isAccessibilityElement = true
icon.accessibilityLabel = accessibilityLabel
let stack = UIStackView(arrangedSubviews: [icon, titleLabel]) let stack = UIStackView(arrangedSubviews: [icon, titleLabel])
stack.axis = .horizontal stack.axis = .horizontal
stack.alignment = .firstBaseline stack.alignment = .firstBaseline

View File

@@ -255,3 +255,28 @@ func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) ->
])) ]))
return output return output
} }
func configureIssueStateIcon(
_ icon: UIImageView,
state: String,
textStyle: UIFont.TextStyle
) {
let symbol: String
let color: UIColor
let accessibilityLabel: String
switch state {
case "open":
(symbol, color, accessibilityLabel) = ("exclamationmark.circle.fill", .systemGreen, "Open issue")
case "closed":
(symbol, color, accessibilityLabel) = ("checkmark.circle.fill", .systemPurple, "Closed issue")
default:
(symbol, color, accessibilityLabel) = ("questionmark.circle.fill", .systemGray, "Unknown issue state")
}
icon.image = UIImage(systemName: symbol)
icon.tintColor = color
icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: textStyle)
icon.setContentHuggingPriority(.required, for: .horizontal)
icon.setContentCompressionResistancePriority(.required, for: .horizontal)
icon.isAccessibilityElement = true
icon.accessibilityLabel = accessibilityLabel
}