Files
bitchat/bitchat/Views/LocationChannelsSheet.swift
T
af954b05ea Liquid Glass theme with in-app appearance switcher (#1337)
* Centralize UI theme colors into semantic ThemePalette tokens

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

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

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

* Add liquid glass theme with in-app appearance switcher

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

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

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

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

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:13:04 +02:00

598 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import CoreLocation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@ThemedPalette private var palette
@State private var customGeohash: String = ""
@State private var customError: String? = nil
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
static let description: LocalizedStringKey = "location_channels.description"
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
return rowTitle(label: label, count: count)
}
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
// High-precision uncertainty: if count is 0 for high-precision levels,
// show "?" because presence broadcasting is disabled for privacy.
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
level.displayName
)
}
return rowTitle(label: level.displayName, count: count)
}
static func bookmarkTitle(geohash: String, count: Int) -> String {
// Check precision for bookmarks too
let len = geohash.count
// Neighborhood=6, Block=7, Building=8+
let isHighPrecision = (len >= 6)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
"#\(geohash)"
)
}
return rowTitle(label: "#\(geohash)", count: count)
}
static func subtitlePrefix(geohash: String, coverage: String) -> String {
String(
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
locale: .current,
geohash, coverage
)
}
static func subtitle(prefix: String, name: String?) -> String {
guard let name, !name.isEmpty else { return prefix }
return String(
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
locale: .current,
prefix, name
)
}
private static func rowTitle(label: String, count: Int) -> String {
String(
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
locale: .current,
label, count
)
}
}
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text(Strings.title)
.bitchatFont(size: 18)
Spacer()
closeButton
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Group {
switch locationChannelsModel.permissionState {
case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text(Strings.requestPermissions)
.bitchatFont(size: 12)
.foregroundColor(standardGreen)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(standardGreen.opacity(0.12))
.cornerRadius(6)
}
.buttonStyle(.plain)
case .denied, .restricted:
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
}
case .authorized:
EmptyView()
}
}
channelList
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
#else
.navigationTitle("")
#endif
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.themedSheetBackground()
.onAppear {
// Refresh channels when opening
if locationChannelsModel.permissionState == .authorized {
locationChannelsModel.refreshChannels()
}
// Begin periodic refresh while sheet is open
locationChannelsModel.beginLiveRefresh()
// Geohash sampling is now managed by ChatViewModel globally
}
.onDisappear {
locationChannelsModel.endLiveRefresh()
}
.onChange(of: locationChannelsModel.permissionState) { newValue in
if newValue == .authorized {
locationChannelsModel.refreshChannels()
}
}
.onChange(of: locationChannelsModel.availableChannels) { _ in }
}
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) {
locationChannelsModel.select(ChannelID.mesh)
isPresented = false
}
.padding(.vertical, 6)
let nearby = locationChannelsModel.availableChannels.filter { $0.level != .building }
if !nearby.isEmpty {
ForEach(nearby) { channel in
sectionDivider
let coverage = coverageString(forPrecision: channel.geohash.count)
let nameBase = locationName(for: channel.level)
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let participantCount = peerListModel.participantCount(for: channel.geohash)
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
let highlight = participantCount > 0
channelRow(
title: Strings.levelTitle(for: channel.level, count: participantCount),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.padding(.vertical, 6)
}
} else {
sectionDivider
HStack(spacing: 8) {
ProgressView()
Text(Strings.loadingNearby)
.bitchatFont(size: 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
}
sectionDivider
customTeleportSection
.padding(.vertical, 8)
let bookmarkedList = locationChannelsModel.bookmarks
if !bookmarkedList.isEmpty {
sectionDivider
bookmarkedSection(bookmarkedList)
.padding(.vertical, 8)
}
if locationChannelsModel.permissionState == .authorized {
sectionDivider
torToggleSection
.padding(.top, 12)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
.padding(.vertical, 8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.themedSurface()
}
.themedSurface()
}
private var sectionDivider: some View {
Rectangle()
.fill(dividerColor)
.frame(height: 1)
}
private var dividerColor: Color { palette.divider }
private var customTeleportSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) {
Text(verbatim: "#")
.bitchatFont(size: 14)
.foregroundColor(.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.bitchatFont(size: 14)
.onChange(of: customGeohash) { newValue in
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let filtered = newValue
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
if filtered.count > 12 {
customGeohash = String(filtered.prefix(12))
} else if filtered != newValue {
customGeohash = filtered
}
}
let normalized = customGeohash
.trimmed
.lowercased()
.replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized)
Button(action: {
let gh = normalized
guard isValid else { customError = Strings.invalidGeohash; return }
locationChannelsModel.teleport(to: gh)
isPresented = false
}) {
HStack(spacing: 6) {
Text(Strings.teleport)
.bitchatFont(size: 14)
Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14))
}
}
.buttonStyle(.plain)
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12))
.cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid)
}
if let err = customError {
Text(err)
.bitchatFont(size: 12)
.foregroundColor(.red)
}
}
}
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
let name = locationChannelsModel.bookmarkNames[gh]
let participantCount = peerListModel.participantCount(for: gh)
channelRow(
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: locationChannelsModel.isSelected(channel),
trailingAccessory: {
Button(action: { locationChannelsModel.toggleBookmark(gh) }) {
Image(systemName: locationChannelsModel.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
locationChannelsModel.markTeleported(for: gh, true)
} else {
locationChannelsModel.markTeleported(for: gh, false)
}
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.padding(.vertical, 6)
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
if index < entries.count - 1 {
sectionDivider
}
}
}
}
}
private func isSelected(_ channel: GeohashChannel) -> Bool {
locationChannelsModel.isSelected(channel)
}
private var isMeshSelected: Bool {
if case .mesh = locationChannelsModel.selectedChannel { return true }
return false
}
@ViewBuilder
private func channelRow(
title: String,
subtitlePrefix: String,
subtitleName: String? = nil,
subtitleNameBold: Bool = false,
isSelected: Bool,
titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) {
// Render title with smaller font for trailing count in parentheses
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
if isSelected {
Text(verbatim: "✔︎")
.bitchatFont(size: 16)
.foregroundColor(standardGreen)
}
trailingAccessory()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(perform: action)
}
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
let prefix = String(s[..<idx]).trimmed
let suffix = String(s[idx...])
return (prefix, suffix)
}
private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false }
return s.allSatisfy { allowed.contains($0) }
}
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
case 8: return .building
default: return .block
}
}
}
// MARK: - TOR Toggle & Standardized Colors
extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { locationChannelsModel.userTorEnabled },
set: { locationChannelsModel.setUserTorEnabled($0) }
)
}
private var torToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary)
Text(Strings.torSubtitle)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
.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 { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
let onLabel: LocalizedStringKey
let offLabel: LocalizedStringKey
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Coverage helpers
extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String {
// Approximate max cell dimension at equator for a given geohash length.
// Values sourced from common geohash dimension tables.
let maxMeters: Double = {
switch len {
case 2: return 1_250_000
case 3: return 156_000
case 4: return 39_100
case 5: return 4_890
case 6: return 1_220
case 7: return 153
case 8: return 38.2
case 9: return 4.77
case 10: return 1.19
default:
if len <= 1 { return 5_000_000 }
// For >10, scale down conservatively by ~1/4 each char
let over = len - 10
return 1.19 * pow(0.25, Double(over))
}
}()
let usesMetric: Bool = {
if #available(iOS 16.0, macOS 13.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
}
}()
if usesMetric {
let km = maxMeters / 1000.0
return "~\(formatDistance(km)) km"
} else {
let miles = maxMeters / 1609.344
return "~\(formatDistance(miles)) mi"
}
}
private func formatDistance(_ value: Double) -> String {
if value >= 100 { return String(format: "%.0f", value.rounded()) }
if value >= 10 { return String(format: "%.1f", value) }
return String(format: "%.1f", value)
}
private func bluetoothRangeString() -> String {
let usesMetric: Bool = {
if #available(iOS 16.0, macOS 13.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
}
}()
// Approximate Bluetooth LE range for typical mobile devices; environment dependent
return usesMetric ? "~1050 m" : "~30160 ft"
}
private func locationName(for level: GeohashChannelLevel) -> String? {
locationChannelsModel.locationName(for: level)
}
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
switch level {
case .region:
return ""
case .building, .block, .neighborhood, .city, .province:
return "~"
}
}
}