~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app reviews a pull from its own sheet, with roomier rows

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/review-sheet
ios/Knotless/Support/Theme.swift+2−0
@@ -8,6 +8,8 @@ extension Color {
}
static let success = Color(light: 0x1a7f37, dark: 0x3fb950)
/// Green to fill behind white text, which `success` is too light for in dark mode.
static let successFill = Color(uiColor: UIColor(hex: 0x1a7f37))
static let danger = Color(light: 0xcf222e, dark: 0xf85149)
static let done = Color(light: 0x7048b5, dark: 0xb39af0)
static let attention = Color(light: 0x9a6700, dark: 0xd29922)
ios/Knotless/Views/FilesView.swift+21−9
@@ -6,12 +6,16 @@ struct FilesView: View {
let repo: RepoRef
let uri: String
@Environment(Account.self) private var account
@State private var reviewing = false
var body: some View {
Loading(load: { [repo, uri] in
let thread: PullThread = try await Client.shared.query(
"getThread", ["repo": repo.uri, "uri": uri, "filesOnly": "true"])
return thread.changes?.files ?? []
}) { files in
return thread
}) { thread in
let files = thread.changes?.files ?? []
ScrollView {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(files) { file in
@@ -19,6 +23,14 @@ struct FilesView: View {
}
}
}
.safeAreaInset(edge: .bottom) {
if account.signedIn, thread.item.state == "open" {
ReviewButton { reviewing = true }.padding(.bottom, 8)
}
}
.sheet(isPresented: $reviewing) {
ReviewSheet(repo: repo, uri: uri, author: thread.item.author.did) {}
}
.navigationSubtitleCompat(added: files.reduce(0) { $0 + $1.added }, removed: files.reduce(0) { $0 + $1.removed })
}
.navigationTitle("Files changed")
@@ -73,21 +85,21 @@ struct FileSection: View {
}
} header: {
Button { open.toggle() } label: {
HStack(spacing: 8) {
Image(systemName: "chevron.right")
.rotationEffect(open ? .degrees(90) : .zero)
.font(.footnote.weight(.semibold))
HStack(spacing: 12) {
Oct(name: "chevron-down", size: 16)
.rotationEffect(open ? .zero : .degrees(-90))
.foregroundStyle(.secondary)
Text(file.path).font(.footnote.monospaced()).lineLimit(1).truncationMode(.head)
Text(file.path).font(.subheadline.monospaced()).lineLimit(1).truncationMode(.head)
.foregroundStyle(.primary)
Spacer(minLength: 4)
Text("+\(file.added)").foregroundStyle(Color.success)
Text("-\(file.removed)").foregroundStyle(Color.danger)
}
.font(.footnote.monospacedDigit())
.padding(.horizontal, 12)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.frame(minHeight: 52)
.background(Color(.secondarySystemBackground))
.overlay(alignment: .top) { Color(.separator).frame(height: 0.5) }
.overlay(alignment: .bottom) { Color(.separator).frame(height: 0.5) }
}
.buttonStyle(.plain)
ios/Knotless/Views/HomeView.swift+3−3
@@ -106,8 +106,8 @@ private struct HomeScreen: View {
.accessibilityLabel("Your profile")
}
ToolbarItemGroup(placement: .topBarTrailing) {
Button { writing = true } label: { Oct(name: "plus", size: 20).foregroundStyle(.primary) }.accessibilityLabel("New issue")
NavigationLink(value: Route.search) { Oct(name: "search", size: 20).foregroundStyle(.primary) }.accessibilityLabel("Search")
Button { writing = true } label: { Oct(name: "plus", size: 24) }.tint(.primary).accessibilityLabel("New issue")
NavigationLink(value: Route.search) { Oct(name: "search", size: 24) }.tint(.primary).accessibilityLabel("Search")
}
}
.sheet(isPresented: $editing) { EditFavorites(person: person, stored: $stored) }
@@ -175,7 +175,7 @@ private struct FavoriteRow: View {
RowChevron(name: "chevron-right")
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
}
ios/Knotless/Views/ProfileView.swift+1−1
@@ -93,7 +93,7 @@ private struct ProfilePage: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.frame(minHeight: 46)
.frame(minHeight: 56)
.contentShape(Rectangle())
}
.buttonStyle(RowPress())
ios/Knotless/Views/RepoView.swift+1−1
@@ -358,7 +358,7 @@ struct PageRowLabel<Trailing: View>: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.frame(minHeight: 46)
.frame(minHeight: 56)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
ios/Knotless/Views/Shared.swift+1−1
@@ -107,7 +107,7 @@ extension StateStyle {
var pillColor: Color {
switch label {
case "Merged": Color(uiColor: UIColor(hex: 0x7048b5))
case "Open": Color(uiColor: UIColor(hex: 0x1a7f37))
case "Open": Color.successFill
case "Closed": icon == "oct-issue-closed" ? Color(uiColor: UIColor(hex: 0x7048b5)) : Color(uiColor: UIColor(hex: 0xcf222e))
default: Color(uiColor: UIColor(hex: 0x636363))
}
ios/Knotless/Views/ThreadView.swift+13−6
@@ -10,6 +10,8 @@ struct ThreadView: View {
/// Moved by a write, so the thread is read again as it now stands.
@State private var edits = 0
@State private var composing = false
@State private var reviewing = false
@State private var isPull = false
@State private var failure: String?
@State private var canSetState: Bool?
@State private var state: String?
@@ -26,9 +28,13 @@ struct ThreadView: View {
canSetState = thread.viewer?.setState
state = thread.item.state
title = thread.item.title
isPull = thread.item.kind == "pull"
}
.sheet(isPresented: $composing) {
Composer(repo: repo, uri: uri, isPull: thread.item.kind == "pull") { edits += 1 }
Composer(repo: repo, uri: uri) { edits += 1 }
}
.sheet(isPresented: $reviewing) {
ReviewSheet(repo: repo, uri: uri, author: thread.item.author.did) { edits += 1 }
}
}
.navigationBarTitleDisplayMode(.inline)
@@ -39,12 +45,13 @@ struct ThreadView: View {
Button {
composing = true
} label: {
SwiftUI.Label { Text("Comment") } icon: { Oct(name: "comment", size: 18) }
.font(.body.weight(.medium))
.padding(.horizontal, 6)
.padding(.vertical, 4)
SwiftUI.Label { Text("Comment") } icon: { Oct(name: "comment", size: 16) }
.font(.callout.weight(.medium))
}
.floating()
if isPull, state == "open" {
ReviewButton { reviewing = true }
}
}
.padding(.horizontal)
.padding(.bottom, 8)
@@ -294,7 +301,7 @@ struct ThreadView: View {
.mask(LinearGradient(stops: [.init(color: .black, location: 0.7), .init(color: .clear, location: 1)], startPoint: .top, endPoint: .bottom))
.overlay(alignment: .bottom) {
Button { expanded = true } label: {
SwiftUI.Label { Text("Read more") } icon: { Oct(name: "arrow-down", size: 16) }.font(.body.weight(.medium))
SwiftUI.Label { Text("Read more") } icon: { Oct(name: "arrow-down", size: 16) }.font(.callout.weight(.medium))
}
.floating()
}
ios/Knotless/Views/Writing.swift+157−24
@@ -1,22 +1,13 @@
import SwiftUI
/// Writes a comment, or on a pull, a review.
/// Writes a comment.
struct Composer: View {
enum Kind: String, CaseIterable, Identifiable {
case comment = "Comment"
case approve = "Approve"
case changes = "Ask for changes"
var id: String { rawValue }
}
let repo: RepoRef
let uri: String
let isPull: Bool
let sent: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var kind = Kind.comment
@State private var sending = false
@State private var failure: String?
@FocusState private var focused: Bool
@@ -26,18 +17,12 @@ struct Composer: View {
var body: some View {
NavigationStack {
VStack(spacing: 12) {
if isPull {
Picker("Kind", selection: $kind) {
ForEach(Kind.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
}
TextEditor(text: $text)
.focused($focused)
.accessibilityLabel("Your comment")
.overlay(alignment: .topLeading) {
if empty {
Text(kind == .approve ? "Say why, if you like" : "Write in Markdown")
Text("Write in Markdown")
.foregroundStyle(.tertiary)
.padding(.top, 8)
.padding(.leading, 5)
@@ -50,7 +35,7 @@ struct Composer: View {
}
}
.padding()
.navigationTitle(isPull ? "Comment or review" : "Comment")
.navigationTitle("Comment")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -58,7 +43,7 @@ struct Composer: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Send") { Task { await send() } }
.disabled(sending || (empty && kind != .approve))
.disabled(sending || empty)
}
}
.onAppear { focused = true }
@@ -70,13 +55,161 @@ struct Composer: View {
sending = true
failure = nil
defer { sending = false }
let verdict: String? = switch kind {
case .comment: nil
case .approve: "approve"
case .changes: "changes"
do {
try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: nil))
sent()
dismiss()
} catch {
failure = error.localizedDescription
}
}
}
/// The green button that opens a pull's review.
struct ReviewButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
SwiftUI.Label { Text("Review") } icon: { Oct(name: "check", size: 16) }
.font(.callout.weight(.medium))
.foregroundStyle(.white)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.controlSize(.large)
.tint(Color.successFill)
}
}
/// Writes a pull's review. Its author can only comment, as readers take no
/// verdict from them.
struct ReviewSheet: View {
private enum Choice: CaseIterable, Identifiable {
case comment, approve, changes
var id: Self { self }
var title: String {
switch self {
case .comment: "Comment"
case .approve: "Approve"
case .changes: "Request changes"
}
}
var verdict: String? {
switch self {
case .comment: nil
case .approve: "approve"
case .changes: "changes"
}
}
func about(own: Bool) -> String {
switch self {
case .comment: "Leave feedback without a verdict."
case .approve: own ? "You can't approve your own pull." : "Say these changes are good to merge."
case .changes: own ? "You can't request changes on your own pull." : "Say what must change before this merges."
}
}
}
let repo: RepoRef
let uri: String
/// The DID of the pull's author.
let author: String
let sent: () -> Void
@Environment(Account.self) private var account
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var choice = Choice.comment
@State private var sending = false
@State private var failure: String?
private var own: Bool { account.person?.did == author }
private var empty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
/// A review may say nothing past its verdict. A comment says something.
private var ready: Bool { !sending && (choice != .comment || !empty) }
private var action: String {
switch choice {
case .comment: "Submit review"
default: choice.title
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
TextField("Leave a comment", text: $text, axis: .vertical)
.lineLimit(1...8)
.accessibilityLabel("Your review")
.padding(16)
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 20))
.padding(.bottom, 8)
ForEach(Choice.allCases) { option($0) }
if let failure {
Text(failure).font(.footnote).foregroundStyle(Color.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button { Task { await send() } } label: {
Text(action)
.font(.body.weight(.semibold))
.foregroundStyle(ready ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.successFill.opacity(ready ? 1 : 0.25), in: RoundedRectangle(cornerRadius: 20))
}
.buttonStyle(.plain)
.disabled(!ready)
.padding(.top, 8)
}
.padding(16)
}
.navigationTitle("Review")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: { Oct(name: "x", size: 20) }
.tint(.primary)
.accessibilityLabel("Close")
}
}
.interactiveDismissDisabled(!empty)
}
}
private func option(_ option: Choice) -> some View {
let open = option == .comment || !own
return Button { choice = option } label: {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(option.title).font(.body.weight(.medium)).foregroundStyle(open ? .primary : .secondary)
Text(option.about(own: own)).font(.subheadline).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if choice == option {
Oct(name: "check-circle-fill", size: 20).foregroundStyle(Color.accent)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 20))
.contentShape(RoundedRectangle(cornerRadius: 20))
}
.buttonStyle(.plain)
.disabled(!open)
.accessibilityAddTraits(choice == option ? .isSelected : [])
}
private func send() async {
sending = true
failure = nil
defer { sending = false }
do {
try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: verdict))
try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: choice.verdict))
sent()
dismiss()
} catch {