From 73dd440d7b59460547c937920b0297d2eaa4c90a Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 7 Mar 2026 11:36:24 -0600 Subject: [PATCH] Add honeycomb pattern toggle and make theme switching reactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- iosApp/HoneyDueTests/ThemeIDTests.swift | 7 ++ iosApp/iosApp/Analytics/AnalyticsEvent.swift | 3 + iosApp/iosApp/Design/DesignSystem.swift | 57 ++++++++--- iosApp/iosApp/Design/OrganicDesign.swift | 97 +++++++++++++++++++ .../Extensions/Notification+Names.swift | 3 + iosApp/iosApp/Helpers/ThemeManager.swift | 25 ++++- iosApp/iosApp/MainTabView.swift | 52 +++++----- .../iosApp/Profile/ThemeSelectionView.swift | 48 ++++++++- iosApp/iosApp/RootView.swift | 3 - 9 files changed, 247 insertions(+), 48 deletions(-) diff --git a/iosApp/HoneyDueTests/ThemeIDTests.swift b/iosApp/HoneyDueTests/ThemeIDTests.swift index 08d34f7..9551c05 100644 --- a/iosApp/HoneyDueTests/ThemeIDTests.swift +++ b/iosApp/HoneyDueTests/ThemeIDTests.swift @@ -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) + } } diff --git a/iosApp/iosApp/Analytics/AnalyticsEvent.swift b/iosApp/iosApp/Analytics/AnalyticsEvent.swift index 709b434..64c096a 100644 --- a/iosApp/iosApp/Analytics/AnalyticsEvent.swift +++ b/iosApp/iosApp/Analytics/AnalyticsEvent.swift @@ -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]) diff --git a/iosApp/iosApp/Design/DesignSystem.swift b/iosApp/iosApp/Design/DesignSystem.swift index e95c768..c03aaec 100644 --- a/iosApp/iosApp/Design/DesignSystem.swift +++ b/iosApp/iosApp/Design/DesignSystem.swift @@ -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") } diff --git a/iosApp/iosApp/Design/OrganicDesign.swift b/iosApp/iosApp/Design/OrganicDesign.swift index 8fcbcde..a269cce 100644 --- a/iosApp/iosApp/Design/OrganicDesign.swift +++ b/iosApp/iosApp/Design/OrganicDesign.swift @@ -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() } diff --git a/iosApp/iosApp/Extensions/Notification+Names.swift b/iosApp/iosApp/Extensions/Notification+Names.swift index 0a70d5b..d25217e 100644 --- a/iosApp/iosApp/Extensions/Notification+Names.swift +++ b/iosApp/iosApp/Extensions/Notification+Names.swift @@ -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") } diff --git a/iosApp/iosApp/Helpers/ThemeManager.swift b/iosApp/iosApp/Helpers/ThemeManager.swift index 3e32a3f..58cd76f 100644 --- a/iosApp/iosApp/Helpers/ThemeManager.swift +++ b/iosApp/iosApp/Helpers/ThemeManager.swift @@ -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 diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 6d475c6..2f7c0f8 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -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 { diff --git a/iosApp/iosApp/Profile/ThemeSelectionView.swift b/iosApp/iosApp/Profile/ThemeSelectionView.swift index 73f96b1..b01a480 100644 --- a/iosApp/iosApp/Profile/ThemeSelectionView.swift +++ b/iosApp/iosApp/Profile/ThemeSelectionView.swift @@ -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 + } } } diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 6d59acb..9ab9733 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -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")