Files
andros 78ba61034f Fix theme colors across all screens; fix Discover title on iOS 26
- Remove .toolbarBackground(.visible) from all NavigationStack views:
  this modifier suppresses large title rendering in iOS 26.
- Discover: switch to always-present List + overlay pattern so SwiftUI
  never loses the scroll context during loading transitions.
- Discover, Notifications, Groups: use .inline title mode; the tab bar
  already identifies these screens, large titles are redundant.
- RootView: add .toolbarColorScheme propagation for nav bar foreground.
- Settings sheet: apply theme background and toolbar color to the Form.
2026-05-24 21:10:04 +02:00

125 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)
}
.toolbarColorScheme(themeManager.current.colorScheme, for: .navigationBar)
.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.
}
}
}