From 43a10a6ec3c3874eb1f6543db5255f834e52b3b7 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 00:23:57 +0100 Subject: [PATCH] feat(app): add session store and root view routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Outpost/ContentView.swift | 79 +--------------------------- Outpost/Item.swift | 18 ------- Outpost/Root/RootView.swift | 39 ++++++++++++++ Outpost/Root/SessionStore.swift | 78 +++++++++++++++++++++++++++ Outpost/Root/WelcomeSplashView.swift | 41 +++++++++++++++ Outpost/Support/AvatarBadge.swift | 62 ++++++++++++++++++++++ 6 files changed, 222 insertions(+), 95 deletions(-) delete mode 100644 Outpost/Item.swift create mode 100644 Outpost/Root/RootView.swift create mode 100644 Outpost/Root/SessionStore.swift create mode 100644 Outpost/Root/WelcomeSplashView.swift create mode 100644 Outpost/Support/AvatarBadge.swift diff --git a/Outpost/ContentView.swift b/Outpost/ContentView.swift index 77b60b9..57f4f38 100644 --- a/Outpost/ContentView.swift +++ b/Outpost/ContentView.swift @@ -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) - .navigationSplitViewColumnWidth(min: 180, ideal: 200) -#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: View { - let content: () -> Content - - var body: some View { -#if os(macOS) - NavigationSplitView { - content() - } detail: { - Text("Select an item") - } +typealias ContentView = ContentView_macOS #else - content() +typealias ContentView = ContentView_iOS #endif - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -} diff --git a/Outpost/Item.swift b/Outpost/Item.swift deleted file mode 100644 index 344e52f..0000000 --- a/Outpost/Item.swift +++ /dev/null @@ -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 - } -} diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift new file mode 100644 index 0000000..8ff7c00 --- /dev/null +++ b/Outpost/Root/RootView.swift @@ -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 + } + } + } +} diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift new file mode 100644 index 0000000..d8563fe --- /dev/null +++ b/Outpost/Root/SessionStore.swift @@ -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 } + } +} diff --git a/Outpost/Root/WelcomeSplashView.swift b/Outpost/Root/WelcomeSplashView.swift new file mode 100644 index 0000000..c00203b --- /dev/null +++ b/Outpost/Root/WelcomeSplashView.swift @@ -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) + } + } + } + } +} diff --git a/Outpost/Support/AvatarBadge.swift b/Outpost/Support/AvatarBadge.swift new file mode 100644 index 0000000..bf35d89 --- /dev/null +++ b/Outpost/Support/AvatarBadge.swift @@ -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) + } +}