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
+31 -37
View File
@@ -9,11 +9,11 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
@State private var customGeohash: String = ""
@State private var customError: String? = nil
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
@@ -97,12 +97,12 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text(Strings.title)
.font(.bitchatSystem(size: 18, design: .monospaced))
.bitchatFont(size: 18)
Spacer()
closeButton
}
Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Group {
@@ -110,7 +110,7 @@ struct LocationChannelsSheet: View {
case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text(Strings.requestPermissions)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(standardGreen)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
@@ -121,7 +121,7 @@ struct LocationChannelsSheet: View {
case .denied, .restricted:
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
@@ -136,7 +136,7 @@ struct LocationChannelsSheet: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(backgroundColor)
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
@@ -147,7 +147,7 @@ struct LocationChannelsSheet: View {
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.background(backgroundColor)
.themedSheetBackground()
.onAppear {
// Refresh channels when opening
if locationChannelsModel.permissionState == .authorized {
@@ -171,7 +171,7 @@ struct LocationChannelsSheet: View {
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -223,7 +223,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 8) {
ProgressView()
Text(Strings.loadingNearby)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
@@ -246,8 +246,8 @@ struct LocationChannelsSheet: View {
.padding(.top, 12)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
@@ -259,9 +259,9 @@ struct LocationChannelsSheet: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.background(backgroundColor)
.themedSurface()
}
.background(backgroundColor)
.themedSurface()
}
private var sectionDivider: some View {
@@ -270,15 +270,13 @@ struct LocationChannelsSheet: View {
.frame(height: 1)
}
private var dividerColor: Color {
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
}
private var dividerColor: Color { palette.divider }
private var customTeleportSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) {
Text(verbatim: "#")
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
@@ -286,7 +284,7 @@ struct LocationChannelsSheet: View {
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.onChange(of: customGeohash) { newValue in
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let filtered = newValue
@@ -312,13 +310,13 @@ struct LocationChannelsSheet: View {
}) {
HStack(spacing: 6) {
Text(Strings.teleport)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14))
}
}
.buttonStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12))
@@ -328,7 +326,7 @@ struct LocationChannelsSheet: View {
}
if let err = customError {
Text(err)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.red)
}
}
@@ -337,7 +335,7 @@ struct LocationChannelsSheet: View {
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
@@ -409,18 +407,18 @@ struct LocationChannelsSheet: View {
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
@@ -428,7 +426,7 @@ struct LocationChannelsSheet: View {
Spacer()
if isSelected {
Text(verbatim: "✔︎")
.font(.bitchatSystem(size: 16, design: .monospaced))
.bitchatFont(size: 16)
.foregroundColor(standardGreen)
}
trailingAccessory()
@@ -478,26 +476,22 @@ extension LocationChannelsSheet {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary)
Text(Strings.torSubtitle)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
}
private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var standardBlue: Color {
Color(red: 0.0, green: 0.478, blue: 1.0)
}
private var standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
private struct IRCToggleStyle: ToggleStyle {
@@ -512,7 +506,7 @@ private struct IRCToggleStyle: ToggleStyle {
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)