Files
bitchat/bitchat/Views/ContentSheetViews.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

495 lines
20 KiB
Swift

import BitFoundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct ContentPeopleSheetView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Binding var showSidebar: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var isAtBottomPrivate: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@ThemedPalette private var palette
let headerHeight: CGFloat
let onSendMessage: () -> Void
#if os(iOS)
@Binding var showImagePicker: Bool
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
#else
@Binding var showMacImagePicker: Bool
#endif
var body: some View {
NavigationStack {
Group {
if privateConversationModel.selectedPeerID != nil {
#if os(iOS)
ContentPrivateChatSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentPrivateChatSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
} else {
ContentPeopleListView(
showSidebar: $showSidebar,
headerHeight: headerHeight
)
}
}
.navigationDestination(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && (showSidebar || privateConversationModel.selectedPeerID != nil) },
set: { isPresented in
if !isPresented {
appChromeModel.clearFingerprint()
}
}
)) {
if let peerID = appChromeModel.showingFingerprintFor {
FingerprintView(peerID: peerID)
.environmentObject(verificationModel)
}
}
}
.themedSheetBackground()
.foregroundColor(palette.primary)
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
#if os(iOS)
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && (showSidebar || privateConversationModel.selectedPeerID != nil) },
set: { newValue in
if !newValue {
showImagePicker = false
}
}
)) {
ImagePickerView(sourceType: imagePickerSourceType) { image in
showImagePicker = false
conversationUIModel.processSelectedImage(image)
}
.ignoresSafeArea()
}
#endif
#if os(macOS)
.sheet(isPresented: $showMacImagePicker) {
MacImagePickerView { url in
showMacImagePicker = false
conversationUIModel.processSelectedImage(from: url)
}
}
#endif
}
}
private struct ContentPeopleListView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@ThemedPalette private var palette
@Binding var showSidebar: Bool
let headerHeight: CGFloat
@State private var showVerifySheet = false
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(peopleSheetTitle)
.bitchatFont(size: 18)
.foregroundColor(palette.primary)
Spacer()
if case .mesh = locationChannelsModel.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.help(
String(localized: "content.help.verification", comment: "Help text for verification button")
)
}
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss()
showSidebar = false
showVerifySheet = false
privateConversationModel.endConversation()
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
let activeText = String.localizedStringWithFormat(
String(localized: "%@ active", comment: "Count of active users in the people sheet"),
"\(peopleSheetActiveCount)"
)
if let subtitle = peopleSheetSubtitle {
let subtitleColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return palette.accentBlue
case .location:
return palette.locationAccent
}
}()
HStack(spacing: 6) {
Text(subtitle)
.foregroundColor(subtitleColor)
Text(activeText)
.foregroundColor(.secondary)
}
.bitchatFont(size: 12)
} else {
Text(activeText)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
ScrollView {
VStack(alignment: .leading, spacing: 6) {
if case .location = locationChannelsModel.selectedChannel {
GeohashPeopleList(
onTapPerson: {
showSidebar = true
}
)
} else {
MeshPeerList(
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
},
onToggleFavorite: { peerID in
peerListModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID)
}
)
}
}
.padding(.top, 4)
.id(peerListModel.renderID)
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(verificationModel)
}
}
}
private extension ContentPeopleListView {
var peopleSheetTitle: String {
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
}
var peopleSheetSubtitle: String? {
switch locationChannelsModel.selectedChannel {
case .mesh:
return "#mesh"
case .location(let channel):
return "#\(channel.geohash.lowercased())"
}
}
var peopleSheetActiveCount: Int {
switch locationChannelsModel.selectedChannel {
case .mesh:
return peerListModel.reachableMeshPeerCount
case .location:
return peerListModel.visibleGeohashPeerCount
}
}
}
private struct ContentPrivateChatSheetView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@Binding var showSidebar: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var isAtBottomPrivate: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let headerHeight: CGFloat
let onSendMessage: () -> Void
#if os(iOS)
@Binding var showImagePicker: Bool
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
#else
@Binding var showMacImagePicker: Bool
#endif
var body: some View {
VStack(spacing: 0) {
if let headerState = privateConversationModel.selectedHeaderState {
HStack(spacing: 12) {
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation()
}
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
)
Spacer(minLength: 0)
HStack(spacing: 8) {
ContentPrivateHeaderInfoButton(
headerState: headerState,
headerHeight: headerHeight
)
if headerState.supportsFavoriteToggle {
Button(action: {
privateConversationModel.toggleFavoriteForSelectedConversation()
}) {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(
headerState.isFavorite
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
)
}
}
.frame(maxWidth: .infinity)
Spacer(minLength: 0)
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation()
showSidebar = true
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.themedSurface()
}
MessageListView(
privatePeer: privateConversationModel.selectedPeerID,
isAtBottom: $isAtBottomPrivate,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
showSidebar: $showSidebar,
isTextFieldFocused: isTextFieldFocused
)
.themedSurface()
.frame(maxWidth: .infinity, maxHeight: .infinity)
if !theme.usesGlassChrome {
Divider()
}
#if os(iOS)
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
.themedSheetBackground()
.foregroundColor(palette.primary)
.highPriorityGesture(
DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onEnded { value in
let horizontal = value.translation.width
let vertical = abs(value.translation.height)
guard horizontal > 80, vertical < 60 else { return }
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
privateConversationModel.endConversation()
}
}
)
}
}
private struct ContentPrivateHeaderInfoButton: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@ThemedPalette private var palette
let headerState: PrivateConversationHeaderState
let headerHeight: CGFloat
var body: some View {
Button(action: {
appChromeModel.showFingerprint(for: headerState.headerPeerID)
}) {
HStack(spacing: 6) {
switch headerState.availability {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
EmptyView()
}
Text(headerState.displayName)
.bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? palette.primary
: Color.red
)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
locale: .current,
encryptionStatus.accessibilityDescription
)
)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
locale: .current,
headerState.displayName
)
)
.accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(height: headerHeight)
}
}