Files
bitchat/bitchat/Views/ContentHeaderView.swift
T
af954b05ea Liquid Glass theme with in-app appearance switcher (#1337)
* Centralize UI theme colors into semantic ThemePalette tokens

Introduce AppTheme/ThemePalette (Utils/Theme.swift) with an environment
key and @ThemedPalette property wrapper, persisted via @AppStorage.
Views now resolve background/primary/secondary/accentBlue/alertRed/
divider tokens from the environment instead of computing colors inline,
removing the backgroundColor/textColor/secondaryTextColor prop-drilling
through the header, composer, and people-sheet hierarchy.

Matrix theme output is pixel-identical; this is groundwork for a
user-selectable theme switcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add liquid glass theme with in-app appearance switcher

Add a .liquidGlass AppTheme alongside the matrix terminal theme,
selectable from a one-line APPEARANCE row in the app info sheet
(persisted via @AppStorage, applies live).

Liquid glass renders system fonts and colors over a subtle gradient
backdrop, with the header and composer floating as Liquid Glass panels
(real .glassEffect() on iOS/macOS 26, compiler-gated with an
ultraThinMaterial fallback for older SDKs). The message list scrolls
underneath the chrome via safe-area insets, and all sheets share the
same backdrop and surface language. The matrix theme is unchanged.

Details:
- Theme is threaded through ChatMessageFormatter so message
  AttributedStrings switch font design per theme; the per-message
  format cache gains a variant key so themes never serve each other's
  cached strings
- New palette tokens: accent (interactive tint) and locationAccent
  (geohash green), replacing scattered hardcoded greens/blues in the
  voice note, waveform, verification, and people-sheet views
- Header controls get full-height tap targets and the people count
  becomes a real Button (previously a tap gesture on the cluster)
- CommandSuggestionsView renders nothing when empty instead of a
  zero-height view that pushed the composer input off-center
- New appearance strings localized for all 29 catalog languages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:13:04 +02:00

321 lines
13 KiB
Swift

import SwiftUI
#if os(iOS)
import UIKit
#endif
struct ContentHeaderView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool
@Binding var showLocationNotes: Bool
@Binding var notesGeohash: String?
var isNicknameFieldFocused: FocusState<Bool>.Binding
let headerHeight: CGFloat
let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
.bitchatFont(size: 18, weight: .medium)
.foregroundColor(palette.primary)
.onTapGesture(count: 3) {
appChromeModel.panicClearAllData()
}
.onTapGesture(count: 1) {
appChromeModel.presentAppInfo()
}
HStack(spacing: 0) {
Text(verbatim: "@")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
TextField(
"content.input.nickname_placeholder",
text: Binding(
get: { appChromeModel.nickname },
set: { appChromeModel.setNickname($0) }
)
)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.frame(maxWidth: 80)
.foregroundColor(palette.primary)
.focused(isNicknameFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.modifier(FocusEffectDisabledModifier())
.onChange(of: isNicknameFieldFocused.wrappedValue) { isFocused in
if !isFocused {
appChromeModel.validateAndSaveNickname()
}
}
.onSubmit {
appChromeModel.validateAndSaveNickname()
}
}
Spacer()
let countAndColor = channelPeopleCountAndColor()
let headerCountColor = countAndColor.1
let headerOtherPeersCount: Int = {
if case .location = locationChannelsModel.selectedChannel {
return peerListModel.visibleGeohashPeerCount
}
return countAndColor.0
}()
HStack(spacing: 2) {
if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange)
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
)
}
if case .mesh = locationChannelsModel.selectedChannel,
locationChannelsModel.permissionState == .authorized {
Button(action: {
locationChannelsModel.enableAndRefresh()
notesGeohash = locationChannelsModel.currentBuildingGeohash
showLocationNotes = true
}) {
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
)
}
if case .location(let channel) = locationChannelsModel.selectedChannel {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
locale: .current,
channel.geohash
)
)
}
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
let badgeText: String = {
switch locationChannelsModel.selectedChannel {
case .mesh: return "#mesh"
case .location(let channel): return "#\(channel.geohash)"
}
}()
let badgeColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location:
return palette.locationAccent
}
}()
Text(badgeText)
.bitchatFont(size: 14)
.foregroundColor(badgeColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
.padding(.horizontal, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.accessibilityLabel(
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
)
}
.buttonStyle(.plain)
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}) {
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular))
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: theme.bodyFontDesign))
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
locale: .current,
headerOtherPeersCount
)
)
}
.layoutPriority(3)
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(verificationModel)
}
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
}
.sheet(isPresented: $showLocationNotes, onDismiss: {
notesGeohash = nil
}) {
Group {
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
LocationNotesView(
geohash: geohash,
senderNickname: appChromeModel.nickname
)
.environmentObject(locationChannelsModel)
} else {
ContentLocationNotesUnavailableView(
showLocationNotes: $showLocationNotes,
headerHeight: headerHeight
)
.environmentObject(locationChannelsModel)
}
}
.onAppear {
locationChannelsModel.enableLocationChannels()
locationChannelsModel.beginLiveRefresh()
}
.onDisappear {
locationChannelsModel.endLiveRefresh()
}
.onChange(of: locationChannelsModel.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash,
notesGeohash != current {
notesGeohash = current
#if os(iOS)
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
#endif
}
}
}
.onAppear {
locationChannelsModel.refreshMeshChannelsIfNeeded()
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
locationChannelsModel.refreshMeshChannelsIfNeeded()
}
.onChange(of: locationChannelsModel.permissionState) { _ in
locationChannelsModel.refreshMeshChannelsIfNeeded()
}
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
Button("common.ok", role: .cancel) {}
} message: {
Text("content.alert.screenshot.message")
}
.themedChromePanel(edge: .top)
}
}
private extension View {
/// Expands a small header icon to a comfortably tappable, full-bar-height
/// hit area without changing its visual size.
func headerTapTarget() -> some View {
frame(minWidth: 30, maxHeight: .infinity)
.contentShape(Rectangle())
}
}
private extension ContentHeaderView {
var headerLineLimit: Int? {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
func channelPeopleCountAndColor() -> (Int, Color) {
switch locationChannelsModel.selectedChannel {
case .location:
let count = peerListModel.visibleGeohashPeerCount
return (count, count > 0 ? palette.locationAccent : Color.secondary)
case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
return (peerListModel.reachableMeshPeerCount, color)
}
}
}
private struct ContentLocationNotesUnavailableView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var showLocationNotes: Bool
let headerHeight: CGFloat
var body: some View {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold)
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.themedChromePanel(edge: .top)
Text("content.notes.location_unavailable")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
Button("content.location.enable") {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
Spacer()
}
.themedSheetBackground()
.foregroundColor(palette.primary)
}
}