Implement persistence layer
This commit is contained in:
parent
76efb1af72
commit
2411a220fe
12 changed files with 303 additions and 42 deletions
|
|
@ -10,6 +10,7 @@ let package = Package(
|
||||||
.package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"),
|
||||||
.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
|
||||||
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
|
|
@ -17,6 +18,7 @@ let package = Package(
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
|
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
|
||||||
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
|
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
|
||||||
|
.product(name: "SQLite", package: "SQLite.swift"),
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")
|
.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,24 @@ struct Luminate: App {
|
||||||
let app = AdwaitaApp(id: "dev.bscubed.Luminate")
|
let app = AdwaitaApp(id: "dev.bscubed.Luminate")
|
||||||
@State private var client: JellyfinClient?
|
@State private var client: JellyfinClient?
|
||||||
@State private var userId = ""
|
@State private var userId = ""
|
||||||
|
@State private var isLaunchLoading = true
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) {
|
||||||
|
DIContainer.shared.register(\.persistence, value: store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var scene: Scene {
|
var scene: Scene {
|
||||||
Window(id: "main") { window in
|
Window(id: "main") { window in
|
||||||
if let client {
|
if isLaunchLoading {
|
||||||
|
VStack {
|
||||||
|
Spinner()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
loadSavedSession()
|
||||||
|
}
|
||||||
|
} else if let client {
|
||||||
ContentView(
|
ContentView(
|
||||||
client: client,
|
client: client,
|
||||||
userId: userId
|
userId: userId
|
||||||
|
|
@ -24,12 +38,13 @@ struct Luminate: App {
|
||||||
DIContainer.shared.register(\.client, value: client)
|
DIContainer.shared.register(\.client, value: client)
|
||||||
DIContainer.shared.register(\.userId, value: id)
|
DIContainer.shared.register(\.userId, value: id)
|
||||||
DIContainer.shared.register(\.imageService, value: ImageService())
|
DIContainer.shared.register(\.imageService, value: ImageService())
|
||||||
|
|
||||||
self.client = client
|
self.client = client
|
||||||
self.userId = id
|
self.userId = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1100, height: 700)
|
.defaultSize(width: 1280, height: 800)
|
||||||
.quitShortcut()
|
.quitShortcut()
|
||||||
.closeShortcut()
|
.closeShortcut()
|
||||||
.keyboardShortcut("f".ctrl()) { _ in
|
.keyboardShortcut("f".ctrl()) { _ in
|
||||||
|
|
@ -37,6 +52,31 @@ struct Luminate: App {
|
||||||
.keyboardShortcut("r".ctrl()) { _ in
|
.keyboardShortcut("r".ctrl()) { _ in
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadSavedSession() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
defer { isLaunchLoading = false }
|
||||||
|
|
||||||
|
let store = try SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL())
|
||||||
|
let auth = try await store.loadAuth()
|
||||||
|
guard let serverURL = URL(string: auth.serverURL) else { return }
|
||||||
|
|
||||||
|
let client = JellyfinClient(serverURL: serverURL, token: auth.token, userId: auth.userId)
|
||||||
|
let isValid = await client.validateToken()
|
||||||
|
guard isValid else { return }
|
||||||
|
|
||||||
|
DIContainer.shared.register(\.client, value: client)
|
||||||
|
DIContainer.shared.register(\.userId, value: auth.userId)
|
||||||
|
DIContainer.shared.register(\.imageService, value: ImageService())
|
||||||
|
|
||||||
|
self.client = client
|
||||||
|
self.userId = auth.userId
|
||||||
|
} catch {
|
||||||
|
isLaunchLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
|
@ -59,20 +99,7 @@ struct ContentView: View {
|
||||||
} else {
|
} else {
|
||||||
HomeView()
|
HomeView()
|
||||||
.topToolbar {
|
.topToolbar {
|
||||||
HeaderBar.end {
|
ToolbarView()
|
||||||
Menu(icon: .default(icon: .openMenu)) {
|
|
||||||
MenuButton("Search", window: false) {
|
|
||||||
|
|
||||||
}
|
|
||||||
MenuSection {
|
|
||||||
MenuButton("About", window: false) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.primary()
|
|
||||||
.tooltip("Main Menu")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,14 @@ public struct ServerSetupView: View {
|
||||||
let result = try await client.authenticate(username: username, password: password)
|
let result = try await client.authenticate(username: username, password: password)
|
||||||
let userId = result.user?.value1.id ?? ""
|
let userId = result.user?.value1.id ?? ""
|
||||||
|
|
||||||
|
let auth = AuthData(
|
||||||
|
serverURL: url.absoluteString,
|
||||||
|
token: result.accessToken ?? "",
|
||||||
|
userId: userId,
|
||||||
|
username: username
|
||||||
|
)
|
||||||
|
try? await DIContainer.shared.values.persistence?.saveAuth(auth)
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
onLogin(client, userId)
|
onLogin(client, userId)
|
||||||
} catch (JellyfinError.httpError(let code)) {
|
} catch (JellyfinError.httpError(let code)) {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ struct ToolbarView: View {
|
||||||
|
|
||||||
@State private var about = false
|
@State private var about = false
|
||||||
@State private var shortcuts = false
|
@State private var shortcuts = false
|
||||||
var app: AdwaitaApp
|
|
||||||
var window: AdwaitaWindow
|
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
HeaderBar.end {
|
HeaderBar.end {
|
||||||
|
|
@ -28,14 +26,13 @@ struct ToolbarView: View {
|
||||||
.shortcutsItem(Loc.keyboardShortcuts, accelerator: "question".ctrl())
|
.shortcutsItem(Loc.keyboardShortcuts, accelerator: "question".ctrl())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.titleWidget {
|
||||||
|
WindowTitle(subtitle: "", title: "Luminate")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var content: AnyView {
|
var content: AnyView {
|
||||||
Menu(icon: .default(icon: .openMenu)) {
|
Menu(icon: .default(icon: .openMenu)) {
|
||||||
MenuButton(Loc.newWindow, window: false) {
|
|
||||||
app.addWindow("main")
|
|
||||||
}
|
|
||||||
.keyboardShortcut("n".ctrl())
|
|
||||||
MenuSection {
|
MenuSection {
|
||||||
MenuButton(Loc.keyboardShortcuts, window: false) {
|
MenuButton(Loc.keyboardShortcuts, window: false) {
|
||||||
shortcuts = true
|
shortcuts = true
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ public struct InjectionValues {
|
||||||
public var userId: String?
|
public var userId: String?
|
||||||
public var imageService: ImageService?
|
public var imageService: ImageService?
|
||||||
public var webSocketClient: WebSocketClient?
|
public var webSocketClient: WebSocketClient?
|
||||||
|
public var persistence: PersistenceService?
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,29 @@ public actor JellyfinClient {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(serverURL: URL, token: String, userId: String) {
|
||||||
|
self.serverURL = serverURL
|
||||||
|
self.token = token
|
||||||
|
self.userId = userId
|
||||||
|
self.clientConfig = Configuration(dateTranscoder: JellyfinDateTranscoder())
|
||||||
|
self.client = Client(
|
||||||
|
serverURL: serverURL,
|
||||||
|
configuration: clientConfig,
|
||||||
|
transport: URLSessionTransport(),
|
||||||
|
middlewares: [AuthMiddleware(token: token)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validateToken() async -> Bool {
|
||||||
|
guard let userId else { return false }
|
||||||
|
do {
|
||||||
|
_ = try await getUserViews(userId: userId)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeClient(token: String) -> Client {
|
private func makeClient(token: String) -> Client {
|
||||||
Client(
|
Client(
|
||||||
serverURL: serverURL,
|
serverURL: serverURL,
|
||||||
|
|
|
||||||
35
Sources/LuminateCore/PersistenceService+Mock.swift
Normal file
35
Sources/LuminateCore/PersistenceService+Mock.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public final class MockPersistenceService: @unchecked Sendable, PersistenceService {
|
||||||
|
|
||||||
|
private var authData: AuthData?
|
||||||
|
private var preferences: [String: String] = [:]
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func loadAuth() async throws -> AuthData {
|
||||||
|
guard let authData else { throw PersistenceError.notFound }
|
||||||
|
return authData
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveAuth(_ auth: AuthData) async throws {
|
||||||
|
authData = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearAuth() async throws {
|
||||||
|
authData = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getPreference(key: String) async throws -> String? {
|
||||||
|
preferences[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setPreference(key: String, value: String) async throws {
|
||||||
|
preferences[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearAll() async throws {
|
||||||
|
authData = nil
|
||||||
|
preferences = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
33
Sources/LuminateCore/PersistenceService.swift
Normal file
33
Sources/LuminateCore/PersistenceService.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AuthData: Codable, Sendable {
|
||||||
|
public let serverURL: String
|
||||||
|
public let token: String
|
||||||
|
public let userId: String
|
||||||
|
public let username: String
|
||||||
|
|
||||||
|
public init(serverURL: String, token: String, userId: String, username: String) {
|
||||||
|
self.serverURL = serverURL
|
||||||
|
self.token = token
|
||||||
|
self.userId = userId
|
||||||
|
self.username = username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PersistenceError: Error {
|
||||||
|
case migrationFailed(Error)
|
||||||
|
case ioFailed(Error)
|
||||||
|
case encodingFailed
|
||||||
|
case decodingFailed
|
||||||
|
case notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol PersistenceService: Sendable {
|
||||||
|
func loadAuth() async throws -> AuthData
|
||||||
|
func saveAuth(_ auth: AuthData) async throws
|
||||||
|
func clearAuth() async throws
|
||||||
|
|
||||||
|
func getPreference(key: String) async throws -> String?
|
||||||
|
func setPreference(key: String, value: String) async throws
|
||||||
|
func clearAll() async throws
|
||||||
|
}
|
||||||
127
Sources/LuminateCore/SQLiteStore.swift
Normal file
127
Sources/LuminateCore/SQLiteStore.swift
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import Foundation
|
||||||
|
import SQLite
|
||||||
|
|
||||||
|
extension Connection: @retroactive @unchecked Sendable {}
|
||||||
|
|
||||||
|
public actor SQLiteStore: PersistenceService {
|
||||||
|
|
||||||
|
private let db: Connection
|
||||||
|
|
||||||
|
public init(dbURL: URL) throws {
|
||||||
|
let directory = dbURL.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
db = try Connection(dbURL.path)
|
||||||
|
db.busyTimeout = 5
|
||||||
|
try db.execute("PRAGMA journal_mode = WAL")
|
||||||
|
try migrate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrate() throws {
|
||||||
|
let version = db.userVersion
|
||||||
|
switch version {
|
||||||
|
case 0:
|
||||||
|
try db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS auth (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
server_url TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS preferences (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
db.userVersion = 1
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func defaultDatabaseURL() -> URL {
|
||||||
|
if #available(macOS 13, *) {
|
||||||
|
let appSupport = FileManager.default.urls(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask
|
||||||
|
).first!
|
||||||
|
return appSupport
|
||||||
|
.appendingPathComponent("dev.bscubed.Luminate")
|
||||||
|
.appendingPathComponent("db.sqlite")
|
||||||
|
} else {
|
||||||
|
#if os(Linux)
|
||||||
|
let xdgData = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]
|
||||||
|
?? "\(FileManager.default.homeDirectoryForCurrentUser.path).local/share"
|
||||||
|
return URL(fileURLWithPath: xdgData)
|
||||||
|
.appendingPathComponent("dev.bscubed.Luminate")
|
||||||
|
.appendingPathComponent("db.sqlite")
|
||||||
|
#else
|
||||||
|
let appSupport = FileManager.default.urls(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask
|
||||||
|
).first!
|
||||||
|
return appSupport
|
||||||
|
.appendingPathComponent("dev.bscubed.Luminate")
|
||||||
|
.appendingPathComponent("db.sqlite")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadAuth() async throws -> AuthData {
|
||||||
|
let stmt = try db.prepare("SELECT server_url, token, user_id, username FROM auth WHERE id = 1")
|
||||||
|
for row in stmt {
|
||||||
|
guard let serverURL = row[0] as? String,
|
||||||
|
let token = row[1] as? String,
|
||||||
|
let userId = row[2] as? String,
|
||||||
|
let username = row[3] as? String
|
||||||
|
else {
|
||||||
|
throw PersistenceError.decodingFailed
|
||||||
|
}
|
||||||
|
return AuthData(
|
||||||
|
serverURL: serverURL,
|
||||||
|
token: token,
|
||||||
|
userId: userId,
|
||||||
|
username: username
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw PersistenceError.notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveAuth(_ auth: AuthData) async throws {
|
||||||
|
try db.run(
|
||||||
|
"INSERT OR REPLACE INTO auth (id, server_url, token, user_id, username) VALUES (1, ?, ?, ?, ?)",
|
||||||
|
auth.serverURL, auth.token, auth.userId, auth.username
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearAuth() async throws {
|
||||||
|
try db.run("DELETE FROM auth WHERE id = 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getPreference(key: String) async throws -> String? {
|
||||||
|
let stmt = try db.prepare("SELECT value FROM preferences WHERE key = ?", key)
|
||||||
|
for row in stmt {
|
||||||
|
return row[0] as? String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setPreference(key: String, value: String) async throws {
|
||||||
|
try db.run(
|
||||||
|
"INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)",
|
||||||
|
key, value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearAll() async throws {
|
||||||
|
try db.run("DELETE FROM auth")
|
||||||
|
try db.run("DELETE FROM preferences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,19 +25,25 @@ struct HomePosterCell: View {
|
||||||
.card()
|
.card()
|
||||||
}
|
}
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
if let title = item.name, !title.isEmpty {
|
||||||
Text(item.name ?? "")
|
Text(item.name ?? "")
|
||||||
.ellipsize()
|
.ellipsize()
|
||||||
.heading()
|
.heading()
|
||||||
.halign(.center)
|
.halign(.center)
|
||||||
.frame(maxWidth: 200)
|
.frame(maxWidth: 200)
|
||||||
Text(item.yearString)
|
}
|
||||||
|
let subtitle = item.yearString
|
||||||
|
if !subtitle.isEmpty {
|
||||||
|
Text(subtitle)
|
||||||
.ellipsize()
|
.ellipsize()
|
||||||
.caption()
|
.caption()
|
||||||
.dimLabel()
|
.dimLabel()
|
||||||
.halign(.center)
|
.halign(.center)
|
||||||
.frame(maxWidth: 200)
|
.frame(maxWidth: 200)
|
||||||
}
|
}
|
||||||
.padding(4)
|
}
|
||||||
|
.padding(6, .vertical)
|
||||||
|
.padding(12, .horizontal)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadImage()
|
loadImage()
|
||||||
|
|
@ -48,13 +54,13 @@ struct HomePosterCell: View {
|
||||||
|
|
||||||
private func loadImage() {
|
private func loadImage() {
|
||||||
guard let tag = item.primaryImageTag,
|
guard let tag = item.primaryImageTag,
|
||||||
let itemId = item.id else { return }
|
let itemId = item.seriesId ?? item.id else { return }
|
||||||
Task {
|
Task {
|
||||||
guard let url = await client.imageURL(
|
guard let url = await client.imageURL(
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
imageType: .primary,
|
imageType: .primary,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
maxWidth: 300
|
maxWidth: 400
|
||||||
) else { return }
|
) else { return }
|
||||||
let service = ImageService()
|
let service = ImageService()
|
||||||
imageData = try? await service.loadImage(url: url)
|
imageData = try? await service.loadImage(url: url)
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,14 @@ public struct HomeView: View {
|
||||||
title: "Continue Watching",
|
title: "Continue Watching",
|
||||||
items: resumeItems
|
items: resumeItems
|
||||||
)
|
)
|
||||||
.padding(16, .bottom)
|
.padding(32, .bottom)
|
||||||
}
|
}
|
||||||
if !nextUpItems.isEmpty {
|
if !nextUpItems.isEmpty {
|
||||||
MediaRow(
|
MediaRow(
|
||||||
title: "Next Up",
|
title: "Next Up",
|
||||||
items: nextUpItems
|
items: nextUpItems
|
||||||
)
|
)
|
||||||
.padding(16, .bottom)
|
.padding(32, .bottom)
|
||||||
}
|
}
|
||||||
if !latestItems.isEmpty {
|
if !latestItems.isEmpty {
|
||||||
MediaRow(
|
MediaRow(
|
||||||
|
|
@ -49,18 +49,21 @@ public struct HomeView: View {
|
||||||
items: latestItems,
|
items: latestItems,
|
||||||
onSeeAll: {}
|
onSeeAll: {}
|
||||||
)
|
)
|
||||||
.padding(16, .bottom)
|
.padding(32, .bottom)
|
||||||
}
|
}
|
||||||
LibraryGrid(
|
LibraryGrid(
|
||||||
libraries: libraries
|
libraries: libraries
|
||||||
)
|
)
|
||||||
|
.padding(32, .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8, .horizontal)
|
.padding(8, .horizontal)
|
||||||
|
.padding(32, .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hscrollbarPolicy(.never)
|
.hscrollbarPolicy(.never)
|
||||||
.propagateNaturalHeight()
|
.propagateNaturalHeight()
|
||||||
|
.navigationTitle("Luminate")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadHomeData()
|
loadHomeData()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,14 @@ struct MediaRow: View {
|
||||||
var onSeeAll: (() -> Void)?
|
var onSeeAll: (() -> Void)?
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 16) {
|
||||||
HStack {
|
HStack(spacing: 16) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.style("title-3")
|
.style("title-3")
|
||||||
if onSeeAll != nil {
|
if onSeeAll != nil {
|
||||||
Button("See All") {
|
Button("See All") {
|
||||||
onSeeAll?()
|
onSeeAll?()
|
||||||
}
|
}
|
||||||
.style("flat")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue