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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
216 lines · 8.5 KB Raw History
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)
    }
}