feat(collections): add collections and documents browsing
macOS: expandable/collapsible sidebar tree (collections.list, with nested documents.list per expanded collection) driving a detail pane. iOS: flat collection list pushing to a document list. Both share row views and per-platform ContentView selectors, matching the AuthView pattern.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Flat document list nested under an expanded collection in the sidebar tree.
|
||||
/// Documents can themselves have children (`parentDocumentId`) in Outline — not
|
||||
/// represented here yet, this is a first pass at the collection-level expansion.
|
||||
struct CollectionDocumentsOutline: View {
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
let onSelectDocument: (OutlineDocument) -> Void
|
||||
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection, onSelectDocument: @escaping (OutlineDocument) -> Void) {
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
self.onSelectDocument = onSelectDocument
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.documents.isEmpty {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.padding(.vertical, 6)
|
||||
} else if viewModel.documents.isEmpty {
|
||||
Text("No documents")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
} else {
|
||||
ForEach(viewModel.documents) { document in
|
||||
Button {
|
||||
onSelectDocument(document)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.body)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionListContent: View {
|
||||
var viewModel: CollectionsViewModel
|
||||
let onSelect: (OutlineCollection) -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.collections.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage, viewModel.collections.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Collections", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
} else if viewModel.collections.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Collections",
|
||||
systemImage: "folder",
|
||||
description: Text("Collections you have access to will appear here.")
|
||||
)
|
||||
} else {
|
||||
List(viewModel.collections) { collection in
|
||||
Button {
|
||||
onSelect(collection)
|
||||
} label: {
|
||||
CollectionRowView(collection: collection)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionRowView: View {
|
||||
let collection: OutlineCollection
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(collection.name)
|
||||
} icon: {
|
||||
if let emoji = collection.emojiIcon {
|
||||
Text(emoji)
|
||||
} else {
|
||||
Image(systemName: "folder.fill")
|
||||
.foregroundStyle(tintColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var tintColor: Color {
|
||||
collection.color.flatMap { Color(hex: $0) } ?? .accentColor
|
||||
}
|
||||
}
|
||||
|
||||
private extension OutlineCollection {
|
||||
/// Outline's `icon` field holds either a literal emoji (when a user picked one)
|
||||
/// or an internal icon-set name (e.g. from their built-in icon picker) — only
|
||||
/// the former is renderable here without a name-to-glyph mapping table.
|
||||
var emojiIcon: String? {
|
||||
// `isEmojiPresentation` misses symbol-class emoji that default to text
|
||||
// presentation (e.g. some picked without a variation selector) — `isEmoji`
|
||||
// is the broader, correct check for "this scalar can be an emoji at all".
|
||||
// Plain ASCII digits/`#`/`*` also report `isEmoji == true` (they're valid
|
||||
// keycap-sequence bases), so exclude the ASCII range to avoid treating a
|
||||
// literal digit or punctuation icon name as an emoji.
|
||||
guard let icon, icon.unicodeScalars.contains(where: { $0.properties.isEmoji && $0.value > 0x7F }) else {
|
||||
return nil
|
||||
}
|
||||
return icon
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionTreeRow: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let collection: OutlineCollection
|
||||
let isExpanded: Bool
|
||||
let isSelected: Bool
|
||||
let onToggle: () -> Void
|
||||
let onSelectDocument: (OutlineDocument) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button(action: onToggle) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.frame(width: 12)
|
||||
|
||||
CollectionRowView(collection: collection)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 6)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
||||
|
||||
if isExpanded {
|
||||
CollectionDocumentsOutline(
|
||||
apiClient: apiClient,
|
||||
collection: collection,
|
||||
onSelectDocument: onSelectDocument
|
||||
)
|
||||
.padding(.leading, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,78 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionsTreeView: View {
|
||||
@State private var viewModel: CollectionsViewModel
|
||||
@Binding private var selectedCollection: OutlineCollection?
|
||||
@State private var expandedCollectionIDs: Set<String> = []
|
||||
let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void
|
||||
|
||||
init(
|
||||
apiClient: OutlineAPIClient,
|
||||
selectedCollection: Binding<OutlineCollection?>,
|
||||
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
||||
_selectedCollection = selectedCollection
|
||||
self.onSelectDocument = onSelectDocument
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.collections.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage, viewModel.collections.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Collections", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
} else if viewModel.collections.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Collections",
|
||||
systemImage: "folder",
|
||||
description: Text("Collections you have access to will appear here.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(viewModel.collections) { collection in
|
||||
CollectionTreeRow(
|
||||
apiClient: viewModel.apiClient,
|
||||
collection: collection,
|
||||
isExpanded: expandedCollectionIDs.contains(collection.id),
|
||||
isSelected: selectedCollection?.id == collection.id,
|
||||
onToggle: { toggle(collection) },
|
||||
onSelectDocument: { document in onSelectDocument(collection, document) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
// Stand-in for Outline's own configured "Start view" — we don't have
|
||||
// a confirmed schema for `team.preferences` to read the actual
|
||||
// setting, so this defaults to the first collection instead of
|
||||
// landing on an empty "No Collection Selected" placeholder.
|
||||
if selectedCollection == nil, let first = viewModel.collections.first {
|
||||
selectedCollection = first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggle(_ collection: OutlineCollection) {
|
||||
selectedCollection = collection
|
||||
if expandedCollectionIDs.contains(collection.id) {
|
||||
expandedCollectionIDs.remove(collection.id)
|
||||
} else {
|
||||
expandedCollectionIDs.insert(collection.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OutlineKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CollectionsViewModel {
|
||||
private(set) var collections: [OutlineCollection] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient) {
|
||||
self.apiClient = apiClient
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
collections = try await apiClient.listCollections(offset: 0, limit: 100)
|
||||
} catch {
|
||||
errorMessage = "Couldn't load collections. Check your connection and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct ContentView_iOS: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let apiClient = session.apiClient {
|
||||
CollectionsScreen(apiClient: apiClient)
|
||||
} else {
|
||||
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||
}
|
||||
}
|
||||
.navigationTitle(session.teamName ?? "Outpost")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollectionsScreen: View {
|
||||
@State private var viewModel: CollectionsViewModel
|
||||
@State private var pushedCollection: OutlineCollection?
|
||||
private let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient) {
|
||||
self.apiClient = apiClient
|
||||
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
CollectionListContent(viewModel: viewModel) { collection in
|
||||
pushedCollection = collection
|
||||
}
|
||||
.navigationDestination(item: $pushedCollection) { collection in
|
||||
DocumentsPane(apiClient: apiClient, collection: collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,149 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct ContentView_macOS: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@State private var selectedCollection: OutlineCollection?
|
||||
@State private var documentPath: [OutlineDocument] = []
|
||||
@State private var globalSearchQuery = ""
|
||||
@State private var contextualSearchQuery = ""
|
||||
|
||||
private var trimmedGlobalQuery: String {
|
||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
VStack(spacing: 0) {
|
||||
SidebarSearchField(text: $globalSearchQuery)
|
||||
Divider()
|
||||
sidebar
|
||||
AccountFooter()
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
.navigationTitle(session.teamName ?? "Outpost")
|
||||
.toolbar {
|
||||
// Per HIG: "sidebar-toggle/back controls appear at the far leading
|
||||
// edge, followed by the view title" — this is that title, not
|
||||
// centered. It doesn't collide with the back button because they're
|
||||
// mutually exclusive: whenever a document's pushed (back button
|
||||
// visible), this shows the document's own title instead of falling
|
||||
// back to the workspace badge.
|
||||
ToolbarItem(placement: .navigation) {
|
||||
leadingToolbarContent
|
||||
}
|
||||
}
|
||||
// Attached at this level (not inside the detail's NavigationStack) so
|
||||
// it reliably renders in the shared window toolbar instead of a
|
||||
// per-screen one that can get lost when nested more deeply.
|
||||
.searchable(
|
||||
text: $contextualSearchQuery,
|
||||
placement: .toolbar,
|
||||
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var leadingToolbarContent: some View {
|
||||
Group {
|
||||
if !trimmedGlobalQuery.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Text("Search")
|
||||
}
|
||||
} else if let currentDocument = documentPath.last, let selectedCollection {
|
||||
// Back button (system-provided) → collection → document, so the
|
||||
// hierarchy reads left to right instead of just the leaf title.
|
||||
HStack(spacing: 6) {
|
||||
CollectionRowView(collection: selectedCollection)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
if let emoji = currentDocument.emoji {
|
||||
Text(emoji)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
}
|
||||
Text(currentDocument.title.isEmpty ? "Untitled" : currentDocument.title)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else if let selectedCollection {
|
||||
CollectionRowView(collection: selectedCollection)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill")
|
||||
Text(session.teamName ?? "Outpost")
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sidebar: some View {
|
||||
if let apiClient = session.apiClient {
|
||||
CollectionsTreeView(
|
||||
apiClient: apiClient,
|
||||
selectedCollection: $selectedCollection,
|
||||
onSelectDocument: selectDocument
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||
}
|
||||
}
|
||||
|
||||
/// `documentPath = [document]` (replacing a same-size array in one step)
|
||||
/// unreliably updates `NavigationStack` on macOS — the stack can silently
|
||||
/// keep showing the previous document. Popping to root and pushing again
|
||||
/// as two separate state updates (so SwiftUI processes them as two render
|
||||
/// passes) is the reliable version of the same operation.
|
||||
private func selectDocument(_ collection: OutlineCollection, _ document: OutlineDocument) {
|
||||
selectedCollection = collection
|
||||
documentPath = []
|
||||
Task { @MainActor in
|
||||
documentPath = [document]
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
if let apiClient = session.apiClient {
|
||||
// A fresh NavigationStack per collection (or when entering/leaving
|
||||
// search), so switching either also clears any pushed document.
|
||||
NavigationStack(path: $documentPath) {
|
||||
Group {
|
||||
if !trimmedGlobalQuery.isEmpty {
|
||||
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery)
|
||||
} else if let selectedCollection {
|
||||
CollectionOverviewView(
|
||||
apiClient: apiClient,
|
||||
collection: selectedCollection,
|
||||
searchQuery: $contextualSearchQuery
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Collection Selected",
|
||||
systemImage: "sidebar.left",
|
||||
description: Text("Choose a collection to see its documents.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: OutlineDocument.self) { document in
|
||||
DocumentReaderView(apiClient: apiClient, document: document)
|
||||
}
|
||||
}
|
||||
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
|
||||
} else {
|
||||
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct DocumentListContent: View {
|
||||
var viewModel: DocumentsViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.documents.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage, viewModel.documents.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
} else if viewModel.documents.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Documents",
|
||||
systemImage: "doc.text",
|
||||
description: Text("Documents in \(viewModel.collection.name) will appear here.")
|
||||
)
|
||||
} else {
|
||||
List(viewModel.documents) { document in
|
||||
DocumentRowView(document: document)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.collection.name)
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct DocumentRowView: View {
|
||||
let document: OutlineDocument
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
}
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Holds `DocumentsViewModel` in `@State` so callers that construct this from a
|
||||
/// computed/closure context (`NavigationSplitView.detail`, `navigationDestination`)
|
||||
/// don't rebuild — and reset — it on every unrelated re-render.
|
||||
struct DocumentsPane: View {
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DocumentListContent(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OutlineKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DocumentsViewModel {
|
||||
private(set) var documents: [OutlineDocument] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
let collection: OutlineCollection
|
||||
private let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
|
||||
self.apiClient = apiClient
|
||||
self.collection = collection
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
documents = try await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100)
|
||||
} catch {
|
||||
errorMessage = "Couldn't load documents. Check your connection and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init?(hex: String) {
|
||||
var sanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
sanitized.removeAll { $0 == "#" }
|
||||
guard sanitized.count == 6, let value = UInt32(sanitized, radix: 16) else { return nil }
|
||||
self.init(
|
||||
red: Double((value >> 16) & 0xFF) / 255,
|
||||
green: Double((value >> 8) & 0xFF) / 255,
|
||||
blue: Double(value & 0xFF) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user