johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
import SwiftUI
/// How the app is drawn on this phone: as the account's pages are, in iOS's own
/// colours, in a theme the pages offer, or in one of the account's own themes.
struct Appearance: RawRepresentable, Hashable, Identifiable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
static let key = "appearance"
static let account = Appearance(rawValue: "account")
static let automatic = Appearance(rawValue: "automatic")
static let light = Appearance(rawValue: "light")
static let dark = Appearance(rawValue: "dark")
/// iOS's own colours.
static let system: [Appearance] = [.automatic, .light, .dark]
/// The themes the pages offer besides light and dark, by the name they give each.
static let named: [Appearance] = ["latte", "mocha", "dracula", "pckt", "plyr", "blacksky"].map(Appearance.init)
/// One of the account's own themes, by its `dev.knotless.theme`.
static func theme(_ uri: String) -> Appearance { Appearance(rawValue: "theme:" + uri) }
var id: String { rawValue }
var themeURI: String? {
rawValue.hasPrefix("theme:") ? String(rawValue.dropFirst("theme:".count)) : nil
}
func label(themes: [OwnTheme]) -> String {
if let uri = themeURI { return themes.first { $0.uri == uri }?.name ?? "A theme of yours" }
switch rawValue {
case "account": return "Same as knotless.dev"
case "latte": return "Catppuccin Latte"
case "mocha": return "Catppuccin Mocha"
case "plyr": return "plyr.fm"
case "pckt": return "pckt"
default: return rawValue.capitalized
}
}
/// What it draws the app in, given the account's own: none follows the system
/// in iOS's colours, as does one of the account's themes it no longer has.
func choice(account: ThemeChoice?, themes: [OwnTheme]) -> ThemeChoice? {
if let uri = themeURI {
return themes.first { $0.uri == uri }.map { ThemeChoice(day: ThemeHalf(theme: "custom", palette: $0.palette)) }
}
switch self {
case .account: return account
case .automatic: return nil
default: return ThemeChoice(day: ThemeHalf(theme: rawValue))
}
}
/// Its colours, for its swatch: none for those that follow the system.
func palette(themes: [OwnTheme]) -> Palette? {
if let uri = themeURI { return themes.first { $0.uri == uri }.map { Palette(record: $0.palette) } }
switch self {
case .light: return .light
case .dark: return .dark
default: return Palette.named[rawValue]
}
}
}
/// `listThemes` with `actor`: the themes the account published.
private struct ThemeList: Decodable, Sendable {
struct Maker: Decodable, Sendable {
let did: String
let handle: String
}
struct View: Decodable, Sendable {
let uri: String
let creator: Maker?
let name: String
let palette: PaletteRecord
let createdAt: String?
}
let themes: [View]
}
/// The theme the account's pages are drawn in, and its own themes, as it last
/// said: kept for the app to start in them.
@MainActor @Observable
final class AccountTheme {
static let shared = AccountTheme()
private static let key = "account-theme"
private static let themesKey = "account-themes"
private(set) var choice: ThemeChoice? = AccountTheme.kept(AccountTheme.key)
private(set) var themes: [OwnTheme] = AccountTheme.kept(AccountTheme.themesKey) ?? []
private static func kept<T: Decodable>(_ key: String) -> T? {
UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(T.self, from: $0) }
}
private static func keep(_ value: some Encodable, _ key: String) {
UserDefaults.standard.set(try? JSONEncoder().encode(value), forKey: key)
}
func hold(_ choice: ThemeChoice?, themes: [OwnTheme] = []) {
self.choice = choice
self.themes = themes
Self.keep(choice, Self.key)
Self.keep(themes, Self.themesKey)
}
func load(as did: String) async {
guard let answer: Preferences = try? await Client.shared.query("getPreferences") else { return }
let made: ThemeList? = try? await Client.shared.query("listThemes", ["actor": did, "limit": "100"])
let mine = (made?.themes ?? []).map {
OwnTheme(uri: $0.uri, name: $0.name, palette: $0.palette, maker: $0.creator?.did == did ? nil : $0.creator?.handle, at: $0.createdAt)
}
var added = answer.themes.filter { theme in !mine.contains { $0.uri == theme.uri } }
// Who made each, as they're called now: a theme since deleted has none.
if !added.isEmpty, let found: ThemeList = try? await Client.shared.query("listThemes", items: added.map { URLQueryItem(name: "uris", value: $0.uri) }) {
for i in added.indices { added[i].maker = found.themes.first { $0.uri == added[i].uri }?.creator?.handle }
}
let all = (mine + added).sorted { ($0.at ?? "") > ($1.at ?? "") }
hold(answer.appearance, themes: all)
}
}
/// Chooses how the app is drawn, each theme shown in its colours.
struct AppearanceView: View {
@AppStorage(Appearance.key) private var appearance = Appearance.account
private let store = AccountTheme.shared
var body: some View {
List {
Group {
Section {
row(.account, swatch: store.choice?.palette(dark: false))
} footer: {
Text("The theme you chose on knotless.dev, which changes here when you change it there.")
}
Section("iOS") {
ForEach(Appearance.system) { row($0, swatch: $0.palette(themes: [])) }
}
Section("Themes") {
ForEach(Appearance.named) { row($0, swatch: $0.palette(themes: [])) }
}
Section {
ForEach(store.themes) { theme in
let choice = Appearance.theme(theme.uri)
row(choice, swatch: choice.palette(themes: store.themes), maker: theme.maker)
}
} header: {
Text("Your themes")
} footer: {
if store.themes.isEmpty {
Text("Themes you make or add on knotless.dev show here.")
}
}
}
.listRowBackground(Color.card)
}
.navigationTitle("Appearance")
.navigationBarTitleDisplayMode(.inline)
}
private func row(_ choice: Appearance, swatch: Palette?, maker: String? = nil) -> some View {
Button { appearance = choice } label: {
HStack(spacing: Space.s3) {
Swatch(palette: swatch)
VStack(alignment: .leading, spacing: Space.s0_5) {
Text(choice.label(themes: store.themes)).foregroundStyle(Color.fg)
if let maker { Text("By \(maker)").font(.footnote).foregroundStyle(Color.mute) }
}
Spacer()
if appearance == choice {
Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(Color.accent)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(appearance == choice ? .isSelected : [])
}
}
/// A theme in small: its page, its text, and its main button and links. One that
/// follows the system is split between light and dark.
private struct Swatch: View {
let palette: Palette?
var body: some View {
Group {
if let palette {
ZStack(alignment: .bottomLeading) {
Color(uiColor: UIColor(hex: palette.bg))
HStack(spacing: 3) {
dot(palette.fg)
dot(palette.primary)
dot(palette.accent)
}
.padding(5)
}
} else {
HStack(spacing: 0) {
Color(uiColor: UIColor(hex: Palette.light.bg))
Color(uiColor: UIColor(hex: Palette.dark.bg))
}
}
}
.frame(width: 44, height: 30)
.clipShape(RoundedRectangle(cornerRadius: Radius.small))
.overlay(RoundedRectangle(cornerRadius: Radius.small).stroke(Color.line))
}
private func dot(_ hex: UInt32) -> some View {
Circle().fill(Color(uiColor: UIColor(hex: hex))).frame(width: 7, height: 7)
}
}