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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user