chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app reviews a pull from its own sheet, with roomier rows
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/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 { | ||