feat(app): add session store and root view routing
SessionStore owns sign-in state, the persisted server URL, and the live OutlineAPIClient; RootView switches between the auth screen and signed-in content and covers the transition with a splash instead of tearing the login UI down abruptly. Drops the SwiftData Item placeholder from the default Xcode template — unused once real content replaced it.
This commit is contained in:
@@ -1,80 +1,5 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// Outpost
|
|
||||||
//
|
|
||||||
// Created by Puranjay Savar Mattas on 12/08/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@Query private var items: [Item]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationViewWrapper {
|
|
||||||
List {
|
|
||||||
ForEach(items) { item in
|
|
||||||
NavigationLink {
|
|
||||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
|
||||||
} label: {
|
|
||||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete(perform: deleteItems)
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
typealias ContentView = ContentView_macOS
|
||||||
#endif
|
|
||||||
.toolbar {
|
|
||||||
#if os(iOS)
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
EditButton()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
ToolbarItem {
|
|
||||||
Button(action: addItem) {
|
|
||||||
Label("Add Item", systemImage: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addItem() {
|
|
||||||
withAnimation {
|
|
||||||
let newItem = Item(timestamp: Date())
|
|
||||||
modelContext.insert(newItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteItems(offsets: IndexSet) {
|
|
||||||
withAnimation {
|
|
||||||
for index in offsets {
|
|
||||||
modelContext.delete(items[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct NavigationViewWrapper<Content: View>: View {
|
|
||||||
let content: () -> Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
#if os(macOS)
|
|
||||||
NavigationSplitView {
|
|
||||||
content()
|
|
||||||
} detail: {
|
|
||||||
Text("Select an item")
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
content()
|
typealias ContentView = ContentView_iOS
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
.modelContainer(for: Item.self, inMemory: true)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// Item.swift
|
|
||||||
// Outpost
|
|
||||||
//
|
|
||||||
// Created by Puranjay Savar Mattas on 12/08/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class Item {
|
|
||||||
var timestamp: Date
|
|
||||||
|
|
||||||
init(timestamp: Date) {
|
|
||||||
self.timestamp = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@State private var welcomeName: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if session.isSignedIn {
|
||||||
|
ContentView()
|
||||||
|
} else {
|
||||||
|
AuthView(onSigningIn: startWelcomeTransition)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let welcomeName {
|
||||||
|
WelcomeSplashView(name: welcomeName)
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||||
|
.task {
|
||||||
|
await session.refreshTeamInfoIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||||
|
welcomeName = result.user.name
|
||||||
|
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.1))
|
||||||
|
withAnimation(.easeInOut(duration: 0.45)) {
|
||||||
|
welcomeName = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SessionStore {
|
||||||
|
private static let serverURLDefaultsKey = "outline.serverURL"
|
||||||
|
|
||||||
|
private let tokenStore: TokenStoring
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
var isSignedIn: Bool
|
||||||
|
var userName: String?
|
||||||
|
var userEmail: String?
|
||||||
|
var userAvatarURL: URL?
|
||||||
|
var teamName: String?
|
||||||
|
var teamAvatarURL: URL?
|
||||||
|
private(set) var apiClient: OutlineAPIClient?
|
||||||
|
|
||||||
|
var serverURL: URL? {
|
||||||
|
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||||
|
}
|
||||||
|
|
||||||
|
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
self.defaults = defaults
|
||||||
|
self.isSignedIn = (try? tokenStore.token()) != nil
|
||||||
|
|
||||||
|
if isSignedIn, let serverURL {
|
||||||
|
apiClient = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||||
|
tokenStore: tokenStore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||||
|
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
||||||
|
apiClient = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||||
|
tokenStore: tokenStore
|
||||||
|
)
|
||||||
|
apply(user: user, team: team, serverURL: serverURL)
|
||||||
|
isSignedIn = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() {
|
||||||
|
try? tokenStore.clear()
|
||||||
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||||
|
isSignedIn = false
|
||||||
|
userName = nil
|
||||||
|
userEmail = nil
|
||||||
|
userAvatarURL = nil
|
||||||
|
teamName = nil
|
||||||
|
teamAvatarURL = nil
|
||||||
|
apiClient = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
||||||
|
/// in-memory state didn't.
|
||||||
|
func refreshTeamInfoIfNeeded() async {
|
||||||
|
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
|
||||||
|
guard let auth = try? await apiClient.authInfo() else { return }
|
||||||
|
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||||
|
userName = user.name
|
||||||
|
userEmail = user.email
|
||||||
|
// Outline can return either an absolute URL or a server-relative path
|
||||||
|
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||||
|
// configured server so relative paths don't fail as "unsupported URL".
|
||||||
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
teamName = team.name
|
||||||
|
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Full-window cover shown between a successful sign-in and `RootView` revealing the
|
||||||
|
/// signed-in content beneath it, so the login form never has to visibly tear down.
|
||||||
|
struct WelcomeSplashView: View {
|
||||||
|
let name: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.background)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 88, height: 88)
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 36, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.shadow(color: Color.accentColor.opacity(0.35), radius: 16, y: 8)
|
||||||
|
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("Welcome, \(name)")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text("Signing you in…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
typealias PlatformImage = NSImage
|
||||||
|
#else
|
||||||
|
import UIKit
|
||||||
|
typealias PlatformImage = UIImage
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Loads the avatar manually instead of using `AsyncImage`, and flattens the result
|
||||||
|
/// with `.drawingGroup()`. Hosted inside AppKit-backed controls (a macOS toolbar
|
||||||
|
/// item, a `Menu` label), this content was observed blowing past its `.frame`/
|
||||||
|
/// `.clipShape` constraints once the host re-measured it after the image loaded —
|
||||||
|
/// `.drawingGroup()` rasterizes it to a fixed bitmap at the constrained size first,
|
||||||
|
/// so there's nothing left for the host to re-measure.
|
||||||
|
struct AvatarBadge: View {
|
||||||
|
let avatarURL: URL?
|
||||||
|
var size: CGFloat = 22
|
||||||
|
var placeholderSystemImage: String = "person.crop.circle.fill"
|
||||||
|
|
||||||
|
@State private var loadedImage: PlatformImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor.opacity(0.2))
|
||||||
|
|
||||||
|
if let loadedImage {
|
||||||
|
platformImage(loadedImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else {
|
||||||
|
placeholderIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.compositingGroup()
|
||||||
|
.drawingGroup()
|
||||||
|
.task(id: avatarURL) {
|
||||||
|
loadedImage = nil
|
||||||
|
guard let avatarURL else { return }
|
||||||
|
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
|
||||||
|
loadedImage = PlatformImage(data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func platformImage(_ image: PlatformImage) -> Image {
|
||||||
|
#if os(macOS)
|
||||||
|
Image(nsImage: image)
|
||||||
|
#else
|
||||||
|
Image(uiImage: image)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderIcon: some View {
|
||||||
|
Image(systemName: placeholderSystemImage)
|
||||||
|
.font(.system(size: size * 0.5, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user