From af954b05ea918ff80e8bbaa312e9d2565d79cdbb Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:13:04 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/ConversationUIModel.swift | 12 +- bitchat/BitchatApp.swift | 2 + bitchat/Localizable.xcstrings | 537 ++++++++++++++++++ bitchat/Utils/Theme.swift | 339 +++++++++++ bitchat/ViewModels/ChatMessageFormatter.swift | 46 +- bitchat/ViewModels/ChatViewModel.swift | 9 +- bitchat/Views/AppInfoView.swift | 98 ++-- .../Components/CommandSuggestionsView.swift | 63 +- .../Views/Components/DeliveryStatusView.swift | 18 +- .../Views/Components/PaymentChipView.swift | 7 +- .../Views/Components/TextMessageView.swift | 5 +- bitchat/Views/ContentComposerView.swift | 44 +- bitchat/Views/ContentHeaderView.swift | 129 +++-- bitchat/Views/ContentSheetViews.swift | 85 +-- bitchat/Views/ContentView.swift | 186 +++--- bitchat/Views/FingerprintView.swift | 40 +- bitchat/Views/GeohashPeopleList.swift | 13 +- bitchat/Views/LocationChannelsSheet.swift | 68 +-- bitchat/Views/LocationNotesView.swift | 56 +- bitchat/Views/Media/MediaMessageView.swift | 3 +- bitchat/Views/Media/VoiceNoteView.swift | 7 +- bitchat/Views/Media/WaveformView.swift | 5 +- bitchat/Views/MeshPeerList.swift | 15 +- bitchat/Views/MessageListView.swift | 3 +- bitchat/Views/VerificationViews.swift | 26 +- bitchatTests/ViewSmokeTests.swift | 19 +- .../BitFoundation/BitchatMessage.swift | 10 +- 27 files changed, 1347 insertions(+), 498 deletions(-) create mode 100644 bitchat/Utils/Theme.swift diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 29770e1a..f012edd3 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -41,6 +41,10 @@ final class ConversationUIModel: ObservableObject { chatViewModel.currentColorScheme = colorScheme } + func setCurrentTheme(_ theme: AppTheme) { + chatViewModel.currentTheme = theme + } + func sendMessage(_ message: String) { chatViewModel.sendMessage(message) } @@ -76,12 +80,12 @@ final class ConversationUIModel: ObservableObject { chatViewModel.completeNickname(nickname, in: &text) } - func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { - chatViewModel.formatMessageAsText(message, colorScheme: colorScheme) + func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { + chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme) } - func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { - chatViewModel.formatMessageHeader(message, colorScheme: colorScheme) + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { + chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme) } func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? { diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 928178cc..4f2e4199 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -15,6 +15,7 @@ struct BitchatApp: App { static let groupID = "group.\(bundleID)" @StateObject private var runtime: AppRuntime + @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue #if os(iOS) @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @@ -30,6 +31,7 @@ struct BitchatApp: App { var body: some Scene { WindowGroup { ContentView() + .environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix) .environmentObject(runtime.publicChatModel) .environmentObject(runtime.privateInboxModel) .environmentObject(runtime.privateConversationModel) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index ac92becf..be1bba06 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -540,6 +540,543 @@ } } }, + "app_info.appearance.liquid_glass" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "زجاج سائل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "লিকুইড গ্লাস" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זכוכית נוזלית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लिक्विड ग्लास" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リキッドガラス" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리퀴드 글래스" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "लिक्विड ग्लास" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "жидкое стекло" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "லிக்விட் கிளாஸ்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลิควิดกลาส" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "рідке скло" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "لیکویڈ گلاس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "liquid glass" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "液态玻璃" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "液態玻璃" + } + } + } + }, + "app_info.appearance.matrix" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ماتريكس" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ম্যাট্রিক্স" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מטריקס" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मैट्रिक्स" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マトリックス" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매트릭스" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "म्याट्रिक्स" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "матрица" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மேட்ரிக்ஸ்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เมทริกซ์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "матриця" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میٹرکس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "matrix" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "矩阵" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "矩陣" + } + } + } + }, + "app_info.appearance.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المظهر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "চেহারা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ERSCHEINUNGSBILD" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "APPEARANCE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "APARIENCIA" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "HITSURA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "APPARENCE" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מראה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दिखावट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAMPILAN" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ASPETTO" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "外観" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 모드" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "PENAMPILAN" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रूप" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WEERGAVE" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WYGLĄD" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "APARÊNCIA" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "APARÊNCIA" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОФОРМЛЕНИЕ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "UTSEENDE" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தோற்றம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลักษณะที่ปรากฏ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GÖRÜNÜM" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОФОРМЛЕННЯ" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ظاہری شکل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "GIAO DIỆN" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外观" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "外觀" + } + } + } + }, "app_info.close" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Utils/Theme.swift b/bitchat/Utils/Theme.swift new file mode 100644 index 00000000..40fe035f --- /dev/null +++ b/bitchat/Utils/Theme.swift @@ -0,0 +1,339 @@ +// +// Theme.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// A user-selectable app-wide visual theme. Persisted by raw value. +enum AppTheme: String, CaseIterable, Identifiable { + case matrix + case liquidGlass + + var id: String { rawValue } + + /// UserDefaults key backing the theme selection. + static let storageKey = "appTheme" + + var displayNameKey: LocalizedStringKey { + switch self { + case .matrix: return "app_info.appearance.matrix" + case .liquidGlass: return "app_info.appearance.liquid_glass" + } + } + + /// Font design used for themed text. Matrix keeps the terminal monospace; + /// liquid glass uses the system default. + var bodyFontDesign: Font.Design { + switch self { + case .matrix: return .monospaced + case .liquidGlass: return .default + } + } + + /// Whether chrome surfaces (header/composer bars, input field) render as + /// translucent glass/material instead of the flat matrix background. + var usesGlassChrome: Bool { + self == .liquidGlass + } + + /// Discriminator mixed into per-message formatting caches so cached + /// AttributedStrings from one theme are never served under another. + /// Empty for matrix to keep its historical cache keys. + var formatCacheVariant: String { + switch self { + case .matrix: return "" + case .liquidGlass: return "lg:" + } + } + + /// Resolves the semantic color palette for this theme under the given color scheme. + func palette(for colorScheme: ColorScheme) -> ThemePalette { + switch self { + case .matrix: + return .matrix(colorScheme) + case .liquidGlass: + return .liquidGlass(colorScheme) + } + } +} + +/// Semantic colors for the active theme, resolved against the current color scheme. +/// Views should consume these via `@ThemedPalette` rather than computing colors inline. +struct ThemePalette { + /// Primary window/sheet background. + let background: Color + /// Primary text color. + let primary: Color + /// De-emphasized text (timestamps, hints, captions). + let secondary: Color + /// Interactive tint (buttons, toggles, selection). + let accent: Color + /// Location/geohash channel accent (badges, counts, subtitles). + let locationAccent: Color + /// Informational accent (links, read receipts, teleport markers). + let accentBlue: Color + /// Destructive/error accent. + let alertRed: Color + /// Hairline separators. + let divider: Color + + static func matrix(_ colorScheme: ColorScheme) -> ThemePalette { + let isDark = colorScheme == .dark + let green = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + return ThemePalette( + background: isDark ? Color.black : Color.white, + primary: green, + secondary: green.opacity(0.8), + accent: green, + locationAccent: green, + accentBlue: Color(red: 0.0, green: 0.478, blue: 1.0), + alertRed: Color(red: 0.75, green: 0.1, blue: 0.1), + divider: isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) + ) + } + + static func liquidGlass(_ colorScheme: ColorScheme) -> ThemePalette { + ThemePalette( + background: systemBackground, + primary: .primary, + secondary: .secondary, + accent: .blue, + locationAccent: .green, + accentBlue: .blue, + alertRed: .red, + divider: separator + ) + } + + private static var systemBackground: Color { + #if os(iOS) + Color(UIColor.systemBackground) + #else + Color(NSColor.windowBackgroundColor) + #endif + } + + private static var separator: Color { + #if os(iOS) + Color(UIColor.separator) + #else + Color(NSColor.separatorColor) + #endif + } +} + +private struct AppThemeKey: EnvironmentKey { + static let defaultValue: AppTheme = .matrix +} + +extension EnvironmentValues { + var appTheme: AppTheme { + get { self[AppThemeKey.self] } + set { self[AppThemeKey.self] = newValue } + } +} + +/// Resolves the active theme's palette against the view's color scheme. +/// +/// @ThemedPalette private var palette +/// var body: some View { Text("hi").foregroundColor(palette.primary) } +@propertyWrapper +struct ThemedPalette: DynamicProperty { + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var wrappedValue: ThemePalette { theme.palette(for: colorScheme) } +} + +// MARK: - Themed view helpers + +/// Themed replacement for `.font(.bitchatSystem(size:weight:design: .monospaced))`: +/// monospaced under matrix, system default under liquid glass. +private struct ThemedFontModifier: ViewModifier { + @Environment(\.appTheme) private var theme + let size: CGFloat + let weight: Font.Weight + + func body(content: Content) -> some View { + content.font(.bitchatSystem(size: size, weight: weight, design: theme.bodyFontDesign)) + } +} + +/// Root backdrop. Matrix gets its flat background; glass gets a subtle static +/// gradient with a soft tinted glow — glass panels need visual texture behind +/// them to refract, and collapse to flat gray over a solid color. +struct ThemedRootBackground: View { + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @ThemedPalette private var palette + + var body: some View { + if theme.usesGlassChrome { + let isDark = colorScheme == .dark + ZStack { + LinearGradient( + colors: isDark + ? [Color(red: 0.09, green: 0.10, blue: 0.15), Color(red: 0.04, green: 0.04, blue: 0.07)] + : [Color(red: 0.93, green: 0.95, blue: 1.0), Color(red: 0.98, green: 0.97, blue: 0.99)], + startPoint: .top, + endPoint: .bottom + ) + RadialGradient( + colors: [Color.blue.opacity(isDark ? 0.22 : 0.12), .clear], + center: .topLeading, + startRadius: 0, + endRadius: 600 + ) + RadialGradient( + colors: [Color.purple.opacity(isDark ? 0.14 : 0.08), .clear], + center: .bottomTrailing, + startRadius: 0, + endRadius: 500 + ) + } + .ignoresSafeArea() + } else { + palette.background + } + } +} + +/// Wraps glass-shape content in real Liquid Glass on OS 26+, with a material +/// fallback below that keeps the frosted look. +private struct GlassPanel: ViewModifier { + let shape: S + + @ViewBuilder + func body(content: Content) -> some View { + #if compiler(>=6.2) + if #available(iOS 26.0, macOS 26.0, *) { + content.glassEffect(.regular, in: shape) + } else { + materialFallback(content) + } + #else + materialFallback(content) + #endif + } + + private func materialFallback(_ content: Content) -> some View { + content + .background(shape.fill(.ultraThinMaterial)) + .overlay(shape.stroke(Color.white.opacity(0.15), lineWidth: 0.5)) + } +} + +/// Chrome surface for the header and composer. Matrix keeps the original flat +/// edge-to-edge wash; glass floats the content as an inset Liquid Glass panel +/// (content is expected to scroll underneath via safe-area insets). +private struct ThemedChromePanelModifier: ViewModifier { + @Environment(\.appTheme) private var theme + @ThemedPalette private var palette + let edge: VerticalEdge + + @ViewBuilder + func body(content: Content) -> some View { + if theme.usesGlassChrome { + content + .modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 18, style: .continuous))) + .padding(.horizontal, 8) + .padding(edge == .top ? .top : .bottom, 4) + } else { + content.background(palette.background.opacity(0.95)) + } + } +} + +/// Background for the composer input field. Matrix keeps its translucent fill; +/// glass leaves it clear — the field sits inside the composer's glass panel, +/// and glass cannot sample other glass. +private struct ThemedInputBackgroundModifier: ViewModifier { + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: 14, style: .continuous) + } + + @ViewBuilder + func body(content: Content) -> some View { + if theme.usesGlassChrome { + content + } else { + content.background( + shape.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) + ) + } + } +} + +extension View { + func bitchatFont(size: CGFloat, weight: Font.Weight = .regular) -> some View { + modifier(ThemedFontModifier(size: size, weight: weight)) + } + + func themedChromePanel(edge: VerticalEdge) -> some View { + modifier(ThemedChromePanelModifier(edge: edge)) + } + + func themedInputBackground() -> some View { + modifier(ThemedInputBackgroundModifier()) + } + + /// Floating surface for popover-style boxes (autocomplete, command + /// suggestions): glass panel under liquid glass, the original flat + /// background + hairline stroke under matrix. + func themedOverlayPanel() -> some View { + modifier(ThemedOverlayPanelModifier()) + } + + /// Root background for sheets — same backdrop as the main window so every + /// surface speaks one visual language. + func themedSheetBackground() -> some View { + background(ThemedRootBackground()) + } + + /// Flat background wash for bars/headers inside sheets. Matrix keeps its + /// opaque wash; glass goes transparent so the backdrop gradient shows. + func themedSurface(opacity: Double = 1.0) -> some View { + modifier(ThemedSurfaceModifier(opacity: opacity)) + } +} + +private struct ThemedSurfaceModifier: ViewModifier { + @Environment(\.appTheme) private var theme + @ThemedPalette private var palette + let opacity: Double + + @ViewBuilder + func body(content: Content) -> some View { + if theme.usesGlassChrome { + content + } else { + content.background(palette.background.opacity(opacity)) + } + } +} + +private struct ThemedOverlayPanelModifier: ViewModifier { + @Environment(\.appTheme) private var theme + @ThemedPalette private var palette + + @ViewBuilder + func body(content: Content) -> some View { + if theme.usesGlassChrome { + content.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 12, style: .continuous))) + } else { + content + .background(palette.background) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(palette.secondary.opacity(0.3), lineWidth: 1) + ) + } + } +} diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 21a63028..cd9ee480 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -14,7 +14,8 @@ final class ChatMessageFormatter { self.viewModel = viewModel } - func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString { + let design = theme.bodyFontDesign let isSelf: Bool = { if let spid = message.senderPeerID { if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { @@ -40,7 +41,7 @@ final class ChatMessageFormatter { }() let isDark = colorScheme == .dark - if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) { + if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) { return cachedText } @@ -52,7 +53,7 @@ final class ChatMessageFormatter { var senderStyle = AttributeContainer() senderStyle.foregroundColor = baseColor let fontWeight: Font.Weight = isSelf ? .bold : .medium - senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced) + senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: design) if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { senderStyle.link = url @@ -79,8 +80,8 @@ final class ChatMessageFormatter { var plainStyle = AttributeContainer() plainStyle.foregroundColor = baseColor plainStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) + ? .bitchatSystem(size: 14, weight: .bold, design: design) + : .bitchatSystem(size: 14, design: design) result.append(AttributedString(content).mergingAttributes(plainStyle)) } else { let hashtagRegex = Patterns.hashtag @@ -197,8 +198,8 @@ final class ChatMessageFormatter { var beforeStyle = AttributeContainer() beforeStyle.foregroundColor = baseColor beforeStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) + ? .bitchatSystem(size: 14, weight: .bold, design: design) + : .bitchatSystem(size: 14, design: design) if isMentioned { beforeStyle.font = beforeStyle.font?.bold() } @@ -230,7 +231,7 @@ final class ChatMessageFormatter { mentionStyle.font = .bitchatSystem( size: 14, weight: isSelf ? .bold : .semibold, - design: .monospaced + design: design ) let mentionColor: Color = isMentionToMe ? .orange : baseColor mentionStyle.foregroundColor = mentionColor @@ -267,8 +268,8 @@ final class ChatMessageFormatter { var tagStyle = AttributeContainer() tagStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) + ? .bitchatSystem(size: 14, weight: .bold, design: design) + : .bitchatSystem(size: 14, design: design) tagStyle.foregroundColor = baseColor if isGeohash && !attachedToMentionToken && standalone, let url = URL(string: "bitchat://geohash/\(token)") { @@ -280,15 +281,15 @@ final class ChatMessageFormatter { var spacer = AttributeContainer() spacer.foregroundColor = baseColor spacer.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) + ? .bitchatSystem(size: 14, weight: .bold, design: design) + : .bitchatSystem(size: 14, design: design) result.append(AttributedString(" ").mergingAttributes(spacer)) } else { var matchStyle = AttributeContainer() matchStyle.font = .bitchatSystem( size: 14, weight: isSelf ? .bold : .semibold, - design: .monospaced + design: design ) if type == "url" { matchStyle.foregroundColor = isSelf ? .orange : .blue @@ -310,8 +311,8 @@ final class ChatMessageFormatter { var remainingStyle = AttributeContainer() remainingStyle.foregroundColor = baseColor remainingStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) + ? .bitchatSystem(size: 14, weight: .bold, design: design) + : .bitchatSystem(size: 14, design: design) if isMentioned { remainingStyle.font = remainingStyle.font?.bold() } @@ -322,27 +323,28 @@ final class ChatMessageFormatter { let timestamp = AttributedString(" [\(message.formattedTimestamp)]") var timestampStyle = AttributeContainer() timestampStyle.foregroundColor = Color.gray.opacity(0.7) - timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) + timestampStyle.font = .bitchatSystem(size: 10, design: design) result.append(timestamp.mergingAttributes(timestampStyle)) } else { var contentStyle = AttributeContainer() contentStyle.foregroundColor = Color.gray let content = AttributedString("* \(message.content) *") - contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic() + contentStyle.font = .bitchatSystem(size: 12, design: design).italic() result.append(content.mergingAttributes(contentStyle)) let timestamp = AttributedString(" [\(message.formattedTimestamp)]") var timestampStyle = AttributeContainer() timestampStyle.foregroundColor = Color.gray.opacity(0.5) - timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) + timestampStyle.font = .bitchatSystem(size: 10, design: design) result.append(timestamp.mergingAttributes(timestampStyle)) } - message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) + message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) return result } - func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString { + let design = theme.bodyFontDesign let isSelf: Bool = { if let spid = message.senderPeerID { if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"), @@ -362,7 +364,7 @@ final class ChatMessageFormatter { if message.sender == "system" { var style = AttributeContainer() style.foregroundColor = baseColor - style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + style.font = .bitchatSystem(size: 14, weight: .medium, design: design) return AttributedString(message.sender).mergingAttributes(style) } @@ -370,7 +372,7 @@ final class ChatMessageFormatter { let (baseName, suffix) = message.sender.splitSuffix() var senderStyle = AttributeContainer() senderStyle.foregroundColor = baseColor - senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced) + senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: design) if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { senderStyle.link = url diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8bf4222a..00f10ae3 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -139,6 +139,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } private var visibleMessagesCache: [BitchatMessage]? @Published var currentColorScheme: ColorScheme = .light + @Published var currentTheme: AppTheme = .matrix @Published var isConnected = false @Published var nickname: String = "" { didSet { @@ -1257,13 +1258,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Message Formatting @MainActor - func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { - messageFormatter.formatMessageAsText(message, colorScheme: colorScheme) + func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { + messageFormatter.formatMessageAsText(message, colorScheme: colorScheme, theme: theme ?? currentTheme) } @MainActor - func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { - messageFormatter.formatMessageHeader(message, colorScheme: colorScheme) + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { + messageFormatter.formatMessageHeader(message, colorScheme: colorScheme, theme: theme ?? currentTheme) } // MARK: - Noise Protocol Support diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 68472e81..cce9499e 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -2,24 +2,24 @@ import SwiftUI struct AppInfoView: View { @Environment(\.dismiss) var dismiss - @Environment(\.colorScheme) var colorScheme - - private var backgroundColor: Color { - colorScheme == .dark ? Color.black : Color.white - } - - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - - private var secondaryTextColor: Color { - colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) + @ThemedPalette private var palette + @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue + + private var selectedTheme: AppTheme { + AppTheme(rawValue: appThemeRawValue) ?? .matrix } + + private var backgroundColor: Color { palette.background } + + private var textColor: Color { palette.primary } + + private var secondaryTextColor: Color { palette.secondary } // MARK: - Constants private enum Strings { static let appName: LocalizedStringKey = "app_info.app_name" static let tagline: LocalizedStringKey = "app_info.tagline" + static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title" enum Features { static let title: LocalizedStringKey = "app_info.features.title" @@ -101,12 +101,12 @@ struct AppInfoView: View { .foregroundColor(textColor) .padding() } - .background(backgroundColor.opacity(0.95)) - + .themedSurface(opacity: 0.95) + ScrollView { infoContent } - .background(backgroundColor) + .themedSheetBackground() } .frame(width: 600, height: 700) #else @@ -114,13 +114,13 @@ struct AppInfoView: View { ScrollView { infoContent } - .background(backgroundColor) + .themedSheetBackground() .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { dismiss() }) { Image(systemName: "xmark") - .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 13, weight: .semibold) .foregroundColor(textColor) .frame(width: 32, height: 32) } @@ -138,16 +138,40 @@ struct AppInfoView: View { // Header VStack(alignment: .center, spacing: 8) { Text(Strings.appName) - .font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced)) + .bitchatFont(size: 32, weight: .bold) .foregroundColor(textColor) Text(Strings.tagline) - .font(.bitchatSystem(size: 16, design: .monospaced)) + .bitchatFont(size: 16) .foregroundColor(secondaryTextColor) } .frame(maxWidth: .infinity) .padding(.vertical) - + + // Appearance — single row: label left, theme chips right + HStack(spacing: 12) { + SectionHeader(Strings.appearanceTitle) + Spacer() + ForEach(AppTheme.allCases) { theme in + Button { + appThemeRawValue = theme.rawValue + } label: { + Text(theme.displayNameKey) + .bitchatFont(size: 13, weight: selectedTheme == theme ? .semibold : .regular) + .foregroundColor(selectedTheme == theme ? palette.accent : secondaryTextColor) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(selectedTheme == theme ? palette.accent.opacity(0.15) : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selectedTheme == theme ? .isSelected : []) + } + } + // How to Use VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.HowToUse.title) @@ -157,7 +181,7 @@ struct AppInfoView: View { Text(instruction) } } - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(textColor) } @@ -201,19 +225,17 @@ struct AppInfoFeatureInfo { struct SectionHeader: View { let title: LocalizedStringKey - @Environment(\.colorScheme) var colorScheme - - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - + @ThemedPalette private var palette + + private var textColor: Color { palette.primary } + init(_ title: LocalizedStringKey) { self.title = title } var body: some View { Text(title) - .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) + .bitchatFont(size: 16, weight: .bold) .foregroundColor(textColor) .padding(.top, 8) } @@ -221,16 +243,12 @@ struct SectionHeader: View { struct FeatureRow: View { let info: AppInfoFeatureInfo - @Environment(\.colorScheme) var colorScheme - - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - - private var secondaryTextColor: Color { - colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) - } - + @ThemedPalette private var palette + + private var textColor: Color { palette.primary } + + private var secondaryTextColor: Color { palette.secondary } + var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: info.icon) @@ -240,11 +258,11 @@ struct FeatureRow: View { VStack(alignment: .leading, spacing: 4) { Text(info.title) - .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 14, weight: .semibold) .foregroundColor(textColor) Text(info.description) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) } diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index 95753611..786a3caf 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -10,13 +10,10 @@ import SwiftUI struct CommandSuggestionsView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - + @ThemedPalette private var palette + @Binding var messageText: String - - let textColor: Color - let backgroundColor: Color - let secondaryTextColor: Color - + private var filteredCommands: [CommandInfo] { guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] } let isGeoPublic = locationChannelsModel.selectedChannel.isLocation @@ -27,42 +24,43 @@ struct CommandSuggestionsView: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(filteredCommands) { command in - Button { - messageText = command.alias + " " - } label: { - buttonRow(for: command) + // Render nothing when there are no matches: a zero-height view would + // still receive the composer VStack's spacing and push the input row + // off-center. + if !filteredCommands.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(filteredCommands) { command in + Button { + messageText = command.alias + " " + } label: { + buttonRow(for: command) + } + .buttonStyle(.plain) + .background(Color.gray.opacity(0.1)) } - .buttonStyle(.plain) - .background(Color.gray.opacity(0.1)) } + .themedOverlayPanel() } - .background(backgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) - ) } private func buttonRow(for command: CommandInfo) -> some View { HStack { Text(command.alias) - .font(.bitchatSystem(size: 11, design: .monospaced)) - .foregroundColor(textColor) + .bitchatFont(size: 11) + .foregroundColor(palette.primary) .fontWeight(.medium) - + if let placeholder = command.placeholder { Text(placeholder) - .font(.bitchatSystem(size: 10, design: .monospaced)) - .foregroundColor(secondaryTextColor.opacity(0.8)) + .bitchatFont(size: 10) + .foregroundColor(palette.secondary.opacity(0.8)) } Spacer() - + Text(command.description) - .font(.bitchatSystem(size: 10, design: .monospaced)) - .foregroundColor(secondaryTextColor) + .bitchatFont(size: 10) + .foregroundColor(palette.secondary) } .padding(.horizontal, 12) .padding(.vertical, 3) @@ -85,12 +83,7 @@ struct CommandSuggestionsView: View { ) let locationChannelsModel = LocationChannelsModel() - CommandSuggestionsView( - messageText: $messageText, - textColor: .green, - backgroundColor: .primary, - secondaryTextColor: .secondary - ) - .environmentObject(privateConversationModel) - .environmentObject(locationChannelsModel) + CommandSuggestionsView(messageText: $messageText) + .environmentObject(privateConversationModel) + .environmentObject(locationChannelsModel) } diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index 4f3025ec..563bd9aa 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -10,18 +10,14 @@ import SwiftUI import BitFoundation struct DeliveryStatusView: View { - @Environment(\.colorScheme) private var colorScheme + @ThemedPalette private var palette let status: DeliveryStatus // MARK: - Computed Properties - - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - - private var secondaryTextColor: Color { - colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) - } + + private var textColor: Color { palette.primary } + + private var secondaryTextColor: Color { palette.secondary } private enum Strings { static func delivered(to nickname: String) -> String { @@ -89,7 +85,7 @@ struct DeliveryStatusView: View { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10, weight: .bold)) } - .foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue + .foregroundColor(palette.accentBlue) .help(Strings.read(by: nickname)) case .failed(let reason): @@ -103,7 +99,7 @@ struct DeliveryStatusView: View { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) Text(verbatim: "\(reached)/\(total)") - .font(.bitchatSystem(size: 10, design: .monospaced)) + .bitchatFont(size: 10) } .foregroundColor(secondaryTextColor.opacity(0.6)) .help(Strings.deliveredToMembers(reached, total)) diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index 00efc924..605f7b47 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -11,6 +11,7 @@ import SwiftUI struct PaymentChipView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL + @ThemedPalette private var palette enum PaymentType { case cashu(String) @@ -54,9 +55,7 @@ struct PaymentChipView: View { let paymentType: PaymentType - private var fgColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } + private var fgColor: Color { palette.primary } private var bgColor: Color { colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) } @@ -73,7 +72,7 @@ struct PaymentChipView: View { HStack(spacing: 6) { Text(paymentType.emoji) Text(paymentType.label) - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 12, weight: .semibold) } .padding(.vertical, 6) .padding(.horizontal, 12) diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index c64f6fd5..0b84961d 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -11,6 +11,7 @@ import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme + @Environment(\.appTheme) private var theme @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage @@ -37,7 +38,7 @@ struct TextMessageView: View { HStack(alignment: .top, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) - Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme)) + Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) .fixedSize(horizontal: false, vertical: true) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) @@ -58,7 +59,7 @@ struct TextMessageView: View { if isExpanded { expandedMessageIDs.remove(message.id) } else { expandedMessageIDs.insert(message.id) } } - .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) + .bitchatFont(size: 11, weight: .medium) .foregroundColor(Color.blue) .padding(.top, 4) } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index 424a649b..a643487a 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -6,16 +6,14 @@ import UIKit struct ContentComposerView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel - @Environment(\.colorScheme) private var colorScheme + @Environment(\.appTheme) private var theme + @ThemedPalette private var palette @Binding var messageText: String var isTextFieldFocused: FocusState.Binding @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @Binding var autocompleteDebounceTimer: Timer? - let backgroundColor: Color - let textColor: Color - let secondaryTextColor: Color let onSendMessage: () -> Void #if os(iOS) @@ -35,8 +33,8 @@ struct ContentComposerView: View { }) { HStack { Text(suggestion) - .font(.bitchatSystem(size: 11, design: .monospaced)) - .foregroundColor(textColor) + .bitchatFont(size: 11) + .foregroundColor(palette.primary) .fontWeight(.medium) Spacer() } @@ -48,20 +46,11 @@ struct ContentComposerView: View { .background(Color.gray.opacity(0.1)) } } - .background(backgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) - ) + .themedOverlayPanel() .padding(.horizontal, 12) } - CommandSuggestionsView( - messageText: $messageText, - textColor: textColor, - backgroundColor: backgroundColor, - secondaryTextColor: secondaryTextColor - ) + CommandSuggestionsView(messageText: $messageText) if voiceRecordingVM.state.isActive { recordingIndicator @@ -74,11 +63,11 @@ struct ContentComposerView: View { prompt: Text( String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") ) - .foregroundColor(secondaryTextColor.opacity(0.6)) + .foregroundColor(palette.secondary.opacity(0.6)) ) .textFieldStyle(.plain) - .font(.bitchatSystem(size: 15, design: .monospaced)) - .foregroundColor(textColor) + .bitchatFont(size: 15) + .foregroundColor(palette.primary) .focused(isTextFieldFocused) .autocorrectionDisabled(true) #if os(iOS) @@ -86,12 +75,9 @@ struct ContentComposerView: View { #endif .submitLabel(.send) .onSubmit(onSendMessage) - .padding(.vertical, 4) + .padding(.vertical, theme.usesGlassChrome ? 8 : 4) .padding(.horizontal, 6) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) - ) + .themedInputBackground() .modifier(FocusEffectDisabledModifier()) .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: messageText) { newValue in @@ -114,9 +100,9 @@ struct ContentComposerView: View { } } .padding(.horizontal, 6) - .padding(.top, 6) + .padding(.top, theme.usesGlassChrome ? 8 : 6) .padding(.bottom, 8) - .background(backgroundColor.opacity(0.95)) + .themedChromePanel(edge: .bottom) .onDisappear { autocompleteDebounceTimer?.invalidate() } @@ -134,7 +120,7 @@ private extension ContentComposerView { "recording \(voiceRecordingVM.formattedDuration(for: context.date))", comment: "Voice note recording duration indicator" ) - .font(.bitchatSystem(size: 13, design: .monospaced)) + .bitchatFont(size: 13) .foregroundColor(.red) } Spacer() @@ -154,7 +140,7 @@ private extension ContentComposerView { } var composerAccentColor: Color { - privateConversationModel.selectedPeerID != nil ? Color.orange : textColor + privateConversationModel.selectedPeerID != nil ? Color.orange : palette.accent } var attachmentButton: some View { diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index baa15034..3c20baa7 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -8,8 +8,9 @@ struct ContentHeaderView: View { @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel - @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.appTheme) private var theme + @ThemedPalette private var palette @Binding var showSidebar: Bool @Binding var showVerifySheet: Bool @@ -20,15 +21,12 @@ struct ContentHeaderView: View { let headerHeight: CGFloat let headerPeerIconSize: CGFloat let headerPeerCountFontSize: CGFloat - let backgroundColor: Color - let textColor: Color - let secondaryTextColor: Color var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") - .font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) + .bitchatFont(size: 18, weight: .medium) + .foregroundColor(palette.primary) .onTapGesture(count: 3) { appChromeModel.panicClearAllData() } @@ -38,8 +36,8 @@ struct ContentHeaderView: View { HStack(spacing: 0) { Text(verbatim: "@") - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) TextField( "content.input.nickname_placeholder", @@ -49,9 +47,9 @@ struct ContentHeaderView: View { ) ) .textFieldStyle(.plain) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .frame(maxWidth: 80) - .foregroundColor(textColor) + .foregroundColor(palette.primary) .focused(isNicknameFieldFocused) .autocorrectionDisabled(true) #if os(iOS) @@ -79,12 +77,13 @@ struct ContentHeaderView: View { return countAndColor.0 }() - HStack(spacing: 10) { + 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( @@ -99,13 +98,10 @@ struct ContentHeaderView: View { notesGeohash = locationChannelsModel.currentBuildingGeohash showLocationNotes = true }) { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "note.text") - .font(.bitchatSystem(size: 12)) - .foregroundColor(Color.orange.opacity(0.8)) - .padding(.top, 1) - } - .fixedSize(horizontal: true, vertical: false) + Image(systemName: "note.text") + .font(.bitchatSystem(size: 12)) + .foregroundColor(Color.orange.opacity(0.8)) + .headerTapTarget() } .buttonStyle(.plain) .accessibilityLabel( @@ -117,6 +113,7 @@ struct ContentHeaderView: View { Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 12)) + .headerTapTarget() } .buttonStyle(.plain) .accessibilityLabel( @@ -140,49 +137,54 @@ struct ContentHeaderView: View { case .mesh: return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) case .location: - return colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + return palette.locationAccent } }() Text(badgeText) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .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) - .padding(.leading, 4) - .padding(.trailing, 2) - HStack(spacing: 4) { - Image(systemName: "person.2.fill") - .font(.system(size: headerPeerIconSize, weight: .regular)) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), - locale: .current, - headerOtherPeersCount - ) - ) - Text("\(headerOtherPeersCount)") - .font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced)) - .accessibilityHidden(true) + 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()) } - .foregroundColor(headerCountColor) - .padding(.leading, 2) - .lineLimit(headerLineLimit) - .fixedSize(horizontal: true, vertical: false) + .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) - .onTapGesture { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - showSidebar.toggle() - } - } .sheet(isPresented: $showVerifySheet) { VerificationSheetView(isPresented: $showVerifySheet) .environmentObject(verificationModel) @@ -208,10 +210,7 @@ struct ContentHeaderView: View { } else { ContentLocationNotesUnavailableView( showLocationNotes: $showLocationNotes, - headerHeight: headerHeight, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor + headerHeight: headerHeight ) .environmentObject(locationChannelsModel) } @@ -249,7 +248,16 @@ struct ContentHeaderView: View { } message: { Text("content.alert.screenshot.message") } - .background(backgroundColor.opacity(0.95)) + .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()) } } @@ -262,8 +270,7 @@ private extension ContentHeaderView { switch locationChannelsModel.selectedChannel { case .location: let count = peerListModel.visibleGeohashPeerCount - let standardGreen = colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - return (count, count > 0 ? standardGreen : Color.secondary) + 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 @@ -274,24 +281,22 @@ private extension ContentHeaderView { private struct ContentLocationNotesUnavailableView: View { @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @ThemedPalette private var palette @Binding var showLocationNotes: Bool let headerHeight: CGFloat - let backgroundColor: Color - let textColor: Color - let secondaryTextColor: Color var body: some View { VStack(spacing: 12) { HStack { Text("content.notes.title") - .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) + .bitchatFont(size: 16, weight: .bold) Spacer() Button(action: { showLocationNotes = false }) { Image(systemName: "xmark") - .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) + .bitchatFont(size: 13, weight: .semibold) + .foregroundColor(palette.primary) .frame(width: 32, height: 32) } .buttonStyle(.plain) @@ -299,17 +304,17 @@ private struct ContentLocationNotesUnavailableView: View { } .frame(height: headerHeight) .padding(.horizontal, 12) - .background(backgroundColor.opacity(0.95)) + .themedChromePanel(edge: .top) Text("content.notes.location_unavailable") - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) Button("content.location.enable") { locationChannelsModel.enableAndRefresh() } .buttonStyle(.bordered) Spacer() } - .background(backgroundColor) - .foregroundColor(textColor) + .themedSheetBackground() + .foregroundColor(palette.primary) } } diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 35edede8..9e4aaa48 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -25,10 +25,8 @@ struct ContentPeopleSheetView: View { var isTextFieldFocused: FocusState.Binding @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @Binding var autocompleteDebounceTimer: Timer? + @ThemedPalette private var palette - let backgroundColor: Color - let textColor: Color - let secondaryTextColor: Color let headerHeight: CGFloat let onSendMessage: () -> Void @@ -56,9 +54,6 @@ struct ContentPeopleSheetView: View { isTextFieldFocused: isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, headerHeight: headerHeight, onSendMessage: onSendMessage, showImagePicker: $showImagePicker, @@ -77,9 +72,6 @@ struct ContentPeopleSheetView: View { isTextFieldFocused: isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, headerHeight: headerHeight, onSendMessage: onSendMessage, showMacImagePicker: $showMacImagePicker @@ -88,9 +80,6 @@ struct ContentPeopleSheetView: View { } else { ContentPeopleListView( showSidebar: $showSidebar, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, headerHeight: headerHeight ) } @@ -109,8 +98,8 @@ struct ContentPeopleSheetView: View { } } } - .background(backgroundColor) - .foregroundColor(textColor) + .themedSheetBackground() + .foregroundColor(palette.primary) #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif @@ -148,12 +137,10 @@ private struct ContentPeopleListView: View { @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel @Environment(\.dismiss) private var dismiss + @ThemedPalette private var palette @Binding var showSidebar: Bool - let backgroundColor: Color - let textColor: Color - let secondaryTextColor: Color let headerHeight: CGFloat @State private var showVerifySheet = false @@ -163,8 +150,8 @@ private struct ContentPeopleListView: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { Text(peopleSheetTitle) - .font(.bitchatSystem(size: 18, design: .monospaced)) - .foregroundColor(textColor) + .bitchatFont(size: 18) + .foregroundColor(palette.primary) Spacer() if case .mesh = locationChannelsModel.selectedChannel { Button(action: { showVerifySheet = true }) { @@ -185,7 +172,7 @@ private struct ContentPeopleListView: View { } }) { Image(systemName: "xmark") - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 12, weight: .semibold) .frame(width: 32, height: 32) } .buttonStyle(.plain) @@ -201,9 +188,9 @@ private struct ContentPeopleListView: View { let subtitleColor: Color = { switch locationChannelsModel.selectedChannel { case .mesh: - return Color.blue + return palette.accentBlue case .location: - return Color.green + return palette.locationAccent } }() @@ -213,32 +200,28 @@ private struct ContentPeopleListView: View { Text(activeText) .foregroundColor(.secondary) } - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) } else { Text(activeText) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 12) - .background(backgroundColor) + .themedSurface() ScrollView { VStack(alignment: .leading, spacing: 6) { if case .location = locationChannelsModel.selectedChannel { GeohashPeopleList( - textColor: textColor, - secondaryTextColor: secondaryTextColor, onTapPerson: { showSidebar = true } ) } else { MeshPeerList( - textColor: textColor, - secondaryTextColor: secondaryTextColor, onTapPeer: { peerID in peerListModel.startConversation(with: peerID) showSidebar = true @@ -302,10 +285,9 @@ private struct ContentPrivateChatSheetView: View { var isTextFieldFocused: FocusState.Binding @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @Binding var autocompleteDebounceTimer: Timer? + @Environment(\.appTheme) private var theme + @ThemedPalette private var palette - let backgroundColor: Color - let textColor: Color - let secondaryTextColor: Color let headerHeight: CGFloat let onSendMessage: () -> Void @@ -327,7 +309,7 @@ private struct ContentPrivateChatSheetView: View { }) { Image(systemName: "chevron.left") .font(.bitchatSystem(size: 12)) - .foregroundColor(textColor) + .foregroundColor(palette.primary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } @@ -341,8 +323,7 @@ private struct ContentPrivateChatSheetView: View { HStack(spacing: 8) { ContentPrivateHeaderInfoButton( headerState: headerState, - headerHeight: headerHeight, - textColor: textColor + headerHeight: headerHeight ) if headerState.supportsFavoriteToggle { @@ -351,7 +332,7 @@ private struct ContentPrivateChatSheetView: View { }) { Image(systemName: headerState.isFavorite ? "star.fill" : "star") .font(.bitchatSystem(size: 14)) - .foregroundColor(headerState.isFavorite ? Color.yellow : textColor) + .foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) } .buttonStyle(.plain) .accessibilityLabel( @@ -372,7 +353,7 @@ private struct ContentPrivateChatSheetView: View { } }) { Image(systemName: "xmark") - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 12, weight: .semibold) .frame(width: 32, height: 32) } .buttonStyle(.plain) @@ -382,7 +363,7 @@ private struct ContentPrivateChatSheetView: View { .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 12) - .background(backgroundColor) + .themedSurface() } MessageListView( @@ -397,10 +378,12 @@ private struct ContentPrivateChatSheetView: View { showSidebar: $showSidebar, isTextFieldFocused: isTextFieldFocused ) - .background(backgroundColor) + .themedSurface() .frame(maxWidth: .infinity, maxHeight: .infinity) - Divider() + if !theme.usesGlassChrome { + Divider() + } #if os(iOS) ContentComposerView( @@ -408,9 +391,6 @@ private struct ContentPrivateChatSheetView: View { isTextFieldFocused: isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, onSendMessage: onSendMessage, showImagePicker: $showImagePicker, imagePickerSourceType: $imagePickerSourceType @@ -421,16 +401,13 @@ private struct ContentPrivateChatSheetView: View { isTextFieldFocused: isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, onSendMessage: onSendMessage, showMacImagePicker: $showMacImagePicker ) #endif } - .background(backgroundColor) - .foregroundColor(textColor) + .themedSheetBackground() + .foregroundColor(palette.primary) .highPriorityGesture( DragGesture(minimumDistance: 25, coordinateSpace: .local) .onEnded { value in @@ -448,10 +425,10 @@ private struct ContentPrivateChatSheetView: View { private struct ContentPrivateHeaderInfoButton: View { @EnvironmentObject private var appChromeModel: AppChromeModel + @ThemedPalette private var palette let headerState: PrivateConversationHeaderState let headerHeight: CGFloat - let textColor: Color var body: some View { Button(action: { @@ -462,12 +439,12 @@ private struct ContentPrivateHeaderInfoButton: View { case .bluetoothConnected: Image(systemName: "dot.radiowaves.left.and.right") .font(.bitchatSystem(size: 14)) - .foregroundColor(textColor) + .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(textColor) + .foregroundColor(palette.primary) .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) case .nostrAvailable: Image(systemName: "globe") @@ -479,8 +456,8 @@ private struct ContentPrivateHeaderInfoButton: View { } Text(headerState.displayName) - .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) + .bitchatFont(size: 16, weight: .medium) + .foregroundColor(palette.primary) if let encryptionStatus = headerState.encryptionStatus, let icon = encryptionStatus.icon { @@ -488,7 +465,7 @@ private struct ContentPrivateHeaderInfoButton: View { .font(.bitchatSystem(size: 14)) .foregroundColor( encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured - ? textColor + ? palette.primary : Color.red ) .accessibilityLabel( diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 420f72c6..86bb31ad 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -40,6 +40,7 @@ struct ContentView: View { @State private var messageText = "" @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme + @Environment(\.appTheme) private var appTheme @State private var showSidebar = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @@ -63,39 +64,19 @@ struct ContentView: View { @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [PeerID: Int] = [:] - private var backgroundColor: Color { - colorScheme == .dark ? Color.black : Color.white - } - - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - - private var secondaryTextColor: Color { - colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) - } + @ThemedPalette private var palette private var selectedPrivatePeerID: PeerID? { privateConversationModel.selectedPeerID } + private var usesGlassLayout: Bool { appTheme.usesGlassChrome } + var body: some View { - VStack(spacing: 0) { - ContentHeaderView( - showSidebar: $showSidebar, - showVerifySheet: $showVerifySheet, - showLocationNotes: $showLocationNotes, - notesGeohash: $notesGeohash, - isNicknameFieldFocused: $isNicknameFieldFocused, - headerHeight: headerHeight, - headerPeerIconSize: headerPeerIconSize, - headerPeerCountFontSize: headerPeerCountFontSize, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor - ) + mainContent .onAppear { conversationUIModel.setCurrentColorScheme(colorScheme) + conversationUIModel.setCurrentTheme(appTheme) #if os(macOS) DispatchQueue.main.async { isNicknameFieldFocused = false @@ -106,62 +87,11 @@ struct ContentView: View { .onChange(of: colorScheme) { newValue in conversationUIModel.setCurrentColorScheme(newValue) } - - Divider() - - GeometryReader { geometry in - VStack(spacing: 0) { - MessageListView( - privatePeer: nil, - isAtBottom: $isAtBottomPublic, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - showSidebar: $showSidebar, - isTextFieldFocused: $isTextFieldFocused - ) - .background(backgroundColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(width: geometry.size.width, height: geometry.size.height) + .onChange(of: appTheme) { newValue in + conversationUIModel.setCurrentTheme(newValue) } - - Divider() - - if selectedPrivatePeerID == nil { - #if os(iOS) - ContentComposerView( - messageText: $messageText, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, - onSendMessage: sendMessage, - showImagePicker: $showImagePicker, - imagePickerSourceType: $imagePickerSourceType - ) - #else - ContentComposerView( - messageText: $messageText, - isTextFieldFocused: $isTextFieldFocused, - voiceRecordingVM: voiceRecordingVM, - autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, - onSendMessage: sendMessage, - showMacImagePicker: $showMacImagePicker - ) - #endif - } - } - .background(backgroundColor) - .foregroundColor(textColor) + .background(ThemedRootBackground()) + .foregroundColor(palette.primary) #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif @@ -194,9 +124,6 @@ struct ContentView: View { isTextFieldFocused: $isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, headerHeight: headerHeight, onSendMessage: sendMessage, showImagePicker: $showImagePicker, @@ -215,9 +142,6 @@ struct ContentView: View { isTextFieldFocused: $isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: backgroundColor, - textColor: textColor, - secondaryTextColor: secondaryTextColor, headerHeight: headerHeight, onSendMessage: sendMessage, showMacImagePicker: $showMacImagePicker @@ -302,6 +226,96 @@ struct ContentView: View { } } + /// Matrix: classic opaque bars with dividers. Glass: full-bleed message + /// list scrolling underneath floating chrome panels (safe-area insets), + /// so the translucency gains usable space instead of losing it. + @ViewBuilder + private var mainContent: some View { + if usesGlassLayout { + publicMessageList + .safeAreaInset(edge: .top, spacing: 0) { + headerView + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if selectedPrivatePeerID == nil { + composerView + } + } + } else { + VStack(spacing: 0) { + headerView + + Divider() + + GeometryReader { geometry in + VStack(spacing: 0) { + publicMessageList + .background(palette.background) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + + Divider() + + if selectedPrivatePeerID == nil { + composerView + } + } + } + } + + private var headerView: some View { + ContentHeaderView( + showSidebar: $showSidebar, + showVerifySheet: $showVerifySheet, + showLocationNotes: $showLocationNotes, + notesGeohash: $notesGeohash, + isNicknameFieldFocused: $isNicknameFieldFocused, + headerHeight: headerHeight, + headerPeerIconSize: headerPeerIconSize, + headerPeerCountFontSize: headerPeerCountFontSize + ) + } + + private var publicMessageList: some View { + MessageListView( + privatePeer: nil, + isAtBottom: $isAtBottomPublic, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + showSidebar: $showSidebar, + isTextFieldFocused: $isTextFieldFocused + ) + } + + private var composerView: some View { + #if os(iOS) + ContentComposerView( + messageText: $messageText, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + onSendMessage: sendMessage, + showImagePicker: $showImagePicker, + imagePickerSourceType: $imagePickerSourceType + ) + #else + ContentComposerView( + messageText: $messageText, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + onSendMessage: sendMessage, + showMacImagePicker: $showMacImagePicker + ) + #endif + } + private func sendMessage() { guard let trimmed = messageText.trimmedOrNilIfEmpty else { return } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index d49007a1..cbac2d2a 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -13,15 +13,11 @@ struct FingerprintView: View { @EnvironmentObject private var verificationModel: VerificationModel let peerID: PeerID @Environment(\.dismiss) var dismiss - @Environment(\.colorScheme) var colorScheme - - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - - private var backgroundColor: Color { - colorScheme == .dark ? Color.black : Color.white - } + @ThemedPalette private var palette + + private var textColor: Color { palette.primary } + + private var backgroundColor: Color { palette.background } private enum Strings { static let title: LocalizedStringKey = "fingerprint.title" @@ -53,7 +49,7 @@ struct FingerprintView: View { // Header HStack { Text(Strings.title) - .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) + .bitchatFont(size: 16, weight: .bold) .foregroundColor(textColor) Spacer() @@ -76,11 +72,11 @@ struct FingerprintView: View { VStack(alignment: .leading, spacing: 4) { Text(fingerprintState.peerNickname) - .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 18, weight: .semibold) .foregroundColor(textColor) Text(fingerprintState.encryptionStatus.description) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(textColor.opacity(0.7)) } @@ -93,12 +89,12 @@ struct FingerprintView: View { // Their fingerprint VStack(alignment: .leading, spacing: 8) { Text(Strings.theirFingerprint) - .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) + .bitchatFont(size: 12, weight: .bold) .foregroundColor(textColor.opacity(0.7)) if let fingerprint = fingerprintState.theirFingerprint { Text(formatFingerprint(fingerprint)) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(nil) @@ -119,7 +115,7 @@ struct FingerprintView: View { } } else { Text(Strings.handshakePending) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(Color.orange) .padding() } @@ -128,11 +124,11 @@ struct FingerprintView: View { // My fingerprint VStack(alignment: .leading, spacing: 8) { Text(Strings.yourFingerprint) - .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) + .bitchatFont(size: 12, weight: .bold) .foregroundColor(textColor.opacity(0.7)) Text(formatFingerprint(fingerprintState.myFingerprint)) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(nil) @@ -157,7 +153,7 @@ struct FingerprintView: View { if fingerprintState.canToggleVerification { VStack(spacing: 12) { Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) - .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) + .bitchatFont(size: 14, weight: .bold) .foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange) .frame(maxWidth: .infinity) @@ -168,7 +164,7 @@ struct FingerprintView: View { Text(Strings.verifyHint(fingerprintState.peerNickname)) } } - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(textColor.opacity(0.7)) .multilineTextAlignment(.center) .lineLimit(nil) @@ -181,7 +177,7 @@ struct FingerprintView: View { dismiss() }) { Text(Strings.markVerified) - .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) + .bitchatFont(size: 14, weight: .bold) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) @@ -195,7 +191,7 @@ struct FingerprintView: View { dismiss() }) { Text(Strings.removeVerification) - .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) + .bitchatFont(size: 14, weight: .bold) .foregroundColor(.white) .padding(.horizontal, 20) .padding(.vertical, 10) @@ -216,7 +212,7 @@ struct FingerprintView: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(backgroundColor) + .themedSheetBackground() } private func formatFingerprint(_ fingerprint: String) -> String { diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 4a3f0f12..4582e706 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -2,8 +2,7 @@ import SwiftUI struct GeohashPeopleList: View { @EnvironmentObject private var peerListModel: PeerListModel - let textColor: Color - let secondaryTextColor: Color + @ThemedPalette private var palette let onTapPerson: () -> Void @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] @@ -20,8 +19,8 @@ struct GeohashPeopleList: View { if peerListModel.geohashPeople.isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) .padding(.horizontal) .padding(.top, 12) } @@ -52,18 +51,18 @@ struct GeohashPeopleList: View { let (base, suffix) = person.displayName.splitSuffix() HStack(spacing: 0) { Text(base) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .fontWeight(person.isMe ? .bold : .regular) .foregroundColor(rowColor) if !suffix.isEmpty { let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) Text(suffix) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(suffixColor) } if person.isMe { Text(Strings.youSuffix) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(rowColor) } } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index a4046c42..addc835c 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -9,11 +9,11 @@ struct LocationChannelsSheet: View { @Binding var isPresented: Bool @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel - @Environment(\.colorScheme) var colorScheme + @ThemedPalette private var palette @State private var customGeohash: String = "" @State private var customError: String? = nil - private var backgroundColor: Color { colorScheme == .dark ? .black : .white } + private var backgroundColor: Color { palette.background } private enum Strings { static let title: LocalizedStringKey = "location_channels.title" @@ -97,12 +97,12 @@ struct LocationChannelsSheet: View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { Text(Strings.title) - .font(.bitchatSystem(size: 18, design: .monospaced)) + .bitchatFont(size: 18) Spacer() closeButton } Text(Strings.description) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) Group { @@ -110,7 +110,7 @@ struct LocationChannelsSheet: View { case .notDetermined: Button(action: { locationChannelsModel.enableLocationChannels() }) { Text(Strings.requestPermissions) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(standardGreen) .frame(maxWidth: .infinity) .padding(.vertical, 6) @@ -121,7 +121,7 @@ struct LocationChannelsSheet: View { case .denied, .restricted: VStack(alignment: .leading, spacing: 8) { Text(Strings.permissionDenied) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) Button(Strings.openSettings, action: SystemSettings.location.open) .buttonStyle(.plain) @@ -136,7 +136,7 @@ struct LocationChannelsSheet: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(backgroundColor) + .themedSurface() #if os(iOS) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) @@ -147,7 +147,7 @@ struct LocationChannelsSheet: View { #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif - .background(backgroundColor) + .themedSheetBackground() .onAppear { // Refresh channels when opening if locationChannelsModel.permissionState == .authorized { @@ -171,7 +171,7 @@ struct LocationChannelsSheet: View { private var closeButton: some View { Button(action: { isPresented = false }) { Image(systemName: "xmark") - .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 13, weight: .semibold) .frame(width: 32, height: 32) } .buttonStyle(.plain) @@ -223,7 +223,7 @@ struct LocationChannelsSheet: View { HStack(spacing: 8) { ProgressView() Text(Strings.loadingNearby) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) @@ -246,8 +246,8 @@ struct LocationChannelsSheet: View { .padding(.top, 12) Button(action: SystemSettings.location.open) { Text(Strings.removeAccess) - .font(.bitchatSystem(size: 12, design: .monospaced)) - .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) + .bitchatFont(size: 12) + .foregroundColor(palette.alertRed) .frame(maxWidth: .infinity) .padding(.vertical, 6) .background(Color.red.opacity(0.08)) @@ -259,9 +259,9 @@ struct LocationChannelsSheet: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) - .background(backgroundColor) + .themedSurface() } - .background(backgroundColor) + .themedSurface() } private var sectionDivider: some View { @@ -270,15 +270,13 @@ struct LocationChannelsSheet: View { .frame(height: 1) } - private var dividerColor: Color { - colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) - } + private var dividerColor: Color { palette.divider } private var customTeleportSection: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 2) { Text(verbatim: "#") - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(.secondary) TextField("geohash", text: $customGeohash) #if os(iOS) @@ -286,7 +284,7 @@ struct LocationChannelsSheet: View { .autocorrectionDisabled(true) .keyboardType(.asciiCapable) #endif - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .onChange(of: customGeohash) { newValue in let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let filtered = newValue @@ -312,13 +310,13 @@ struct LocationChannelsSheet: View { }) { HStack(spacing: 6) { Text(Strings.teleport) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) Image(systemName: "face.dashed") .font(.bitchatSystem(size: 14)) } } .buttonStyle(.plain) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color.secondary.opacity(0.12)) @@ -328,7 +326,7 @@ struct LocationChannelsSheet: View { } if let err = customError { Text(err) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.red) } } @@ -337,7 +335,7 @@ struct LocationChannelsSheet: View { private func bookmarkedSection(_ entries: [String]) -> some View { VStack(alignment: .leading, spacing: 8) { Text(Strings.bookmarked) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) LazyVStack(spacing: 0) { ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in @@ -409,18 +407,18 @@ struct LocationChannelsSheet: View { let parts = splitTitleAndCount(title) HStack(spacing: 4) { Text(parts.base) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .fontWeight(titleBold ? .bold : .regular) .foregroundColor(titleColor ?? Color.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) - .font(.bitchatSystem(size: 11, design: .monospaced)) + .bitchatFont(size: 11) .foregroundColor(.secondary) } } let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) Text(subtitleFull) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.tail) @@ -428,7 +426,7 @@ struct LocationChannelsSheet: View { Spacer() if isSelected { Text(verbatim: "✔︎") - .font(.bitchatSystem(size: 16, design: .monospaced)) + .bitchatFont(size: 16) .foregroundColor(standardGreen) } trailingAccessory() @@ -478,26 +476,22 @@ extension LocationChannelsSheet { Toggle(isOn: torToggleBinding) { VStack(alignment: .leading, spacing: 2) { Text(Strings.torTitle) - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 12, weight: .semibold) .foregroundColor(.primary) Text(Strings.torSubtitle) - .font(.bitchatSystem(size: 11, design: .monospaced)) + .bitchatFont(size: 11) .foregroundColor(.secondary) } } - .toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) + .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) } .padding(12) .background(Color.secondary.opacity(0.12)) .cornerRadius(8) } - private var standardGreen: Color { - (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - private var standardBlue: Color { - Color(red: 0.0, green: 0.478, blue: 1.0) - } + private var standardGreen: Color { palette.primary } + private var standardBlue: Color { palette.accentBlue } } private struct IRCToggleStyle: ToggleStyle { @@ -512,7 +506,7 @@ private struct IRCToggleStyle: ToggleStyle { Spacer() Text(configuration.isOn ? onLabel : offLabel) .textCase(.uppercase) - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 12, weight: .semibold) .foregroundColor(configuration.isOn ? accent : .secondary) .padding(.vertical, 4) .padding(.horizontal, 10) diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index 4124a87d..9c79f21e 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -6,7 +6,7 @@ struct LocationNotesView: View { let senderNickname: String let onNotesCountChanged: ((Int) -> Void)? - @Environment(\.colorScheme) var colorScheme + @ThemedPalette private var palette @Environment(\.dynamicTypeSize) private var dynamicTypeSize @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @Environment(\.dismiss) private var dismiss @@ -25,8 +25,8 @@ struct LocationNotesView: View { _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) } - private var backgroundColor: Color { colorScheme == .dark ? .black : .white } - private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) } + private var backgroundColor: Color { palette.background } + private var accentGreen: Color { palette.accent } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private enum Strings { @@ -53,11 +53,11 @@ struct LocationNotesView: View { notesContent } } - .background(backgroundColor) + .themedSurface() inputSection } .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) - .background(backgroundColor) + .themedSheetBackground() .onDisappear { manager.cancel() } .onChange(of: geohash) { newValue in manager.setGeohash(newValue) @@ -76,7 +76,7 @@ struct LocationNotesView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) inputSection } - .background(backgroundColor) + .themedSurface() #if os(iOS) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) @@ -84,7 +84,7 @@ struct LocationNotesView: View { .navigationTitle("") #endif } - .background(backgroundColor) + .themedSheetBackground() .onDisappear { manager.cancel() } .onChange(of: geohash) { newValue in manager.setGeohash(newValue) @@ -99,7 +99,7 @@ struct LocationNotesView: View { private var closeButton: some View { Button(action: { dismiss() }) { Image(systemName: "xmark") - .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 13, weight: .semibold) .frame(width: 32, height: 32) } .buttonStyle(.plain) @@ -111,33 +111,33 @@ struct LocationNotesView: View { return VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { Text(headerTitle(for: count)) - .font(.bitchatSystem(size: 18, design: .monospaced)) + .bitchatFont(size: 18) Spacer() closeButton } if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty { Text(building) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(accentGreen) } else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty { Text(block) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(accentGreen) } Text(Strings.description) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) if manager.state == .noRelays { Text(Strings.relaysPaused) - .font(.bitchatSystem(size: 11, design: .monospaced)) + .bitchatFont(size: 11) .foregroundColor(.secondary) } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 12) - .background(backgroundColor) + .themedSurface() } private func headerTitle(for count: Int) -> String { @@ -176,16 +176,16 @@ struct LocationNotesView: View { return VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(verbatim: "@\(baseName)") - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 12, weight: .semibold) if !ts.isEmpty { Text(ts) - .font(.bitchatSystem(size: 11, design: .monospaced)) + .bitchatFont(size: 11) .foregroundColor(.secondary) } Spacer() } Text(note.content) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .fixedSize(horizontal: false, vertical: true) } .padding(.vertical, 4) @@ -194,12 +194,12 @@ struct LocationNotesView: View { private var noRelaysRow: some View { VStack(alignment: .leading, spacing: 4) { Text(Strings.noRelaysNearby) - .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 13, weight: .semibold) Text(Strings.relaysRetryHint) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) Button(Strings.retry) { manager.refresh() } - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .buttonStyle(.plain) } .padding(.vertical, 6) @@ -209,7 +209,7 @@ struct LocationNotesView: View { HStack(spacing: 10) { ProgressView() Text(Strings.loadingNotes) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) Spacer() } @@ -219,9 +219,9 @@ struct LocationNotesView: View { private var emptyRow: some View { VStack(alignment: .leading, spacing: 4) { Text(Strings.emptyTitle) - .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .bitchatFont(size: 13, weight: .semibold) Text(Strings.emptySubtitle) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.secondary) } .padding(.vertical, 6) @@ -231,13 +231,13 @@ struct LocationNotesView: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) Text(message) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) Spacer() } Button(Strings.dismissError) { manager.clearError() } - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .buttonStyle(.plain) } .padding(.vertical, 6) @@ -247,7 +247,7 @@ struct LocationNotesView: View { HStack(alignment: .top, spacing: 10) { TextField(Strings.addPlaceholder, text: $draft, axis: .vertical) .textFieldStyle(.plain) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .lineLimit(maxDraftLines, reservesSpace: true) .padding(.vertical, 6) Button(action: send) { @@ -261,7 +261,7 @@ struct LocationNotesView: View { } .padding(.horizontal, 16) .padding(.vertical, 14) - .background(backgroundColor) + .themedSurface() .overlay(Divider(), alignment: .top) } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 4279aaa6..8bee3bd2 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -10,6 +10,7 @@ import BitFoundation struct MediaMessageView: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.appTheme) private var theme @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage let media: BitchatMessage.Media @@ -36,7 +37,7 @@ struct MediaMessageView: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center, spacing: 4) { - Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme)) + Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index 7547893e..9b18e498 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -8,6 +8,7 @@ struct VoiceNoteView: View { private let onCancel: (() -> Void)? @Environment(\.colorScheme) private var colorScheme + @ThemedPalette private var palette @StateObject private var playback: VoiceNotePlaybackController @State private var waveform: [Float] = [] @@ -31,7 +32,7 @@ struct VoiceNoteView: View { } private var borderColor: Color { - colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2) + colorScheme == .dark ? palette.accent.opacity(0.3) : palette.accent.opacity(0.2) } private var playbackLabel: String { @@ -46,7 +47,7 @@ struct VoiceNoteView: View { Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill") .foregroundColor(.white) .frame(width: 36, height: 36) - .background(Circle().fill(Color.green)) + .background(Circle().fill(palette.accent)) } .buttonStyle(.plain) @@ -61,7 +62,7 @@ struct VoiceNoteView: View { ) Text(playbackLabel) - .font(.bitchatSystem(size: 13, design: .monospaced)) + .bitchatFont(size: 13) .foregroundColor(Color.secondary) if let onCancel = onCancel, isSending { diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift index 063ef44d..c925a1d5 100644 --- a/bitchat/Views/Media/WaveformView.swift +++ b/bitchat/Views/Media/WaveformView.swift @@ -6,6 +6,7 @@ struct WaveformView: View { let sendProgress: Double? let onSeek: ((Double) -> Void)? let isInteractive: Bool + @ThemedPalette private var palette private var clampedPlayback: Double { max(0, min(1, playbackProgress)) @@ -37,9 +38,9 @@ struct WaveformView: View { let binPosition = Double(index) / Double(samples.count) let color: Color if binPosition <= clampedPlayback { - color = Color.green + color = palette.accent } else if let send = clampedSend, binPosition <= send { - color = Color.blue + color = palette.accentBlue } else { color = Color.gray.opacity(0.35) } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 5a838230..42902202 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -3,8 +3,7 @@ import BitFoundation struct MeshPeerList: View { @EnvironmentObject private var peerListModel: PeerListModel - let textColor: Color - let secondaryTextColor: Color + @ThemedPalette private var palette let onTapPeer: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void let onShowFingerprint: (PeerID) -> Void @@ -28,8 +27,8 @@ struct MeshPeerList: View { if peerListModel.meshRows.isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) .padding(.horizontal) .padding(.top, 12) } @@ -64,18 +63,18 @@ struct MeshPeerList: View { // Fallback icon for others (dimmed) Image(systemName: "person") .font(.bitchatSystem(size: 10)) - .foregroundColor(secondaryTextColor) + .foregroundColor(palette.secondary) } let (base, suffix) = peer.displayName.splitSuffix() HStack(spacing: 0) { Text(base) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(baseColor) if !suffix.isEmpty { let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) Text(suffix) - .font(.bitchatSystem(size: 14, design: .monospaced)) + .bitchatFont(size: 14) .foregroundColor(suffixColor) } } @@ -123,7 +122,7 @@ struct MeshPeerList: View { Button(action: { onToggleFavorite(peer.peerID) }) { Image(systemName: peer.isFavorite ? "star.fill" : "star") .font(.bitchatSystem(size: 12)) - .foregroundColor(peer.isFavorite ? .yellow : secondaryTextColor) + .foregroundColor(peer.isFavorite ? .yellow : palette.secondary) } .buttonStyle(.plain) } diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index cf3c9778..c8837983 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -21,6 +21,7 @@ struct MessageListView: View { @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @Environment(\.colorScheme) private var colorScheme + @Environment(\.appTheme) private var theme let privatePeer: PeerID? @Binding var isAtBottom: Bool @@ -222,7 +223,7 @@ private extension MessageListView { @ViewBuilder func systemMessageRow(_ message: BitchatMessage) -> some View { - Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme)) + Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index acd2402b..78e0ff93 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -21,7 +21,7 @@ struct MyQRView: View { var body: some View { VStack(spacing: 12) { Text(Strings.title) - .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) + .bitchatFont(size: 16, weight: .bold) VStack(spacing: 10) { QRCodeImage(data: qrString, size: 240) @@ -29,7 +29,7 @@ struct MyQRView: View { // Non-scrolling, fully visible URL (wraps across lines) Text(qrString) - .font(.bitchatSystem(size: 11, design: .monospaced)) + .bitchatFont(size: 11) .textSelection(.enabled) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) @@ -69,7 +69,7 @@ struct QRCodeImage: View { .frame(width: size, height: size) .overlay( Text(Strings.unavailable) - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) .foregroundColor(.gray) ) } @@ -150,7 +150,7 @@ struct QRScanView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) #else Text(Strings.pastePrompt) - .font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced)) + .bitchatFont(size: 14, weight: .medium) TextEditor(text: $input) .frame(height: 100) .border(Color.gray.opacity(0.4)) @@ -281,10 +281,10 @@ struct VerificationSheetView: View { @EnvironmentObject private var verificationModel: VerificationModel @Binding var isPresented: Bool @State private var showingScanner = false - @Environment(\.colorScheme) var colorScheme + @ThemedPalette private var palette - private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } - private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } + private var backgroundColor: Color { palette.background } + private var accentColor: Color { palette.accent } private var boxColor: Color { Color.gray.opacity(0.1) } var body: some View { @@ -292,7 +292,7 @@ struct VerificationSheetView: View { // Top header (always at top) HStack { Text("verification.sheet.title") - .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) + .bitchatFont(size: 14, weight: .bold) .foregroundColor(accentColor) Spacer() Button(action: { @@ -316,7 +316,7 @@ struct VerificationSheetView: View { if showingScanner { VStack(alignment: .leading, spacing: 12) { Text("verification.scan.prompt_friend") - .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) + .bitchatFont(size: 16, weight: .bold) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .foregroundColor(accentColor) @@ -350,13 +350,13 @@ struct VerificationSheetView: View { if showingScanner { Button(action: { showingScanner = false }) { Label("show my qr", systemImage: "qrcode") - .font(.bitchatSystem(size: 13, design: .monospaced)) + .bitchatFont(size: 13) } .buttonStyle(.bordered) } else { Button(action: { showingScanner = true }) { Label("scan someone else's qr", systemImage: "camera.viewfinder") - .font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced)) + .bitchatFont(size: 13, weight: .medium) } .buttonStyle(.bordered) .tint(.gray) @@ -367,7 +367,7 @@ struct VerificationSheetView: View { verificationModel.isVerified(peerID: peerID) { Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) { Label("remove verification", systemImage: "minus.circle") - .font(.bitchatSystem(size: 12, design: .monospaced)) + .bitchatFont(size: 12) } .buttonStyle(.bordered) .tint(.gray) @@ -376,7 +376,7 @@ struct VerificationSheetView: View { .frame(maxWidth: .infinity) .padding(.vertical, 14) } - .background(backgroundColor) + .themedSheetBackground() .onDisappear { showingScanner = false } } } diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 0d6f99c6..09631d6a 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -142,9 +142,6 @@ private struct ContentPeopleSheetHarness: View { isTextFieldFocused: $isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: .black, - textColor: .green, - secondaryTextColor: .gray, headerHeight: 44, onSendMessage: {}, showImagePicker: $showImagePicker, @@ -163,9 +160,6 @@ private struct ContentPeopleSheetHarness: View { isTextFieldFocused: $isTextFieldFocused, voiceRecordingVM: voiceRecordingVM, autocompleteDebounceTimer: $autocompleteDebounceTimer, - backgroundColor: .black, - textColor: .green, - secondaryTextColor: .gray, headerHeight: 44, onSendMessage: {}, showMacImagePicker: $showMacImagePicker @@ -335,8 +329,6 @@ struct ViewSmokeTests { _ = mount( MeshPeerList( - textColor: .green, - secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } @@ -344,8 +336,6 @@ struct ViewSmokeTests { .environmentObject(featureModels.peerListModel) ) _ = MeshPeerList( - textColor: .green, - secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } @@ -363,8 +353,6 @@ struct ViewSmokeTests { _ = mount( MeshPeerList( - textColor: .green, - secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } @@ -389,10 +377,7 @@ struct ViewSmokeTests { messageText: Binding( get: { messageText }, set: { messageText = $0 } - ), - textColor: .green, - backgroundColor: .black, - secondaryTextColor: .gray + ) ) .environmentObject(featureModels.privateConversationModel) .environmentObject(featureModels.locationChannelsModel) @@ -539,8 +524,6 @@ struct ViewSmokeTests { let (viewModel, _, _) = makeSmokeViewModel() let featureModels = makeSmokeFeatureModels(for: viewModel) let geohashPeopleList = GeohashPeopleList( - textColor: .green, - secondaryTextColor: .gray, onTapPerson: {} ) let truncatableMessage = BitchatMessage( diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift index aa91eccc..ced3a9b7 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift @@ -34,12 +34,12 @@ public final class BitchatMessage: Codable { // Cached formatted text (not included in Codable) private var _cachedFormattedText: [String: AttributedString] = [:] - public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { - return _cachedFormattedText["\(isDark)-\(isSelf)"] + public func getCachedFormattedText(isDark: Bool, isSelf: Bool, variant: String = "") -> AttributedString? { + return _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] } - - public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { - _cachedFormattedText["\(isDark)-\(isSelf)"] = text + + public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool, variant: String = "") { + _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] = text } // Codable implementation