Files
org-social-ios/App/Views/RootView.swift
andros d2ab6ea377 Add color theme system with 6 built-in themes; fix modal sheet theming
New files: AppTheme (palette + env key), ThemeManager (Observable singleton,
UserDefaults persistence). Themes: Default, Emacs, Dracula, One Dark,
Monokai, Material. Each controls background, accent, text, code block
colors and a highlight.js theme for syntax highlighting.

RootView applies preferredColorScheme, tint and appTheme env to the root
group. SettingsView applies the same modifiers to its NavigationStack so
the modal sheet is themed immediately (sheets run in a separate UIWindow
and don't inherit preferredColorScheme from the parent hierarchy).
Theme selection in Settings uses onTapGesture instead of buttonStyle(.plain)
inside Form, which was silently intercepting the button action.
CodeBlockView, TimelineView, NotificationsView all read from appTheme env.
2026-05-24 09:50:16 +02:00

124 lines
5.0 KiB
Swift

import SwiftUI
import OrgSocialKit
/// Route identifiers used by `TabView(selection:)`. Kept as an enum so other views
/// (e.g. TimelineView's empty state) can programmatically switch tabs via a binding.
enum RootTab: Hashable {
case timeline, notifications, discover, groups, profile
}
struct RootView: View {
@AppStorage("publicFeedURL") private var publicFeedURL = ""
@AppStorage("vfileURL") private var vfileURL = ""
@AppStorage("githubToken") private var githubToken = ""
@AppStorage("codebergToken") private var codebergToken = ""
@AppStorage("webdavURL") private var webdavURL = ""
@AppStorage("eulaAcceptedVersion") private var eulaAcceptedVersion: Int = 0
@State private var selectedTab: RootTab = .timeline
@State private var notificationsViewModel = NotificationsViewModel()
@State private var themeManager = ThemeManager.shared
private var pushRouter = PushRouter.shared
// User is "configured" once they have both a public feed URL and some kind of credential
// for publishing. This gate avoids landing on an empty timeline before first-time setup.
private var isConfigured: Bool {
guard !publicFeedURL.isEmpty, URL(string: publicFeedURL) != nil else { return false }
return !vfileURL.isEmpty || !githubToken.isEmpty || !codebergToken.isEmpty || !webdavURL.isEmpty
}
private var hasAcceptedEULA: Bool { eulaAcceptedVersion >= EULAView.currentVersion }
var body: some View {
Group {
if isConfigured {
tabs
} else {
WelcomeView()
}
}
.tint(themeManager.current.accent)
.preferredColorScheme(themeManager.current.colorScheme)
.environment(\.appTheme, themeManager.current)
// EULA gate is rendered as a full-screen sheet over whatever the
// root would have shown. Cannot be dismissed without "I Agree";
// tapping I Agree updates `eulaAcceptedVersion` which flips the
// binding here and dismisses the cover.
.fullScreenCover(isPresented: Binding(
get: { !hasAcceptedEULA },
set: { _ in /* dismiss is driven by the agree button writing eulaAcceptedVersion */ }
)) {
EULAView()
}
}
private var tabs: some View {
TabView(selection: $selectedTab) {
TimelineView(selectedTab: $selectedTab)
.tabItem {
Label("Timeline", systemImage: "house")
}
.tag(RootTab.timeline)
NotificationsView(viewModel: notificationsViewModel)
.badge(notificationsViewModel.unreadCount)
.tabItem {
Label("Notifications", systemImage: "bell")
}
.tag(RootTab.notifications)
DiscoverView()
.tabItem {
Label("Discover", systemImage: "globe")
}
.tag(RootTab.discover)
GroupsView()
.tabItem {
Label("Groups", systemImage: "person.3")
}
.tag(RootTab.groups)
OwnProfileView()
.tabItem {
Label("Profile", systemImage: "person.circle")
}
.tag(RootTab.profile)
}
.task {
await registerWithRelayIfNeeded()
await PushRegistration.shared.requestPermissionAndRegister()
// Background fetch so the badge is populated before the user
// opens the notifications tab.
await notificationsViewModel.load()
}
.onChange(of: pushRouter.pendingRoute) { _, route in
if route != nil { selectedTab = .notifications }
}
}
/// Proactively registers the user's feed URL with the relay so it starts
/// getting crawled (~every minute) instead of waiting for someone to
/// follow the feed and for the relay's daily follow-crawl to pick it up.
/// A brand-new account otherwise wouldn't appear anywhere on the relay
/// for up to 24 hours. The registration is best-effort: a UserDefaults
/// flag avoids repeating it every launch for the same (feed, relay) pair.
private func registerWithRelayIfNeeded() async {
let relayRaw = UserDefaults.standard.string(forKey: "relayURL") ?? ""
guard !publicFeedURL.isEmpty,
let feedURL = URL(string: publicFeedURL),
let relayURL = URL(string: relayRaw) else { return }
let registeredKey = "relayRegistration.\(relayURL.absoluteString).\(feedURL.absoluteString)"
if UserDefaults.standard.bool(forKey: registeredKey) { return }
do {
try await RelayClient().registerFeed(feedURL, with: relayURL)
UserDefaults.standard.set(true, forKey: registeredKey)
} catch {
// Silent: the next launch will retry, and notifications/timeline
// code already re-registers reactively on 404.
}
}
}