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

179 lines
7.1 KiB
Swift

import SwiftUI
import OrgSocialKit
struct GroupsView: View {
@State private var viewModel = GroupsViewModel()
@Environment(\.appTheme) private var theme
var body: some View {
NavigationStack {
Group {
if viewModel.isLoadingGroups {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.groupsError {
ContentUnavailableView {
Label("Groups unavailable", systemImage: "person.3.slash")
} description: {
Text(error)
} actions: {
Button("Retry") { Task { await viewModel.loadGroups() } }
.buttonStyle(.bordered)
}
} else if viewModel.items.isEmpty {
ContentUnavailableView(
"No groups",
systemImage: "person.3",
description: Text("No groups are registered on this relay.")
)
} else {
groupList
}
}
.background(theme.background)
.navigationTitle("Groups")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.loadGroups() }
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
// Safe navigation: no NavigationLink inside List rows.
// selectedItem drives the destination to avoid the iOS bug where
// tapping a Button in a row fires the nearest NavigationLink instead.
.navigationDestination(item: $viewModel.selectedItem) { item in
GroupPostsView(item: item, viewModel: viewModel)
}
.alert("Error", isPresented: .constant(viewModel.followError != nil)) {
Button("OK") { viewModel.followError = nil }
} message: {
Text(viewModel.followError ?? "")
}
}
}
private var groupList: some View {
List(viewModel.items) { item in
HStack(spacing: 12) {
// Tap area: navigates to group posts
Button {
viewModel.selectedItem = item
} label: {
HStack(spacing: 10) {
Image(systemName: item.slug == nil ? "person.3.slash" : "person.3.fill")
.foregroundStyle(item.slug == nil ? .secondary : .primary)
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.foregroundStyle(.primary)
if item.slug == nil {
Text("Not on current relay")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if viewModel.canFollow {
followButton(for: item)
}
}
.listRowSeparator(.visible)
.listRowSeparatorTint(Color.secondary.opacity(0.2))
.listRowBackground(theme.background)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(theme.background)
}
@ViewBuilder
private func followButton(for item: GroupDisplayItem) -> some View {
let toggling = viewModel.isToggling(item)
Button {
Task { await viewModel.toggleFollow(item) }
} label: {
if toggling {
ProgressView()
.frame(width: 70, height: 28)
} else {
Text(item.isFollowed ? "Unfollow" : "Follow")
.font(.subheadline)
.fontWeight(item.isFollowed ? .regular : .semibold)
.frame(width: 72)
}
}
.buttonStyle(.bordered)
.tint(item.isFollowed ? .secondary : .accentColor)
.disabled(toggling)
}
}
// Alias so the navigation destination closure can refer to the type directly.
private typealias GroupDisplayItem = GroupsViewModel.GroupDisplayItem
private struct GroupPostsView: View {
let item: GroupDisplayItem
var viewModel: GroupsViewModel
@State private var showCompose = false
@Environment(\.appTheme) private var theme
var body: some View {
Group {
if viewModel.isLoadingPosts {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.postsError {
ContentUnavailableView {
Label("Could not load posts", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Retry") { Task { await viewModel.loadPosts(for: item) } }
.buttonStyle(.bordered)
}
} else if viewModel.posts.isEmpty {
ContentUnavailableView(
"No posts",
systemImage: "bubble.left.and.bubble.right",
description: Text("This group has no posts yet.")
)
} else {
List {
ForEach(viewModel.posts, id: \.timestamp) { post in
PostRowView(post: post)
.listRowSeparator(.visible)
.listRowSeparatorTint(Color.secondary.opacity(0.2))
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowBackground(theme.background)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(theme.background)
.navigationDestination(for: URL.self) { ProfileView(feedURL: $0) }
.navigationDestination(for: ThreadRoute.self) {
ThreadView(postURL: $0.postURL, relayURL: $0.relayURL)
}
}
}
.background(theme.background)
.navigationTitle(item.name)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { showCompose = true } label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("Compose")
}
}
.sheet(isPresented: $showCompose) {
let relayBase = UserDefaults.standard.string(forKey: "relayURL") ?? "https://relay.org-social.org"
ComposeView(group: "\(item.name) \(relayBase)")
}
.task { await viewModel.loadPosts(for: item) }
}
}