mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:45:18 +00:00
* 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>
321 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|