chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app's sign-in screen is the tilde over a handle and a button
ios/Knotless/API/Account.swift+74−28
| @@ -87,13 +87,12 @@ final class Account { | ||
} | ||
} | ||
| ||
/// Logs in through the browser, as `handle`, or making an account. | ||
func signIn(handle: String = "", create: Bool = false) async { | ||
/// Logs in through the browser, as `handle`. | ||
func signIn(handle: String) async { | ||
failure = nil | ||
let pkce = PKCE() | ||
var parts = URLComponents(url: Client.site.appendingPathComponent("oauth/app-login"), resolvingAgainstBaseURL: false)! | ||
parts.queryItems = [URLQueryItem(name: "challenge", value: pkce.challenge)] | ||
+ (create ? [URLQueryItem(name: "create", value: "true")] : [URLQueryItem(name: "handle", value: handle)]) | ||
parts.queryItems = [URLQueryItem(name: "challenge", value: pkce.challenge), URLQueryItem(name: "handle", value: handle)] | ||
let opened = Date() | ||
do { | ||
let back = try await browse(parts.url!) | ||
| @@ -150,44 +149,91 @@ private final class BrowserAnchor: NSObject, ASWebAuthenticationPresentationCont | ||
} | ||
} | ||
| ||
/// Asks for a handle and goes to the browser. | ||
/// The tilde over a handle to sign in with, which goes to the browser. | ||
struct SignInForm: View { | ||
@Environment(Account.self) private var account | ||
@State private var handle = "" | ||
@State private var busy = false | ||
@FocusState private var typing: Bool | ||
var why: String | ||
| ||
private var name: String { handle.trimmingCharacters(in: .whitespaces) } | ||
private var ready: Bool { !name.isEmpty && !busy } | ||
| ||
var body: some View { | ||
Form { | ||
Section { | ||
TextField("alice.bsky.social", text: $handle) | ||
.textInputAutocapitalization(.never) | ||
.autocorrectionDisabled() | ||
.keyboardType(.URL) | ||
.submitLabel(.go) | ||
.onSubmit { go(create: false) } | ||
Button("Sign in") { go(create: false) } | ||
.disabled(handle.trimmingCharacters(in: .whitespaces).isEmpty || busy) | ||
} header: { | ||
Text("Your handle") | ||
} footer: { | ||
Text(why) | ||
} | ||
Section { | ||
Button("Make an account") { go(create: true) }.disabled(busy) | ||
} footer: { | ||
Text("An Atmosphere account is one you can use across apps.") | ||
VStack(spacing: Space.s3) { | ||
Spacer(minLength: Space.s4) | ||
Image("mark").renderingMode(.template).resizable().scaledToFit() | ||
.frame(width: 112) | ||
.foregroundStyle(.primary) | ||
.accessibilityLabel("knotless") | ||
Spacer(minLength: Space.s4) | ||
| ||
TextField("alice.bsky.social", text: $handle) | ||
.textContentType(.username) | ||
.textInputAutocapitalization(.never) | ||
.autocorrectionDisabled() | ||
.keyboardType(.URL) | ||
.submitLabel(.go) | ||
.focused($typing) | ||
.onSubmit(go) | ||
.padding(.horizontal, Space.s5) | ||
.frame(minHeight: Size.signIn) | ||
.background(Color(.secondarySystemBackground), in: Capsule()) | ||
.accessibilityLabel("Your handle") | ||
| ||
Button(action: go) { | ||
Group { | ||
if busy { | ||
ProgressView().tint(Color(.systemBackground)) | ||
} else { | ||
Text("Sign in").font(.body.weight(.semibold)) | ||
} | ||
} | ||
.foregroundStyle(ready || busy ? Color(.systemBackground) : Color.secondary) | ||
.frame(maxWidth: .infinity, minHeight: Size.signIn) | ||
.background(ready || busy ? Color.primary : Color(.tertiarySystemFill), in: Capsule()) | ||
.contentShape(Capsule()) | ||
} | ||
if let failure = account.failure { | ||
Section { Text(failure).foregroundStyle(Color.danger) } | ||
.buttonStyle(.plain) | ||
.disabled(!ready) | ||
| ||
Group { | ||
if let failure = account.failure { | ||
Text(failure).foregroundStyle(Color.danger) | ||
} else { | ||
Text(why).foregroundStyle(.secondary) | ||
} | ||
} | ||
.font(.footnote) | ||
.multilineTextAlignment(.center) | ||
| ||
Text(Self.terms) | ||
.font(.footnote) | ||
.multilineTextAlignment(.center) | ||
.tint(Color.accent) | ||
.padding(.top, Space.s5) | ||
.padding(.bottom, Space.s4) | ||
} | ||
.padding(.horizontal, Space.s6) | ||
.frame(maxWidth: .infinity, maxHeight: .infinity) | ||
.contentShape(Rectangle()) | ||
.onTapGesture { typing = false } | ||
} | ||
| ||
private func go(create: Bool) { | ||
private static let terms: AttributedString = { | ||
let terms = Client.site.appendingPathComponent("terms").absoluteString | ||
let privacy = Client.site.appendingPathComponent("privacy").absoluteString | ||
let line = "By signing in you accept our [Terms of use](\(terms)) and [Privacy policy](\(privacy))." | ||
return (try? AttributedString(markdown: line)) ?? AttributedString(line) | ||
}() | ||
| ||
private func go() { | ||
guard ready else { return } | ||
typing = false | ||
busy = true | ||
Task { | ||
await account.signIn(handle: handle.trimmingCharacters(in: .whitespaces), create: create) | ||
await account.signIn(handle: name) | ||
busy = false | ||
} | ||
} | ||
ios/Knotless/Assets.xcassets/mark.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"mark.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/mark.imageset/mark.svgnew+1−0
| @@ -0,0 +1 @@ | ||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="150 385 724 254"><path d="M204.8 542.7 L211.2 536.4 L217.6 530.1 L224.0 523.8 L230.4 517.6 L236.8 511.5 L243.2 505.6 L249.6 499.7 L256.0 494.1 L262.4 488.6 L268.8 483.3 L275.2 478.3 L281.6 473.5 L288.0 469.0 L294.4 464.7 L300.8 460.7 L307.2 457.1 L313.6 453.8 L320.0 450.8 L326.4 448.1 L332.8 445.8 L339.2 443.9 L345.6 442.3 L352.0 441.1 L358.4 440.3 L364.8 439.9 L371.2 439.8 L377.6 440.1 L384.0 440.7 L390.4 441.7 L396.8 443.1 L403.2 444.8 L409.6 446.9 L416.0 449.2 L422.4 451.9 L428.8 454.9 L435.2 458.1 L441.6 461.7 L448.0 465.4 L454.4 469.4 L460.8 473.6 L467.2 478.0 L473.6 482.5 L480.0 487.2 L486.4 492.0 L492.8 496.9 L499.2 501.9 L505.6 506.9 L512.0 512.0 L518.4 517.1 L524.8 522.1 L531.2 527.1 L537.6 532.0 L544.0 536.8 L550.4 541.5 L556.8 546.0 L563.2 550.4 L569.6 554.6 L576.0 558.6 L582.4 562.3 L588.8 565.9 L595.2 569.1 L601.6 572.1 L608.0 574.8 L614.4 577.1 L620.8 579.2 L627.2 580.9 L633.6 582.3 L640.0 583.3 L646.4 583.9 L652.8 584.2 L659.2 584.1 L665.6 583.7 L672.0 582.9 L678.4 581.7 L684.8 580.1 L691.2 578.2 L697.6 575.9 L704.0 573.2 L710.4 570.2 L716.8 566.9 L723.2 563.3 L729.6 559.3 L736.0 555.0 L742.4 550.5 L748.8 545.7 L755.2 540.7 L761.6 535.4 L768.0 529.9 L774.4 524.3 L780.8 518.4 L787.2 512.5 L793.6 506.4 L800.0 500.2 L806.4 493.9 L812.8 487.6 L819.2 481.3" fill="none" stroke="#000" stroke-width="76.8" stroke-linecap="round" stroke-linejoin="round"/></svg> | ||
ios/Knotless/Support/Theme.swift+2−0
| @@ -73,6 +73,8 @@ enum Size { | ||
/// The least height of a row that opens a screen. | ||
static let row: CGFloat = 56 | ||
static let fileHeader: CGFloat = 52 | ||
/// The sign-in screen's field and button. | ||
static let signIn: CGFloat = 52 | ||
/// Room under a page for the button floating over its end. | ||
static let floatingClearance: CGFloat = 60 | ||
/// Where a row's text starts, past its padding and a regular avatar or | ||
ios/Knotless/Views/HomeView.swift+1−1
| @@ -30,7 +30,7 @@ struct HomeView: View { | ||
} else if account.checking { | ||
ProgressView() | ||
} else { | ||
SignInForm(why: "Sign in to see your work.").navigationTitle("Home") | ||
SignInForm(why: "Sign in to see your work.") | ||
} | ||
} | ||
} | ||
ios/Knotless/Views/InboxView.swift+6−2
| @@ -17,16 +17,20 @@ struct InboxView: View { | ||
| ||
var body: some View { | ||
VStack(spacing: 0) { | ||
if case .signedOut = phase {} else { | ||
if !signedOut { | ||
chips | ||
Divider() | ||
} | ||
content.frame(maxHeight: .infinity) | ||
} | ||
.navigationTitle("Inbox") | ||
.navigationTitle(signedOut ? "" : "Inbox") | ||
.task(id: ReloadKey(filter: filter, did: account.person?.did)) { await reload() } | ||
} | ||
| ||
private var signedOut: Bool { | ||
if case .signedOut = phase { true } else { false } | ||
} | ||
| ||
private var chips: some View { | ||
ScrollView(.horizontal, showsIndicators: false) { | ||
HStack(spacing: Space.s2) { | ||
ios/Knotless/Views/ProfileView.swift+1−1
| @@ -19,7 +19,7 @@ struct OwnProfileView: View { | ||
} else if account.checking { | ||
ProgressView() | ||
} else { | ||
SignInForm(why: "Sign in to see your profile.").navigationTitle("Profile") | ||
SignInForm(why: "Sign in to see your profile.") | ||
} | ||
} | ||
} | ||