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:
2026-08-14 00:23:57 +01:00
parent 9203897ccd
commit 43a10a6ec3
6 changed files with 222 additions and 95 deletions
+2 -77
View File
@@ -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)
}
-18
View File
@@ -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
}
}
+39
View File
@@ -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
}
}
}
}
+78
View File
@@ -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 }
}
}
+41
View File
@@ -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)
}
}
}
}
}
+62
View File
@@ -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)
}
}