Add honeycomb pattern toggle and make theme switching reactive

Adds a toggleable honeycomb hexagonal grid overlay (matching the website pattern)
that can be enabled independently of any theme via the Appearance screen. Uses a
cached tiled UIImage approach consistent with the existing grain texture system.

Replaces the destructive refreshID-based theme switching (which destroyed all
NavigationStacks and dismissed sheets) with @Observable AppThemeSource. Color
resolution now happens through Swift's Observation framework, so all views using
Color.appPrimary etc. automatically re-render when the theme changes — no view
identity reset needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-07 11:36:24 -06:00
parent 28339544e5
commit 73dd440d7b
9 changed files with 247 additions and 48 deletions

View File

@@ -47,4 +47,11 @@ struct ThemeIDTests {
@Test func oceanRawValueIsOcean() {
#expect(ThemeID.ocean.rawValue == "Ocean")
}
// MARK: - Honeycomb Setting Tests
@Test func honeycombDefaultsToDisabled() {
// HoneycombSetting reads from UserDefaults which defaults to false for unset Bool keys
#expect(HoneycombSetting.isEnabled == false)
}
}

View File

@@ -29,6 +29,7 @@ enum AnalyticsEvent {
// MARK: - Settings
case themeChanged(theme: String)
case honeycombToggled(enabled: Bool)
case analyticsToggled(enabled: Bool)
// MARK: - Errors
@@ -82,6 +83,8 @@ enum AnalyticsEvent {
// Settings
case .themeChanged(let theme):
return ("theme_changed", ["theme": theme])
case .honeycombToggled(let enabled):
return ("honeycomb_toggled", ["enabled": enabled])
case .analyticsToggled(let enabled):
return ("analytics_toggled", ["enabled": enabled])

View File

@@ -3,29 +3,60 @@ import SwiftUI
// MARK: - Design System
// Modern, sleek design system for honeyDue with Light and Dark mode support
// MARK: - Reactive Theme Source
#if WIDGET_EXTENSION
// Widgets: read directly from UserDefaults (no reactivity needed)
enum AppThemeSource {
private static let defaults: UserDefaults = {
let groupID = Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String
return groupID.flatMap { UserDefaults(suiteName: $0) } ?? .standard
}()
static var themeName: String {
defaults.string(forKey: "selectedTheme") ?? ThemeID.bright.rawValue
}
static var honeycombEnabled: Bool {
defaults.bool(forKey: "honeycombEnabled")
}
}
#else
/// Observable theme source that SwiftUI automatically tracks.
/// When `themeName` changes, all views using `Color.appPrimary` etc. re-render.
@Observable
final class AppThemeSource {
static let shared = AppThemeSource()
var themeName: String
var honeycombEnabled: Bool
private init() {
let groupID = Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String
let defaults = groupID.flatMap { UserDefaults(suiteName: $0) } ?? .standard
self.themeName = defaults.string(forKey: "selectedTheme") ?? ThemeID.bright.rawValue
self.honeycombEnabled = defaults.bool(forKey: "honeycombEnabled")
}
}
#endif
// MARK: - Colors
extension Color {
// MARK: - Dynamic Theme Resolution
/// Shared App Group defaults for reading the active theme.
/// Thread-safe: UserDefaults is safe to read from any thread/actor.
private static let _themeDefaults: UserDefaults = {
let groupID = Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String
return groupID.flatMap { UserDefaults(suiteName: $0) } ?? .standard
}()
private static func themed(_ name: String) -> Color {
// Read theme directly from shared UserDefaults instead of going through
// @MainActor-isolated ThemeManager.shared. This is safe to call from any
// actor context (including widget timeline providers and background threads).
let theme = _themeDefaults.string(forKey: "selectedTheme") ?? ThemeID.bright.rawValue
#if WIDGET_EXTENSION
let theme = AppThemeSource.themeName
#else
let theme = AppThemeSource.shared.themeName
#endif
return Color("\(theme)/\(name)", bundle: nil)
}
// MARK: - Semantic Colors (Use These in UI)
// These dynamically resolve based on the active theme stored in App Group UserDefaults.
// Safe to call from any actor context (main app, widget extensions, background threads).
// Main app: resolved via @Observable AppThemeSource views auto-update on theme change.
// Widgets: resolved via UserDefaults (static, no reactivity needed).
static var appPrimary: Color { themed("Primary") }
static var appSecondary: Color { themed("Secondary") }
static var appAccent: Color { themed("Accent") }

View File

@@ -4,6 +4,18 @@ import UIKit
// MARK: - Organic Design System
// Warm, natural aesthetic with soft shapes, subtle textures, and flowing layouts
// MARK: - Honeycomb Setting Reader
/// Reads honeycomb enabled state from AppThemeSource (reactive in main app, static in widgets).
enum HoneycombSetting {
static var isEnabled: Bool {
#if WIDGET_EXTENSION
AppThemeSource.honeycombEnabled
#else
AppThemeSource.shared.honeycombEnabled
#endif
}
}
// MARK: - Organic Shapes
/// Soft organic blob shape for backgrounds
@@ -140,6 +152,80 @@ private final class GrainTextureCache {
}
}
// MARK: - Honeycomb Texture Cache
/// Generates and caches a honeycomb hexagonal grid tile image.
/// Matches the website pattern: 60×103.92px tile, #C4856A stroke, 0.8px width.
private final class HoneycombTextureCache {
static let shared = HoneycombTextureCache()
private(set) var cachedImage: UIImage?
private let tileSize = CGSize(width: 60, height: 103.92)
private let lock = NSLock()
private init() {
generateIfNeeded()
}
func generateIfNeeded() {
lock.lock()
defer { lock.unlock() }
guard cachedImage == nil else { return }
let w = tileSize.width
let h = tileSize.height
let strokeColor = UIColor(red: 196/255, green: 133/255, blue: 106/255, alpha: 1.0) // #C4856A
let strokeWidth: CGFloat = 0.8
let renderer = UIGraphicsImageRenderer(size: tileSize)
cachedImage = renderer.image { ctx in
let cgCtx = ctx.cgContext
cgCtx.setStrokeColor(strokeColor.cgColor)
cgCtx.setLineWidth(strokeWidth)
cgCtx.setFillColor(UIColor.clear.cgColor)
// First hexagon (top)
let hex1 = CGMutablePath()
hex1.move(to: CGPoint(x: 30, y: 0))
hex1.addLine(to: CGPoint(x: 60, y: 17.32))
hex1.addLine(to: CGPoint(x: 60, y: 51.96))
hex1.addLine(to: CGPoint(x: 30, y: 69.28))
hex1.addLine(to: CGPoint(x: 0, y: 51.96))
hex1.addLine(to: CGPoint(x: 0, y: 17.32))
hex1.closeSubpath()
cgCtx.addPath(hex1)
cgCtx.strokePath()
// Second hexagon (bottom-right, offset for tessellation)
let hex2 = CGMutablePath()
hex2.move(to: CGPoint(x: 60, y: 51.96))
hex2.addLine(to: CGPoint(x: 90, y: 69.28))
hex2.addLine(to: CGPoint(x: 90, y: h))
hex2.addLine(to: CGPoint(x: 60, y: h + 17.32))
hex2.addLine(to: CGPoint(x: 30, y: h))
hex2.addLine(to: CGPoint(x: 30, y: 69.28))
hex2.closeSubpath()
cgCtx.addPath(hex2)
cgCtx.strokePath()
}
}
}
// MARK: - Honeycomb Texture Overlay
struct HoneycombTexture: View {
var opacity: Double = 0.10
var body: some View {
if let uiImage = HoneycombTextureCache.shared.cachedImage {
Image(uiImage: uiImage)
.resizable(resizingMode: .tile)
.opacity(opacity)
.allowsHitTesting(false)
}
}
}
// MARK: - Grain Texture Overlay
struct GrainTexture: View {
@@ -193,6 +279,12 @@ struct OrganicCardBackground: View {
// Grain texture
GrainTexture(opacity: 0.015)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
// Honeycomb pattern overlay
if HoneycombSetting.isEnabled {
HoneycombTexture(opacity: 0.10)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
}
}
}
}
@@ -385,6 +477,11 @@ struct WarmGradientBackground: View {
// Grain for natural feel
GrainTexture(opacity: 0.02)
// Honeycomb pattern overlay
if HoneycombSetting.isEnabled {
HoneycombTexture(opacity: 0.10)
}
}
.ignoresSafeArea()
}

View File

@@ -10,4 +10,7 @@ extension Notification.Name {
// Task action completion notification (for UI refresh)
static let taskActionCompleted = Notification.Name("taskActionCompleted")
// Appearance changed posted when the settings sheet dismisses after theme/honeycomb changes
static let appearanceDidFinishChanging = Notification.Name("appearanceDidFinishChanging")
}

View File

@@ -88,7 +88,12 @@ class ThemeManager {
return .bright
}
var honeycombEnabled: Bool {
sharedDefaults.bool(forKey: honeycombKey)
}
private let themeKey = "selectedTheme"
private let honeycombKey = "honeycombEnabled"
private init() {}
}
@@ -104,7 +109,14 @@ class ThemeManager: ObservableObject {
}
}
@Published var honeycombEnabled: Bool {
didSet {
sharedDefaults.set(honeycombEnabled, forKey: honeycombKey)
}
}
private let themeKey = "selectedTheme"
private let honeycombKey = "honeycombEnabled"
private init() {
// Load saved theme from shared App Group defaults
@@ -114,17 +126,24 @@ class ThemeManager: ObservableObject {
} else {
self.currentTheme = .bright
}
self.honeycombEnabled = sharedDefaults.bool(forKey: honeycombKey)
}
private func saveTheme() {
// Save to shared App Group defaults so widgets can access it
sharedDefaults.set(currentTheme.rawValue, forKey: themeKey)
// Update reactive source so all views using Color.appPrimary etc. re-render
AppThemeSource.shared.themeName = currentTheme.rawValue
}
func setTheme(_ theme: ThemeID) {
withAnimation(.easeInOut(duration: 0.3)) {
currentTheme = theme
}
currentTheme = theme
}
func setHoneycomb(_ enabled: Bool) {
honeycombEnabled = enabled
// Update reactive source so honeycomb overlays re-render
AppThemeSource.shared.honeycombEnabled = enabled
}
}
#endif

View File

@@ -65,34 +65,11 @@ struct MainTabView: View {
.onChange(of: authManager.isAuthenticated) { _, _ in
selectedTab = .residences
}
.onChange(of: themeManager.currentTheme) { _, _ in
updateTabBarAppearance()
}
.onAppear {
// FIX_SKIPPED(F-10): UITabBar.appearance() is the standard SwiftUI pattern
// for customizing tab bar appearance. The global side effect persists but
// there is no safe alternative without UIKit hosting.
// Configure tab bar appearance
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
// Use theme-aware colors
appearance.backgroundColor = UIColor(Color.appBackgroundSecondary)
// Selected item uses Dynamic Type caption2 style (A-2)
appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary)
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor(Color.appPrimary),
.font: UIFont.preferredFont(forTextStyle: .caption2)
]
// Normal item uses Dynamic Type caption2 style (A-2)
appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: UIColor(Color.appTextSecondary),
.font: UIFont.preferredFont(forTextStyle: .caption2)
]
UITabBar.appearance().standardAppearance = appearance
UITabBar.appearance().scrollEdgeAppearance = appearance
updateTabBarAppearance()
// Handle pending navigation from push notification
if pushManager.pendingNavigationTaskId != nil {
@@ -119,6 +96,27 @@ struct MainTabView: View {
selectedTab = .residences
}
}
private func updateTabBarAppearance() {
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = UIColor(Color.appBackgroundSecondary)
appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary)
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor(Color.appPrimary),
.font: UIFont.preferredFont(forTextStyle: .caption2)
]
appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: UIColor(Color.appTextSecondary),
.font: UIFont.preferredFont(forTextStyle: .caption2)
]
UITabBar.appearance().standardAppearance = appearance
UITabBar.appearance().scrollEdgeAppearance = appearance
}
}
#Preview {

View File

@@ -11,6 +11,46 @@ struct ThemeSelectionView: View {
.ignoresSafeArea()
List {
// Honeycomb pattern toggle
Section {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 40, height: 40)
Image(systemName: "hexagon")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
VStack(alignment: .leading, spacing: 3) {
Text("Honeycomb Pattern")
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Adds a subtle hexagonal grid overlay")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
Spacer()
Toggle("", isOn: Binding(
get: { themeManager.honeycombEnabled },
set: { newValue in
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
AnalyticsManager.shared.track(.honeycombToggled(enabled: newValue))
themeManager.setHoneycomb(newValue)
}
))
.labelsHidden()
.tint(Color.appPrimary)
}
.padding(.vertical, 4)
}
.sectionBackground()
// Theme list
ForEach(ThemeID.allCases, id: \.self) { theme in
Button(action: {
selectTheme(theme)
@@ -46,8 +86,12 @@ struct ThemeSelectionView: View {
// Track theme change
AnalyticsManager.shared.track(.themeChanged(theme: theme.rawValue))
// Update theme with animation
themeManager.setTheme(theme)
// Set theme without animation to prevent list rows from being removed/shuffled
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
themeManager.currentTheme = theme
}
}
}

View File

@@ -190,9 +190,6 @@ struct RootView: View {
// Show main app
ZStack(alignment: .topLeading) {
MainTabView(refreshID: refreshID)
.onChange(of: themeManager.currentTheme) { _, _ in
refreshID = UUID()
}
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("ui.root.mainTabs")