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(_ 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) } }