Liquid Glass theme with in-app appearance switcher (#1337)

* Centralize UI theme colors into semantic ThemePalette tokens

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

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

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

* Add liquid glass theme with in-app appearance switcher

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

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

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

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-12 01:13:04 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent c8381737bb
commit af954b05ea
27 changed files with 1347 additions and 498 deletions
+8 -4
View File
@@ -41,6 +41,10 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.currentColorScheme = colorScheme chatViewModel.currentColorScheme = colorScheme
} }
func setCurrentTheme(_ theme: AppTheme) {
chatViewModel.currentTheme = theme
}
func sendMessage(_ message: String) { func sendMessage(_ message: String) {
chatViewModel.sendMessage(message) chatViewModel.sendMessage(message)
} }
@@ -76,12 +80,12 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.completeNickname(nickname, in: &text) chatViewModel.completeNickname(nickname, in: &text)
} }
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme) chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
} }
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme) chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
} }
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? { func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
+2
View File
@@ -15,6 +15,7 @@ struct BitchatApp: App {
static let groupID = "group.\(bundleID)" static let groupID = "group.\(bundleID)"
@StateObject private var runtime: AppRuntime @StateObject private var runtime: AppRuntime
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
#if os(iOS) #if os(iOS)
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -30,6 +31,7 @@ struct BitchatApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
.environmentObject(runtime.publicChatModel) .environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel) .environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel) .environmentObject(runtime.privateConversationModel)
+537
View File
@@ -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" : { "app_info.close" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
+339
View File
@@ -0,0 +1,339 @@
//
// Theme.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<S: Shape>: 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)
)
}
}
}
+24 -22
View File
@@ -14,7 +14,8 @@ final class ChatMessageFormatter {
self.viewModel = viewModel 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 = { let isSelf: Bool = {
if let spid = message.senderPeerID { if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
@@ -40,7 +41,7 @@ final class ChatMessageFormatter {
}() }()
let isDark = colorScheme == .dark 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 return cachedText
} }
@@ -52,7 +53,7 @@ final class ChatMessageFormatter {
var senderStyle = AttributeContainer() var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium 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, if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") {
senderStyle.link = url senderStyle.link = url
@@ -79,8 +80,8 @@ final class ChatMessageFormatter {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf plainStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) ? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: .monospaced) : .bitchatSystem(size: 14, design: design)
result.append(AttributedString(content).mergingAttributes(plainStyle)) result.append(AttributedString(content).mergingAttributes(plainStyle))
} else { } else {
let hashtagRegex = Patterns.hashtag let hashtagRegex = Patterns.hashtag
@@ -197,8 +198,8 @@ final class ChatMessageFormatter {
var beforeStyle = AttributeContainer() var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf beforeStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) ? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: .monospaced) : .bitchatSystem(size: 14, design: design)
if isMentioned { if isMentioned {
beforeStyle.font = beforeStyle.font?.bold() beforeStyle.font = beforeStyle.font?.bold()
} }
@@ -230,7 +231,7 @@ final class ChatMessageFormatter {
mentionStyle.font = .bitchatSystem( mentionStyle.font = .bitchatSystem(
size: 14, size: 14,
weight: isSelf ? .bold : .semibold, weight: isSelf ? .bold : .semibold,
design: .monospaced design: design
) )
let mentionColor: Color = isMentionToMe ? .orange : baseColor let mentionColor: Color = isMentionToMe ? .orange : baseColor
mentionStyle.foregroundColor = mentionColor mentionStyle.foregroundColor = mentionColor
@@ -267,8 +268,8 @@ final class ChatMessageFormatter {
var tagStyle = AttributeContainer() var tagStyle = AttributeContainer()
tagStyle.font = isSelf tagStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) ? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: .monospaced) : .bitchatSystem(size: 14, design: design)
tagStyle.foregroundColor = baseColor tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMentionToken && standalone, if isGeohash && !attachedToMentionToken && standalone,
let url = URL(string: "bitchat://geohash/\(token)") { let url = URL(string: "bitchat://geohash/\(token)") {
@@ -280,15 +281,15 @@ final class ChatMessageFormatter {
var spacer = AttributeContainer() var spacer = AttributeContainer()
spacer.foregroundColor = baseColor spacer.foregroundColor = baseColor
spacer.font = isSelf spacer.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) ? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: .monospaced) : .bitchatSystem(size: 14, design: design)
result.append(AttributedString(" ").mergingAttributes(spacer)) result.append(AttributedString(" ").mergingAttributes(spacer))
} else { } else {
var matchStyle = AttributeContainer() var matchStyle = AttributeContainer()
matchStyle.font = .bitchatSystem( matchStyle.font = .bitchatSystem(
size: 14, size: 14,
weight: isSelf ? .bold : .semibold, weight: isSelf ? .bold : .semibold,
design: .monospaced design: design
) )
if type == "url" { if type == "url" {
matchStyle.foregroundColor = isSelf ? .orange : .blue matchStyle.foregroundColor = isSelf ? .orange : .blue
@@ -310,8 +311,8 @@ final class ChatMessageFormatter {
var remainingStyle = AttributeContainer() var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = baseColor remainingStyle.foregroundColor = baseColor
remainingStyle.font = isSelf remainingStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) ? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: .monospaced) : .bitchatSystem(size: 14, design: design)
if isMentioned { if isMentioned {
remainingStyle.font = remainingStyle.font?.bold() remainingStyle.font = remainingStyle.font?.bold()
} }
@@ -322,27 +323,28 @@ final class ChatMessageFormatter {
let timestamp = AttributedString(" [\(message.formattedTimestamp)]") let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer() var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7) 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)) result.append(timestamp.mergingAttributes(timestampStyle))
} else { } else {
var contentStyle = AttributeContainer() var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray contentStyle.foregroundColor = Color.gray
let content = AttributedString("* \(message.content) *") 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)) result.append(content.mergingAttributes(contentStyle))
let timestamp = AttributedString(" [\(message.formattedTimestamp)]") let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer() var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5) 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)) result.append(timestamp.mergingAttributes(timestampStyle))
} }
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
return result 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 = { let isSelf: Bool = {
if let spid = message.senderPeerID { if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"), if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"),
@@ -362,7 +364,7 @@ final class ChatMessageFormatter {
if message.sender == "system" { if message.sender == "system" {
var style = AttributeContainer() var style = AttributeContainer()
style.foregroundColor = baseColor 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) return AttributedString(message.sender).mergingAttributes(style)
} }
@@ -370,7 +372,7 @@ final class ChatMessageFormatter {
let (baseName, suffix) = message.sender.splitSuffix() let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer() var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor 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, if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
senderStyle.link = url senderStyle.link = url
+5 -4
View File
@@ -139,6 +139,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
} }
private var visibleMessagesCache: [BitchatMessage]? private var visibleMessagesCache: [BitchatMessage]?
@Published var currentColorScheme: ColorScheme = .light @Published var currentColorScheme: ColorScheme = .light
@Published var currentTheme: AppTheme = .matrix
@Published var isConnected = false @Published var isConnected = false
@Published var nickname: String = "" { @Published var nickname: String = "" {
didSet { didSet {
@@ -1257,13 +1258,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Formatting // MARK: - Message Formatting
@MainActor @MainActor
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
messageFormatter.formatMessageAsText(message, colorScheme: colorScheme) messageFormatter.formatMessageAsText(message, colorScheme: colorScheme, theme: theme ?? currentTheme)
} }
@MainActor @MainActor
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
messageFormatter.formatMessageHeader(message, colorScheme: colorScheme) messageFormatter.formatMessageHeader(message, colorScheme: colorScheme, theme: theme ?? currentTheme)
} }
// MARK: - Noise Protocol Support // MARK: - Noise Protocol Support
+48 -30
View File
@@ -2,24 +2,24 @@ import SwiftUI
struct AppInfoView: View { struct AppInfoView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme @ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
private var backgroundColor: Color { private var selectedTheme: AppTheme {
colorScheme == .dark ? Color.black : Color.white AppTheme(rawValue: appThemeRawValue) ?? .matrix
} }
private var textColor: Color { private var backgroundColor: Color { palette.background }
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color { private var textColor: Color { palette.primary }
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
} private var secondaryTextColor: Color { palette.secondary }
// MARK: - Constants // MARK: - Constants
private enum Strings { private enum Strings {
static let appName: LocalizedStringKey = "app_info.app_name" static let appName: LocalizedStringKey = "app_info.app_name"
static let tagline: LocalizedStringKey = "app_info.tagline" static let tagline: LocalizedStringKey = "app_info.tagline"
static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title"
enum Features { enum Features {
static let title: LocalizedStringKey = "app_info.features.title" static let title: LocalizedStringKey = "app_info.features.title"
@@ -101,12 +101,12 @@ struct AppInfoView: View {
.foregroundColor(textColor) .foregroundColor(textColor)
.padding() .padding()
} }
.background(backgroundColor.opacity(0.95)) .themedSurface(opacity: 0.95)
ScrollView { ScrollView {
infoContent infoContent
} }
.background(backgroundColor) .themedSheetBackground()
} }
.frame(width: 600, height: 700) .frame(width: 600, height: 700)
#else #else
@@ -114,13 +114,13 @@ struct AppInfoView: View {
ScrollView { ScrollView {
infoContent infoContent
} }
.background(backgroundColor) .themedSheetBackground()
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
@@ -138,16 +138,40 @@ struct AppInfoView: View {
// Header // Header
VStack(alignment: .center, spacing: 8) { VStack(alignment: .center, spacing: 8) {
Text(Strings.appName) Text(Strings.appName)
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced)) .bitchatFont(size: 32, weight: .bold)
.foregroundColor(textColor) .foregroundColor(textColor)
Text(Strings.tagline) Text(Strings.tagline)
.font(.bitchatSystem(size: 16, design: .monospaced)) .bitchatFont(size: 16)
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical) .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 // How to Use
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title) SectionHeader(Strings.HowToUse.title)
@@ -157,7 +181,7 @@ struct AppInfoView: View {
Text(instruction) Text(instruction)
} }
} }
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(textColor) .foregroundColor(textColor)
} }
@@ -201,11 +225,9 @@ struct AppInfoFeatureInfo {
struct SectionHeader: View { struct SectionHeader: View {
let title: LocalizedStringKey let title: LocalizedStringKey
@Environment(\.colorScheme) var colorScheme @ThemedPalette private var palette
private var textColor: Color { private var textColor: Color { palette.primary }
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
init(_ title: LocalizedStringKey) { init(_ title: LocalizedStringKey) {
self.title = title self.title = title
@@ -213,7 +235,7 @@ struct SectionHeader: View {
var body: some View { var body: some View {
Text(title) Text(title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor) .foregroundColor(textColor)
.padding(.top, 8) .padding(.top, 8)
} }
@@ -221,15 +243,11 @@ struct SectionHeader: View {
struct FeatureRow: View { struct FeatureRow: View {
let info: AppInfoFeatureInfo let info: AppInfoFeatureInfo
@Environment(\.colorScheme) var colorScheme @ThemedPalette private var palette
private var textColor: Color { private var textColor: Color { palette.primary }
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color { private var secondaryTextColor: Color { palette.secondary }
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
@@ -240,11 +258,11 @@ struct FeatureRow: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(info.title) Text(info.title)
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced)) .bitchatFont(size: 14, weight: .semibold)
.foregroundColor(textColor) .foregroundColor(textColor)
Text(info.description) Text(info.description)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@@ -10,13 +10,10 @@ import SwiftUI
struct CommandSuggestionsView: View { struct CommandSuggestionsView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var messageText: String @Binding var messageText: String
let textColor: Color
let backgroundColor: Color
let secondaryTextColor: Color
private var filteredCommands: [CommandInfo] { private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] } guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
@@ -27,6 +24,10 @@ struct CommandSuggestionsView: View {
} }
var body: some View { var body: some View {
// 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) { VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in ForEach(filteredCommands) { command in
Button { Button {
@@ -38,31 +39,28 @@ struct CommandSuggestionsView: View {
.background(Color.gray.opacity(0.1)) .background(Color.gray.opacity(0.1))
} }
} }
.background(backgroundColor) .themedOverlayPanel()
.overlay( }
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
} }
private func buttonRow(for command: CommandInfo) -> some View { private func buttonRow(for command: CommandInfo) -> some View {
HStack { HStack {
Text(command.alias) Text(command.alias)
.font(.bitchatSystem(size: 11, design: .monospaced)) .bitchatFont(size: 11)
.foregroundColor(textColor) .foregroundColor(palette.primary)
.fontWeight(.medium) .fontWeight(.medium)
if let placeholder = command.placeholder { if let placeholder = command.placeholder {
Text(placeholder) Text(placeholder)
.font(.bitchatSystem(size: 10, design: .monospaced)) .bitchatFont(size: 10)
.foregroundColor(secondaryTextColor.opacity(0.8)) .foregroundColor(palette.secondary.opacity(0.8))
} }
Spacer() Spacer()
Text(command.description) Text(command.description)
.font(.bitchatSystem(size: 10, design: .monospaced)) .bitchatFont(size: 10)
.foregroundColor(secondaryTextColor) .foregroundColor(palette.secondary)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 3) .padding(.vertical, 3)
@@ -85,12 +83,7 @@ struct CommandSuggestionsView: View {
) )
let locationChannelsModel = LocationChannelsModel() let locationChannelsModel = LocationChannelsModel()
CommandSuggestionsView( CommandSuggestionsView(messageText: $messageText)
messageText: $messageText,
textColor: .green,
backgroundColor: .primary,
secondaryTextColor: .secondary
)
.environmentObject(privateConversationModel) .environmentObject(privateConversationModel)
.environmentObject(locationChannelsModel) .environmentObject(locationChannelsModel)
} }
@@ -10,18 +10,14 @@ import SwiftUI
import BitFoundation import BitFoundation
struct DeliveryStatusView: View { struct DeliveryStatusView: View {
@Environment(\.colorScheme) private var colorScheme @ThemedPalette private var palette
let status: DeliveryStatus let status: DeliveryStatus
// MARK: - Computed Properties // MARK: - Computed Properties
private var textColor: Color { private var textColor: Color { palette.primary }
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color { private var secondaryTextColor: Color { palette.secondary }
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
private enum Strings { private enum Strings {
static func delivered(to nickname: String) -> String { static func delivered(to nickname: String) -> String {
@@ -89,7 +85,7 @@ struct DeliveryStatusView: View {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold)) .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)) .help(Strings.read(by: nickname))
case .failed(let reason): case .failed(let reason):
@@ -103,7 +99,7 @@ struct DeliveryStatusView: View {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
Text(verbatim: "\(reached)/\(total)") Text(verbatim: "\(reached)/\(total)")
.font(.bitchatSystem(size: 10, design: .monospaced)) .bitchatFont(size: 10)
} }
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total)) .help(Strings.deliveredToMembers(reached, total))
@@ -11,6 +11,7 @@ import SwiftUI
struct PaymentChipView: View { struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@ThemedPalette private var palette
enum PaymentType { enum PaymentType {
case cashu(String) case cashu(String)
@@ -54,9 +55,7 @@ struct PaymentChipView: View {
let paymentType: PaymentType let paymentType: PaymentType
private var fgColor: Color { private var fgColor: Color { palette.primary }
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var bgColor: Color { private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
} }
@@ -73,7 +72,7 @@ struct PaymentChipView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(paymentType.emoji) Text(paymentType.emoji)
Text(paymentType.label) Text(paymentType.label)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .bitchatFont(size: 12, weight: .semibold)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -11,6 +11,7 @@ import BitFoundation
struct TextMessageView: View { struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
@@ -37,7 +38,7 @@ struct TextMessageView: View {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) 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) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -58,7 +59,7 @@ struct TextMessageView: View {
if isExpanded { expandedMessageIDs.remove(message.id) } if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) } else { expandedMessageIDs.insert(message.id) }
} }
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) .bitchatFont(size: 11, weight: .medium)
.foregroundColor(Color.blue) .foregroundColor(Color.blue)
.padding(.top, 4) .padding(.top, 4)
} }
+15 -29
View File
@@ -6,16 +6,14 @@ import UIKit
struct ContentComposerView: View { struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@Environment(\.colorScheme) private var colorScheme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var messageText: String @Binding var messageText: String
var isTextFieldFocused: FocusState<Bool>.Binding var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer? @Binding var autocompleteDebounceTimer: Timer?
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let onSendMessage: () -> Void let onSendMessage: () -> Void
#if os(iOS) #if os(iOS)
@@ -35,8 +33,8 @@ struct ContentComposerView: View {
}) { }) {
HStack { HStack {
Text(suggestion) Text(suggestion)
.font(.bitchatSystem(size: 11, design: .monospaced)) .bitchatFont(size: 11)
.foregroundColor(textColor) .foregroundColor(palette.primary)
.fontWeight(.medium) .fontWeight(.medium)
Spacer() Spacer()
} }
@@ -48,20 +46,11 @@ struct ContentComposerView: View {
.background(Color.gray.opacity(0.1)) .background(Color.gray.opacity(0.1))
} }
} }
.background(backgroundColor) .themedOverlayPanel()
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
CommandSuggestionsView( CommandSuggestionsView(messageText: $messageText)
messageText: $messageText,
textColor: textColor,
backgroundColor: backgroundColor,
secondaryTextColor: secondaryTextColor
)
if voiceRecordingVM.state.isActive { if voiceRecordingVM.state.isActive {
recordingIndicator recordingIndicator
@@ -74,11 +63,11 @@ struct ContentComposerView: View {
prompt: Text( prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") 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) .textFieldStyle(.plain)
.font(.bitchatSystem(size: 15, design: .monospaced)) .bitchatFont(size: 15)
.foregroundColor(textColor) .foregroundColor(palette.primary)
.focused(isTextFieldFocused) .focused(isTextFieldFocused)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
#if os(iOS) #if os(iOS)
@@ -86,12 +75,9 @@ struct ContentComposerView: View {
#endif #endif
.submitLabel(.send) .submitLabel(.send)
.onSubmit(onSendMessage) .onSubmit(onSendMessage)
.padding(.vertical, 4) .padding(.vertical, theme.usesGlassChrome ? 8 : 4)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.background( .themedInputBackground()
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
)
.modifier(FocusEffectDisabledModifier()) .modifier(FocusEffectDisabledModifier())
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
@@ -114,9 +100,9 @@ struct ContentComposerView: View {
} }
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.top, 6) .padding(.top, theme.usesGlassChrome ? 8 : 6)
.padding(.bottom, 8) .padding(.bottom, 8)
.background(backgroundColor.opacity(0.95)) .themedChromePanel(edge: .bottom)
.onDisappear { .onDisappear {
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
} }
@@ -134,7 +120,7 @@ private extension ContentComposerView {
"recording \(voiceRecordingVM.formattedDuration(for: context.date))", "recording \(voiceRecordingVM.formattedDuration(for: context.date))",
comment: "Voice note recording duration indicator" comment: "Voice note recording duration indicator"
) )
.font(.bitchatSystem(size: 13, design: .monospaced)) .bitchatFont(size: 13)
.foregroundColor(.red) .foregroundColor(.red)
} }
Spacer() Spacer()
@@ -154,7 +140,7 @@ private extension ContentComposerView {
} }
var composerAccentColor: Color { var composerAccentColor: Color {
privateConversationModel.selectedPeerID != nil ? Color.orange : textColor privateConversationModel.selectedPeerID != nil ? Color.orange : palette.accent
} }
var attachmentButton: some View { var attachmentButton: some View {
+55 -50
View File
@@ -8,8 +8,9 @@ struct ContentHeaderView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var showSidebar: Bool @Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool @Binding var showVerifySheet: Bool
@@ -20,15 +21,12 @@ struct ContentHeaderView: View {
let headerHeight: CGFloat let headerHeight: CGFloat
let headerPeerIconSize: CGFloat let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat let headerPeerCountFontSize: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text(verbatim: "bitchat/") Text(verbatim: "bitchat/")
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced)) .bitchatFont(size: 18, weight: .medium)
.foregroundColor(textColor) .foregroundColor(palette.primary)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
appChromeModel.panicClearAllData() appChromeModel.panicClearAllData()
} }
@@ -38,8 +36,8 @@ struct ContentHeaderView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text(verbatim: "@") Text(verbatim: "@")
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(secondaryTextColor) .foregroundColor(palette.secondary)
TextField( TextField(
"content.input.nickname_placeholder", "content.input.nickname_placeholder",
@@ -49,9 +47,9 @@ struct ContentHeaderView: View {
) )
) )
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.frame(maxWidth: 80) .frame(maxWidth: 80)
.foregroundColor(textColor) .foregroundColor(palette.primary)
.focused(isNicknameFieldFocused) .focused(isNicknameFieldFocused)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
#if os(iOS) #if os(iOS)
@@ -79,12 +77,13 @@ struct ContentHeaderView: View {
return countAndColor.0 return countAndColor.0
}() }()
HStack(spacing: 10) { HStack(spacing: 2) {
if appChromeModel.hasUnreadPrivateMessages { if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
.headerTapTarget()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
@@ -99,13 +98,10 @@ struct ContentHeaderView: View {
notesGeohash = locationChannelsModel.currentBuildingGeohash notesGeohash = locationChannelsModel.currentBuildingGeohash
showLocationNotes = true showLocationNotes = true
}) { }) {
HStack(alignment: .center, spacing: 4) {
Image(systemName: "note.text") Image(systemName: "note.text")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8)) .foregroundColor(Color.orange.opacity(0.8))
.padding(.top, 1) .headerTapTarget()
}
.fixedSize(horizontal: true, vertical: false)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
@@ -117,6 +113,7 @@ struct ContentHeaderView: View {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.headerTapTarget()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
@@ -140,27 +137,45 @@ struct ContentHeaderView: View {
case .mesh: case .mesh:
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location: case .location:
return colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) return palette.locationAccent
} }
}() }()
Text(badgeText) Text(badgeText)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(badgeColor) .foregroundColor(badgeColor)
.lineLimit(headerLineLimit) .lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
.layoutPriority(2) .layoutPriority(2)
.padding(.horizontal, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.accessibilityLabel( .accessibilityLabel(
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button") String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 4)
.padding(.trailing, 2)
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}) {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular)) .font(.system(size: headerPeerIconSize, weight: .regular))
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: theme.bodyFontDesign))
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
String( String(
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
@@ -168,21 +183,8 @@ struct ContentHeaderView: View {
headerOtherPeersCount headerOtherPeersCount
) )
) )
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
} }
.layoutPriority(3) .layoutPriority(3)
.onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}
.sheet(isPresented: $showVerifySheet) { .sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet) VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(verificationModel) .environmentObject(verificationModel)
@@ -208,10 +210,7 @@ struct ContentHeaderView: View {
} else { } else {
ContentLocationNotesUnavailableView( ContentLocationNotesUnavailableView(
showLocationNotes: $showLocationNotes, showLocationNotes: $showLocationNotes,
headerHeight: headerHeight, headerHeight: headerHeight
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor
) )
.environmentObject(locationChannelsModel) .environmentObject(locationChannelsModel)
} }
@@ -249,7 +248,16 @@ struct ContentHeaderView: View {
} message: { } message: {
Text("content.alert.screenshot.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 { switch locationChannelsModel.selectedChannel {
case .location: case .location:
let count = peerListModel.visibleGeohashPeerCount let count = peerListModel.visibleGeohashPeerCount
let standardGreen = colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) return (count, count > 0 ? palette.locationAccent : Color.secondary)
return (count, count > 0 ? standardGreen : Color.secondary)
case .mesh: case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
@@ -274,24 +281,22 @@ private extension ContentHeaderView {
private struct ContentLocationNotesUnavailableView: View { private struct ContentLocationNotesUnavailableView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var showLocationNotes: Bool @Binding var showLocationNotes: Bool
let headerHeight: CGFloat let headerHeight: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
HStack { HStack {
Text("content.notes.title") Text("content.notes.title")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .bitchatFont(size: 16, weight: .bold)
Spacer() Spacer()
Button(action: { showLocationNotes = false }) { Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor) .foregroundColor(palette.primary)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -299,17 +304,17 @@ private struct ContentLocationNotesUnavailableView: View {
} }
.frame(height: headerHeight) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95)) .themedChromePanel(edge: .top)
Text("content.notes.location_unavailable") Text("content.notes.location_unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(secondaryTextColor) .foregroundColor(palette.secondary)
Button("content.location.enable") { Button("content.location.enable") {
locationChannelsModel.enableAndRefresh() locationChannelsModel.enableAndRefresh()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
} }
.background(backgroundColor) .themedSheetBackground()
.foregroundColor(textColor) .foregroundColor(palette.primary)
} }
} }
+30 -53
View File
@@ -25,10 +25,8 @@ struct ContentPeopleSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer? @Binding var autocompleteDebounceTimer: Timer?
@ThemedPalette private var palette
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat let headerHeight: CGFloat
let onSendMessage: () -> Void let onSendMessage: () -> Void
@@ -56,9 +54,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused, isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight, headerHeight: headerHeight,
onSendMessage: onSendMessage, onSendMessage: onSendMessage,
showImagePicker: $showImagePicker, showImagePicker: $showImagePicker,
@@ -77,9 +72,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused, isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight, headerHeight: headerHeight,
onSendMessage: onSendMessage, onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker showMacImagePicker: $showMacImagePicker
@@ -88,9 +80,6 @@ struct ContentPeopleSheetView: View {
} else { } else {
ContentPeopleListView( ContentPeopleListView(
showSidebar: $showSidebar, showSidebar: $showSidebar,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight headerHeight: headerHeight
) )
} }
@@ -109,8 +98,8 @@ struct ContentPeopleSheetView: View {
} }
} }
} }
.background(backgroundColor) .themedSheetBackground()
.foregroundColor(textColor) .foregroundColor(palette.primary)
#if os(macOS) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
@@ -148,12 +137,10 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ThemedPalette private var palette
@Binding var showSidebar: Bool @Binding var showSidebar: Bool
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat let headerHeight: CGFloat
@State private var showVerifySheet = false @State private var showVerifySheet = false
@@ -163,8 +150,8 @@ private struct ContentPeopleListView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) { HStack(spacing: 12) {
Text(peopleSheetTitle) Text(peopleSheetTitle)
.font(.bitchatSystem(size: 18, design: .monospaced)) .bitchatFont(size: 18)
.foregroundColor(textColor) .foregroundColor(palette.primary)
Spacer() Spacer()
if case .mesh = locationChannelsModel.selectedChannel { if case .mesh = locationChannelsModel.selectedChannel {
Button(action: { showVerifySheet = true }) { Button(action: { showVerifySheet = true }) {
@@ -185,7 +172,7 @@ private struct ContentPeopleListView: View {
} }
}) { }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -201,9 +188,9 @@ private struct ContentPeopleListView: View {
let subtitleColor: Color = { let subtitleColor: Color = {
switch locationChannelsModel.selectedChannel { switch locationChannelsModel.selectedChannel {
case .mesh: case .mesh:
return Color.blue return palette.accentBlue
case .location: case .location:
return Color.green return palette.locationAccent
} }
}() }()
@@ -213,32 +200,28 @@ private struct ContentPeopleListView: View {
Text(activeText) Text(activeText)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
} else { } else {
Text(activeText) Text(activeText)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 16) .padding(.top, 16)
.padding(.bottom, 12) .padding(.bottom, 12)
.background(backgroundColor) .themedSurface()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
if case .location = locationChannelsModel.selectedChannel { if case .location = locationChannelsModel.selectedChannel {
GeohashPeopleList( GeohashPeopleList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: { onTapPerson: {
showSidebar = true showSidebar = true
} }
) )
} else { } else {
MeshPeerList( MeshPeerList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in onTapPeer: { peerID in
peerListModel.startConversation(with: peerID) peerListModel.startConversation(with: peerID)
showSidebar = true showSidebar = true
@@ -302,10 +285,9 @@ private struct ContentPrivateChatSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer? @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 headerHeight: CGFloat
let onSendMessage: () -> Void let onSendMessage: () -> Void
@@ -327,7 +309,7 @@ private struct ContentPrivateChatSheetView: View {
}) { }) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(textColor) .foregroundColor(palette.primary)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -341,8 +323,7 @@ private struct ContentPrivateChatSheetView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ContentPrivateHeaderInfoButton( ContentPrivateHeaderInfoButton(
headerState: headerState, headerState: headerState,
headerHeight: headerHeight, headerHeight: headerHeight
textColor: textColor
) )
if headerState.supportsFavoriteToggle { if headerState.supportsFavoriteToggle {
@@ -351,7 +332,7 @@ private struct ContentPrivateChatSheetView: View {
}) { }) {
Image(systemName: headerState.isFavorite ? "star.fill" : "star") Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : textColor) .foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
@@ -372,7 +353,7 @@ private struct ContentPrivateChatSheetView: View {
} }
}) { }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -382,7 +363,7 @@ private struct ContentPrivateChatSheetView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 12)
.background(backgroundColor) .themedSurface()
} }
MessageListView( MessageListView(
@@ -397,10 +378,12 @@ private struct ContentPrivateChatSheetView: View {
showSidebar: $showSidebar, showSidebar: $showSidebar,
isTextFieldFocused: isTextFieldFocused isTextFieldFocused: isTextFieldFocused
) )
.background(backgroundColor) .themedSurface()
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
if !theme.usesGlassChrome {
Divider() Divider()
}
#if os(iOS) #if os(iOS)
ContentComposerView( ContentComposerView(
@@ -408,9 +391,6 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused, isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage, onSendMessage: onSendMessage,
showImagePicker: $showImagePicker, showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType imagePickerSourceType: $imagePickerSourceType
@@ -421,16 +401,13 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused, isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage, onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker showMacImagePicker: $showMacImagePicker
) )
#endif #endif
} }
.background(backgroundColor) .themedSheetBackground()
.foregroundColor(textColor) .foregroundColor(palette.primary)
.highPriorityGesture( .highPriorityGesture(
DragGesture(minimumDistance: 25, coordinateSpace: .local) DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onEnded { value in .onEnded { value in
@@ -448,10 +425,10 @@ private struct ContentPrivateChatSheetView: View {
private struct ContentPrivateHeaderInfoButton: View { private struct ContentPrivateHeaderInfoButton: View {
@EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var appChromeModel: AppChromeModel
@ThemedPalette private var palette
let headerState: PrivateConversationHeaderState let headerState: PrivateConversationHeaderState
let headerHeight: CGFloat let headerHeight: CGFloat
let textColor: Color
var body: some View { var body: some View {
Button(action: { Button(action: {
@@ -462,12 +439,12 @@ private struct ContentPrivateHeaderInfoButton: View {
case .bluetoothConnected: case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right") Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(textColor) .foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable: case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(textColor) .foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable: case .nostrAvailable:
Image(systemName: "globe") Image(systemName: "globe")
@@ -479,8 +456,8 @@ private struct ContentPrivateHeaderInfoButton: View {
} }
Text(headerState.displayName) Text(headerState.displayName)
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) .bitchatFont(size: 16, weight: .medium)
.foregroundColor(textColor) .foregroundColor(palette.primary)
if let encryptionStatus = headerState.encryptionStatus, if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon { let icon = encryptionStatus.icon {
@@ -488,7 +465,7 @@ private struct ContentPrivateHeaderInfoButton: View {
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor( .foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? textColor ? palette.primary
: Color.red : Color.red
) )
.accessibilityLabel( .accessibilityLabel(
+100 -86
View File
@@ -40,6 +40,7 @@ struct ContentView: View {
@State private var messageText = "" @State private var messageText = ""
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.appTheme) private var appTheme
@State private var showSidebar = false @State private var showSidebar = false
@State private var selectedMessageSender: String? @State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID? @State private var selectedMessageSenderID: PeerID?
@@ -63,39 +64,19 @@ struct ContentView: View {
@State private var windowCountPublic: Int = 300 @State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [PeerID: Int] = [:] @State private var windowCountPrivate: [PeerID: Int] = [:]
private var backgroundColor: Color { @ThemedPalette private var palette
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)
}
private var selectedPrivatePeerID: PeerID? { private var selectedPrivatePeerID: PeerID? {
privateConversationModel.selectedPeerID privateConversationModel.selectedPeerID
} }
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
var body: some View { var body: some View {
VStack(spacing: 0) { mainContent
ContentHeaderView(
showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet,
showLocationNotes: $showLocationNotes,
notesGeohash: $notesGeohash,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
headerPeerCountFontSize: headerPeerCountFontSize,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor
)
.onAppear { .onAppear {
conversationUIModel.setCurrentColorScheme(colorScheme) conversationUIModel.setCurrentColorScheme(colorScheme)
conversationUIModel.setCurrentTheme(appTheme)
#if os(macOS) #if os(macOS)
DispatchQueue.main.async { DispatchQueue.main.async {
isNicknameFieldFocused = false isNicknameFieldFocused = false
@@ -106,62 +87,11 @@ struct ContentView: View {
.onChange(of: colorScheme) { newValue in .onChange(of: colorScheme) { newValue in
conversationUIModel.setCurrentColorScheme(newValue) conversationUIModel.setCurrentColorScheme(newValue)
} }
.onChange(of: appTheme) { newValue in
Divider() conversationUIModel.setCurrentTheme(newValue)
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) .background(ThemedRootBackground())
} .foregroundColor(palette.primary)
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)
#if os(macOS) #if os(macOS)
.frame(minWidth: 600, minHeight: 400) .frame(minWidth: 600, minHeight: 400)
#endif #endif
@@ -194,9 +124,6 @@ struct ContentView: View {
isTextFieldFocused: $isTextFieldFocused, isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight, headerHeight: headerHeight,
onSendMessage: sendMessage, onSendMessage: sendMessage,
showImagePicker: $showImagePicker, showImagePicker: $showImagePicker,
@@ -215,9 +142,6 @@ struct ContentView: View {
isTextFieldFocused: $isTextFieldFocused, isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight, headerHeight: headerHeight,
onSendMessage: sendMessage, onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker 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() { private func sendMessage() {
guard let trimmed = messageText.trimmedOrNilIfEmpty else { return } guard let trimmed = messageText.trimmedOrNilIfEmpty else { return }
+16 -20
View File
@@ -13,15 +13,11 @@ struct FingerprintView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
let peerID: PeerID let peerID: PeerID
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme @ThemedPalette private var palette
private var textColor: Color { private var textColor: Color { palette.primary }
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var backgroundColor: Color { private var backgroundColor: Color { palette.background }
colorScheme == .dark ? Color.black : Color.white
}
private enum Strings { private enum Strings {
static let title: LocalizedStringKey = "fingerprint.title" static let title: LocalizedStringKey = "fingerprint.title"
@@ -53,7 +49,7 @@ struct FingerprintView: View {
// Header // Header
HStack { HStack {
Text(Strings.title) Text(Strings.title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
@@ -76,11 +72,11 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(fingerprintState.peerNickname) Text(fingerprintState.peerNickname)
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) .bitchatFont(size: 18, weight: .semibold)
.foregroundColor(textColor) .foregroundColor(textColor)
Text(fingerprintState.encryptionStatus.description) Text(fingerprintState.encryptionStatus.description)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
} }
@@ -93,12 +89,12 @@ struct FingerprintView: View {
// Their fingerprint // Their fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.theirFingerprint) Text(Strings.theirFingerprint)
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
if let fingerprint = fingerprintState.theirFingerprint { if let fingerprint = fingerprintState.theirFingerprint {
Text(formatFingerprint(fingerprint)) Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(textColor) .foregroundColor(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(nil) .lineLimit(nil)
@@ -119,7 +115,7 @@ struct FingerprintView: View {
} }
} else { } else {
Text(Strings.handshakePending) Text(Strings.handshakePending)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
.padding() .padding()
} }
@@ -128,11 +124,11 @@ struct FingerprintView: View {
// My fingerprint // My fingerprint
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.yourFingerprint) Text(Strings.yourFingerprint)
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
Text(formatFingerprint(fingerprintState.myFingerprint)) Text(formatFingerprint(fingerprintState.myFingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(textColor) .foregroundColor(textColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(nil) .lineLimit(nil)
@@ -157,7 +153,7 @@ struct FingerprintView: View {
if fingerprintState.canToggleVerification { if fingerprintState.canToggleVerification {
VStack(spacing: 12) { VStack(spacing: 12) {
Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) 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) .foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -168,7 +164,7 @@ struct FingerprintView: View {
Text(Strings.verifyHint(fingerprintState.peerNickname)) Text(Strings.verifyHint(fingerprintState.peerNickname))
} }
} }
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(nil) .lineLimit(nil)
@@ -181,7 +177,7 @@ struct FingerprintView: View {
dismiss() dismiss()
}) { }) {
Text(Strings.markVerified) Text(Strings.markVerified)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -195,7 +191,7 @@ struct FingerprintView: View {
dismiss() dismiss()
}) { }) {
Text(Strings.removeVerification) Text(Strings.removeVerification)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -216,7 +212,7 @@ struct FingerprintView: View {
} }
.padding() .padding()
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor) .themedSheetBackground()
} }
private func formatFingerprint(_ fingerprint: String) -> String { private func formatFingerprint(_ fingerprint: String) -> String {
+6 -7
View File
@@ -2,8 +2,7 @@ import SwiftUI
struct GeohashPeopleList: View { struct GeohashPeopleList: View {
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color @ThemedPalette private var palette
let secondaryTextColor: Color
let onTapPerson: () -> Void let onTapPerson: () -> Void
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = [] @State private var orderedIDs: [String] = []
@@ -20,8 +19,8 @@ struct GeohashPeopleList: View {
if peerListModel.geohashPeople.isEmpty { if peerListModel.geohashPeople.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby) Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(secondaryTextColor) .foregroundColor(palette.secondary)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
} }
@@ -52,18 +51,18 @@ struct GeohashPeopleList: View {
let (base, suffix) = person.displayName.splitSuffix() let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) { HStack(spacing: 0) {
Text(base) Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular) .fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor) .foregroundColor(rowColor)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix) Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(suffixColor) .foregroundColor(suffixColor)
} }
if person.isMe { if person.isMe {
Text(Strings.youSuffix) Text(Strings.youSuffix)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(rowColor) .foregroundColor(rowColor)
} }
} }
+31 -37
View File
@@ -9,11 +9,11 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) var colorScheme @ThemedPalette private var palette
@State private var customGeohash: String = "" @State private var customGeohash: String = ""
@State private var customError: String? = nil @State private var customError: String? = nil
private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private var backgroundColor: Color { palette.background }
private enum Strings { private enum Strings {
static let title: LocalizedStringKey = "location_channels.title" static let title: LocalizedStringKey = "location_channels.title"
@@ -97,12 +97,12 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) { HStack(spacing: 12) {
Text(Strings.title) Text(Strings.title)
.font(.bitchatSystem(size: 18, design: .monospaced)) .bitchatFont(size: 18)
Spacer() Spacer()
closeButton closeButton
} }
Text(Strings.description) Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Group { Group {
@@ -110,7 +110,7 @@ struct LocationChannelsSheet: View {
case .notDetermined: case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) { Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text(Strings.requestPermissions) Text(Strings.requestPermissions)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -121,7 +121,7 @@ struct LocationChannelsSheet: View {
case .denied, .restricted: case .denied, .restricted:
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied) Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open) Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -136,7 +136,7 @@ struct LocationChannelsSheet: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(backgroundColor) .themedSurface()
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true) .navigationBarHidden(true)
@@ -147,7 +147,7 @@ struct LocationChannelsSheet: View {
#if os(macOS) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
.background(backgroundColor) .themedSheetBackground()
.onAppear { .onAppear {
// Refresh channels when opening // Refresh channels when opening
if locationChannelsModel.permissionState == .authorized { if locationChannelsModel.permissionState == .authorized {
@@ -171,7 +171,7 @@ struct LocationChannelsSheet: View {
private var closeButton: some View { private var closeButton: some View {
Button(action: { isPresented = false }) { Button(action: { isPresented = false }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -223,7 +223,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
Text(Strings.loadingNearby) Text(Strings.loadingNearby)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -246,8 +246,8 @@ struct LocationChannelsSheet: View {
.padding(.top, 12) .padding(.top, 12)
Button(action: SystemSettings.location.open) { Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess) Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) .foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color.red.opacity(0.08)) .background(Color.red.opacity(0.08))
@@ -259,9 +259,9 @@ struct LocationChannelsSheet: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(backgroundColor) .themedSurface()
} }
.background(backgroundColor) .themedSurface()
} }
private var sectionDivider: some View { private var sectionDivider: some View {
@@ -270,15 +270,13 @@ struct LocationChannelsSheet: View {
.frame(height: 1) .frame(height: 1)
} }
private var dividerColor: Color { private var dividerColor: Color { palette.divider }
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
}
private var customTeleportSection: some View { private var customTeleportSection: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) { HStack(spacing: 2) {
Text(verbatim: "#") Text(verbatim: "#")
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS) #if os(iOS)
@@ -286,7 +284,7 @@ struct LocationChannelsSheet: View {
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
.keyboardType(.asciiCapable) .keyboardType(.asciiCapable)
#endif #endif
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.onChange(of: customGeohash) { newValue in .onChange(of: customGeohash) { newValue in
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let filtered = newValue let filtered = newValue
@@ -312,13 +310,13 @@ struct LocationChannelsSheet: View {
}) { }) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(Strings.teleport) Text(Strings.teleport)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
Image(systemName: "face.dashed") Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12)) .background(Color.secondary.opacity(0.12))
@@ -328,7 +326,7 @@ struct LocationChannelsSheet: View {
} }
if let err = customError { if let err = customError {
Text(err) Text(err)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.red) .foregroundColor(.red)
} }
} }
@@ -337,7 +335,7 @@ struct LocationChannelsSheet: View {
private func bookmarkedSection(_ entries: [String]) -> some View { private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked) Text(Strings.bookmarked)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
@@ -409,18 +407,18 @@ struct LocationChannelsSheet: View {
let parts = splitTitleAndCount(title) let parts = splitTitleAndCount(title)
HStack(spacing: 4) { HStack(spacing: 4) {
Text(parts.base) Text(parts.base)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary) .foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty { if let count = parts.countSuffix, !count.isEmpty {
Text(count) Text(count)
.font(.bitchatSystem(size: 11, design: .monospaced)) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull) Text(subtitleFull)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
@@ -428,7 +426,7 @@ struct LocationChannelsSheet: View {
Spacer() Spacer()
if isSelected { if isSelected {
Text(verbatim: "✔︎") Text(verbatim: "✔︎")
.font(.bitchatSystem(size: 16, design: .monospaced)) .bitchatFont(size: 16)
.foregroundColor(standardGreen) .foregroundColor(standardGreen)
} }
trailingAccessory() trailingAccessory()
@@ -478,26 +476,22 @@ extension LocationChannelsSheet {
Toggle(isOn: torToggleBinding) { Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle) Text(Strings.torTitle)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary) .foregroundColor(.primary)
Text(Strings.torSubtitle) Text(Strings.torSubtitle)
.font(.bitchatSystem(size: 11, design: .monospaced)) .bitchatFont(size: 11)
.foregroundColor(.secondary) .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) .padding(12)
.background(Color.secondary.opacity(0.12)) .background(Color.secondary.opacity(0.12))
.cornerRadius(8) .cornerRadius(8)
} }
private var standardGreen: Color { private var standardGreen: Color { palette.primary }
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) private var standardBlue: Color { palette.accentBlue }
}
private var standardBlue: Color {
Color(red: 0.0, green: 0.478, blue: 1.0)
}
} }
private struct IRCToggleStyle: ToggleStyle { private struct IRCToggleStyle: ToggleStyle {
@@ -512,7 +506,7 @@ private struct IRCToggleStyle: ToggleStyle {
Spacer() Spacer()
Text(configuration.isOn ? onLabel : offLabel) Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase) .textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary) .foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.horizontal, 10) .padding(.horizontal, 10)
+28 -28
View File
@@ -6,7 +6,7 @@ struct LocationNotesView: View {
let senderNickname: String let senderNickname: String
let onNotesCountChanged: ((Int) -> Void)? let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme @ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -25,8 +25,8 @@ struct LocationNotesView: View {
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
} }
private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private var backgroundColor: Color { palette.background }
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) } private var accentGreen: Color { palette.accent }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings { private enum Strings {
@@ -53,11 +53,11 @@ struct LocationNotesView: View {
notesContent notesContent
} }
} }
.background(backgroundColor) .themedSurface()
inputSection inputSection
} }
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.background(backgroundColor) .themedSheetBackground()
.onDisappear { manager.cancel() } .onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in .onChange(of: geohash) { newValue in
manager.setGeohash(newValue) manager.setGeohash(newValue)
@@ -76,7 +76,7 @@ struct LocationNotesView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection inputSection
} }
.background(backgroundColor) .themedSurface()
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true) .navigationBarHidden(true)
@@ -84,7 +84,7 @@ struct LocationNotesView: View {
.navigationTitle("") .navigationTitle("")
#endif #endif
} }
.background(backgroundColor) .themedSheetBackground()
.onDisappear { manager.cancel() } .onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in .onChange(of: geohash) { newValue in
manager.setGeohash(newValue) manager.setGeohash(newValue)
@@ -99,7 +99,7 @@ struct LocationNotesView: View {
private var closeButton: some View { private var closeButton: some View {
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -111,33 +111,33 @@ struct LocationNotesView: View {
return VStack(alignment: .leading, spacing: 8) { return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) { HStack(spacing: 12) {
Text(headerTitle(for: count)) Text(headerTitle(for: count))
.font(.bitchatSystem(size: 18, design: .monospaced)) .bitchatFont(size: 18)
Spacer() Spacer()
closeButton closeButton
} }
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty { if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
Text(building) Text(building)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(accentGreen) .foregroundColor(accentGreen)
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty { } else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
Text(block) Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(accentGreen) .foregroundColor(accentGreen)
} }
Text(Strings.description) Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays { if manager.state == .noRelays {
Text(Strings.relaysPaused) Text(Strings.relaysPaused)
.font(.bitchatSystem(size: 11, design: .monospaced)) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 16) .padding(.top, 16)
.padding(.bottom, 12) .padding(.bottom, 12)
.background(backgroundColor) .themedSurface()
} }
private func headerTitle(for count: Int) -> String { private func headerTitle(for count: Int) -> String {
@@ -176,16 +176,16 @@ struct LocationNotesView: View {
return VStack(alignment: .leading, spacing: 2) { return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(verbatim: "@\(baseName)") Text(verbatim: "@\(baseName)")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) .bitchatFont(size: 12, weight: .semibold)
if !ts.isEmpty { if !ts.isEmpty {
Text(ts) Text(ts)
.font(.bitchatSystem(size: 11, design: .monospaced)) .bitchatFont(size: 11)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
} }
Text(note.content) Text(note.content)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -194,12 +194,12 @@ struct LocationNotesView: View {
private var noRelaysRow: some View { private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby) Text(Strings.noRelaysNearby)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint) Text(Strings.relaysRetryHint)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button(Strings.retry) { manager.refresh() } Button(Strings.retry) { manager.refresh() }
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -209,7 +209,7 @@ struct LocationNotesView: View {
HStack(spacing: 10) { HStack(spacing: 10) {
ProgressView() ProgressView()
Text(Strings.loadingNotes) Text(Strings.loadingNotes)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer() Spacer()
} }
@@ -219,9 +219,9 @@ struct LocationNotesView: View {
private var emptyRow: some View { private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle) Text(Strings.emptyTitle)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle) Text(Strings.emptySubtitle)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -231,13 +231,13 @@ struct LocationNotesView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
Text(message) Text(message)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
Spacer() Spacer()
} }
Button(Strings.dismissError) { manager.clearError() } Button(Strings.dismissError) { manager.clearError() }
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -247,7 +247,7 @@ struct LocationNotesView: View {
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 10) {
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical) TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true) .lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6) .padding(.vertical, 6)
Button(action: send) { Button(action: send) {
@@ -261,7 +261,7 @@ struct LocationNotesView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
.background(backgroundColor) .themedSurface()
.overlay(Divider(), alignment: .top) .overlay(Divider(), alignment: .top)
} }
+2 -1
View File
@@ -10,6 +10,7 @@ import BitFoundation
struct MediaMessageView: View { struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
let media: BitchatMessage.Media let media: BitchatMessage.Media
@@ -36,7 +37,7 @@ struct MediaMessageView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme)) Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
+4 -3
View File
@@ -8,6 +8,7 @@ struct VoiceNoteView: View {
private let onCancel: (() -> Void)? private let onCancel: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
@StateObject private var playback: VoiceNotePlaybackController @StateObject private var playback: VoiceNotePlaybackController
@State private var waveform: [Float] = [] @State private var waveform: [Float] = []
@@ -31,7 +32,7 @@ struct VoiceNoteView: View {
} }
private var borderColor: Color { 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 { private var playbackLabel: String {
@@ -46,7 +47,7 @@ struct VoiceNoteView: View {
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill") Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
.foregroundColor(.white) .foregroundColor(.white)
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
.background(Circle().fill(Color.green)) .background(Circle().fill(palette.accent))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -61,7 +62,7 @@ struct VoiceNoteView: View {
) )
Text(playbackLabel) Text(playbackLabel)
.font(.bitchatSystem(size: 13, design: .monospaced)) .bitchatFont(size: 13)
.foregroundColor(Color.secondary) .foregroundColor(Color.secondary)
if let onCancel = onCancel, isSending { if let onCancel = onCancel, isSending {
+3 -2
View File
@@ -6,6 +6,7 @@ struct WaveformView: View {
let sendProgress: Double? let sendProgress: Double?
let onSeek: ((Double) -> Void)? let onSeek: ((Double) -> Void)?
let isInteractive: Bool let isInteractive: Bool
@ThemedPalette private var palette
private var clampedPlayback: Double { private var clampedPlayback: Double {
max(0, min(1, playbackProgress)) max(0, min(1, playbackProgress))
@@ -37,9 +38,9 @@ struct WaveformView: View {
let binPosition = Double(index) / Double(samples.count) let binPosition = Double(index) / Double(samples.count)
let color: Color let color: Color
if binPosition <= clampedPlayback { if binPosition <= clampedPlayback {
color = Color.green color = palette.accent
} else if let send = clampedSend, binPosition <= send { } else if let send = clampedSend, binPosition <= send {
color = Color.blue color = palette.accentBlue
} else { } else {
color = Color.gray.opacity(0.35) color = Color.gray.opacity(0.35)
} }
+7 -8
View File
@@ -3,8 +3,7 @@ import BitFoundation
struct MeshPeerList: View { struct MeshPeerList: View {
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color @ThemedPalette private var palette
let secondaryTextColor: Color
let onTapPeer: (PeerID) -> Void let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void let onShowFingerprint: (PeerID) -> Void
@@ -28,8 +27,8 @@ struct MeshPeerList: View {
if peerListModel.meshRows.isEmpty { if peerListModel.meshRows.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby) Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(secondaryTextColor) .foregroundColor(palette.secondary)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
} }
@@ -64,18 +63,18 @@ struct MeshPeerList: View {
// Fallback icon for others (dimmed) // Fallback icon for others (dimmed)
Image(systemName: "person") Image(systemName: "person")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor) .foregroundColor(palette.secondary)
} }
let (base, suffix) = peer.displayName.splitSuffix() let (base, suffix) = peer.displayName.splitSuffix()
HStack(spacing: 0) { HStack(spacing: 0) {
Text(base) Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(baseColor) .foregroundColor(baseColor)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix) Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced)) .bitchatFont(size: 14)
.foregroundColor(suffixColor) .foregroundColor(suffixColor)
} }
} }
@@ -123,7 +122,7 @@ struct MeshPeerList: View {
Button(action: { onToggleFavorite(peer.peerID) }) { Button(action: { onToggleFavorite(peer.peerID) }) {
Image(systemName: peer.isFavorite ? "star.fill" : "star") Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : secondaryTextColor) .foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
+2 -1
View File
@@ -21,6 +21,7 @@ struct MessageListView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
let privatePeer: PeerID? let privatePeer: PeerID?
@Binding var isAtBottom: Bool @Binding var isAtBottom: Bool
@@ -222,7 +223,7 @@ private extension MessageListView {
@ViewBuilder @ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View { 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) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
+13 -13
View File
@@ -21,7 +21,7 @@ struct MyQRView: View {
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text(Strings.title) Text(Strings.title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .bitchatFont(size: 16, weight: .bold)
VStack(spacing: 10) { VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240) QRCodeImage(data: qrString, size: 240)
@@ -29,7 +29,7 @@ struct MyQRView: View {
// Non-scrolling, fully visible URL (wraps across lines) // Non-scrolling, fully visible URL (wraps across lines)
Text(qrString) Text(qrString)
.font(.bitchatSystem(size: 11, design: .monospaced)) .bitchatFont(size: 11)
.textSelection(.enabled) .textSelection(.enabled)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -69,7 +69,7 @@ struct QRCodeImage: View {
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Text(Strings.unavailable) Text(Strings.unavailable)
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
.foregroundColor(.gray) .foregroundColor(.gray)
) )
} }
@@ -150,7 +150,7 @@ struct QRScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
#else #else
Text(Strings.pastePrompt) Text(Strings.pastePrompt)
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced)) .bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input) TextEditor(text: $input)
.frame(height: 100) .frame(height: 100)
.border(Color.gray.opacity(0.4)) .border(Color.gray.opacity(0.4))
@@ -281,10 +281,10 @@ struct VerificationSheetView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@Binding var isPresented: Bool @Binding var isPresented: Bool
@State private var showingScanner = false @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 backgroundColor: Color { palette.background }
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var accentColor: Color { palette.accent }
private var boxColor: Color { Color.gray.opacity(0.1) } private var boxColor: Color { Color.gray.opacity(0.1) }
var body: some View { var body: some View {
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
// Top header (always at top) // Top header (always at top)
HStack { HStack {
Text("verification.sheet.title") Text("verification.sheet.title")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor) .foregroundColor(accentColor)
Spacer() Spacer()
Button(action: { Button(action: {
@@ -316,7 +316,7 @@ struct VerificationSheetView: View {
if showingScanner { if showingScanner {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("verification.scan.prompt_friend") Text("verification.scan.prompt_friend")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .bitchatFont(size: 16, weight: .bold)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(accentColor) .foregroundColor(accentColor)
@@ -350,13 +350,13 @@ struct VerificationSheetView: View {
if showingScanner { if showingScanner {
Button(action: { showingScanner = false }) { Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode") Label("show my qr", systemImage: "qrcode")
.font(.bitchatSystem(size: 13, design: .monospaced)) .bitchatFont(size: 13)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} else { } else {
Button(action: { showingScanner = true }) { Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder") Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced)) .bitchatFont(size: 13, weight: .medium)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.gray) .tint(.gray)
@@ -367,7 +367,7 @@ struct VerificationSheetView: View {
verificationModel.isVerified(peerID: peerID) { verificationModel.isVerified(peerID: peerID) {
Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) { Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) {
Label("remove verification", systemImage: "minus.circle") Label("remove verification", systemImage: "minus.circle")
.font(.bitchatSystem(size: 12, design: .monospaced)) .bitchatFont(size: 12)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.gray) .tint(.gray)
@@ -376,7 +376,7 @@ struct VerificationSheetView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 14) .padding(.vertical, 14)
} }
.background(backgroundColor) .themedSheetBackground()
.onDisappear { showingScanner = false } .onDisappear { showingScanner = false }
} }
} }
+1 -18
View File
@@ -142,9 +142,6 @@ private struct ContentPeopleSheetHarness: View {
isTextFieldFocused: $isTextFieldFocused, isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: .black,
textColor: .green,
secondaryTextColor: .gray,
headerHeight: 44, headerHeight: 44,
onSendMessage: {}, onSendMessage: {},
showImagePicker: $showImagePicker, showImagePicker: $showImagePicker,
@@ -163,9 +160,6 @@ private struct ContentPeopleSheetHarness: View {
isTextFieldFocused: $isTextFieldFocused, isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM, voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer, autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: .black,
textColor: .green,
secondaryTextColor: .gray,
headerHeight: 44, headerHeight: 44,
onSendMessage: {}, onSendMessage: {},
showMacImagePicker: $showMacImagePicker showMacImagePicker: $showMacImagePicker
@@ -335,8 +329,6 @@ struct ViewSmokeTests {
_ = mount( _ = mount(
MeshPeerList( MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in }, onTapPeer: { _ in },
onToggleFavorite: { _ in }, onToggleFavorite: { _ in },
onShowFingerprint: { _ in } onShowFingerprint: { _ in }
@@ -344,8 +336,6 @@ struct ViewSmokeTests {
.environmentObject(featureModels.peerListModel) .environmentObject(featureModels.peerListModel)
) )
_ = MeshPeerList( _ = MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in }, onTapPeer: { _ in },
onToggleFavorite: { _ in }, onToggleFavorite: { _ in },
onShowFingerprint: { _ in } onShowFingerprint: { _ in }
@@ -363,8 +353,6 @@ struct ViewSmokeTests {
_ = mount( _ = mount(
MeshPeerList( MeshPeerList(
textColor: .green,
secondaryTextColor: .gray,
onTapPeer: { _ in }, onTapPeer: { _ in },
onToggleFavorite: { _ in }, onToggleFavorite: { _ in },
onShowFingerprint: { _ in } onShowFingerprint: { _ in }
@@ -389,10 +377,7 @@ struct ViewSmokeTests {
messageText: Binding( messageText: Binding(
get: { messageText }, get: { messageText },
set: { messageText = $0 } set: { messageText = $0 }
), )
textColor: .green,
backgroundColor: .black,
secondaryTextColor: .gray
) )
.environmentObject(featureModels.privateConversationModel) .environmentObject(featureModels.privateConversationModel)
.environmentObject(featureModels.locationChannelsModel) .environmentObject(featureModels.locationChannelsModel)
@@ -539,8 +524,6 @@ struct ViewSmokeTests {
let (viewModel, _, _) = makeSmokeViewModel() let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel) let featureModels = makeSmokeFeatureModels(for: viewModel)
let geohashPeopleList = GeohashPeopleList( let geohashPeopleList = GeohashPeopleList(
textColor: .green,
secondaryTextColor: .gray,
onTapPerson: {} onTapPerson: {}
) )
let truncatableMessage = BitchatMessage( let truncatableMessage = BitchatMessage(
@@ -34,12 +34,12 @@ public final class BitchatMessage: Codable {
// Cached formatted text (not included in Codable) // Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:] private var _cachedFormattedText: [String: AttributedString] = [:]
public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { public func getCachedFormattedText(isDark: Bool, isSelf: Bool, variant: String = "") -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"] return _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"]
} }
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool, variant: String = "") {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] = text
} }
// Codable implementation // Codable implementation