refactored and moved to ios26 base

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-07 10:32:11 -06:00
parent 7c16d1e646
commit 7252500c9a
33 changed files with 598 additions and 897 deletions

View File

@ -1,10 +1,10 @@
Use /ios-18-role Use /ios-26-role
read the PRD.md read the PRD.md
read the README.md read the README.md
read the Bedrock README.md as well read the Bedrock README.md as well
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented. Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator. Always try to build using xcodebuildmcp after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
Try and use xcode build mcp if it is working and test using screenshots when asked. Try and use xcode build mcp if it is working and test using screenshots when asked.

26
PRD.md
View File

@ -22,7 +22,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Safe area handling** with proper Dynamic Island avoidance on iPhone and full-width layout on iPad - **Safe area handling** with proper Dynamic Island avoidance on iPhone and full-width layout on iPad
- **Full-screen mode** with status bar hiding and tab bar expansion - **Full-screen mode** with status bar hiding and tab bar expansion
- **Orientation-aware spacing** for optimal layout in all orientations - **Orientation-aware spacing** for optimal layout in all orientations
- **Modern iOS 18+ Animations**: - **Modern iOS 26 Animations**:
- **Selectable Animation Styles**: Choose from effective animation styles including None, Spring, Bounce, and Glitch. - **Selectable Animation Styles**: Choose from effective animation styles including None, Spring, Bounce, and Glitch.
- **Numeric Text Transitions**: Smooth scrolling transitions for digits using `.contentTransition(.numericText())` (available in most styles). - **Numeric Text Transitions**: Smooth scrolling transitions for digits using `.contentTransition(.numericText())` (available in most styles).
- **Phase-Based Digit Animations**: Dynamic scale, vertical offset, and jitter effects when digits change. - **Phase-Based Digit Animations**: Dynamic scale, vertical offset, and jitter effects when digits change.
@ -240,7 +240,7 @@ These principles are fundamental to the project's long-term success and must be
- **Main App**: `TheNoiseClockApp.swift` - Entry point with WindowGroup - **Main App**: `TheNoiseClockApp.swift` - Entry point with WindowGroup
- **Branded launch**: AppLaunchView wrapped in a Color.Branding.primary ZStack - **Branded launch**: AppLaunchView wrapped in a Color.Branding.primary ZStack
- **Tab-based navigation**: Three main tabs (Clock, Alarms, Noise) - **Tab-based navigation**: Three main tabs (Clock, Alarms, Noise)
- **SwiftUI framework**: Modern declarative UI framework with iOS 18+ and iOS 26 features - **SwiftUI framework**: Modern declarative UI framework with iOS 26 features and Liquid Glass
- **Dark theme**: Preferred color scheme set to dark - **Dark theme**: Preferred color scheme set to dark
### Data Models ### Data Models
@ -343,7 +343,7 @@ These principles are fundamental to the project's long-term success and must be
### Visual Design ### Visual Design
- **Rounded corners**: Modern iOS design language - **Rounded corners**: Modern iOS design language
- **Modern animations**: iOS 18+ smooth and bouncy animations - **Modern animations**: iOS 26 smooth and bouncy animations
- **Color consistency**: Bedrock theme with branded surfaces and accents - **Color consistency**: Bedrock theme with branded surfaces and accents
- **Branded launch**: AppLaunchView with matching launch screen background - **Branded launch**: AppLaunchView with matching launch screen background
- **Accessibility**: Proper labels and hidden decorative elements - **Accessibility**: Proper labels and hidden decorative elements
@ -607,19 +607,19 @@ The following changes **automatically require** PRD updates:
## Technical Requirements ## Technical Requirements
### iOS Compatibility ### iOS Compatibility
- **Minimum iOS version**: iOS 18.0+ (Latest SwiftUI features and performance optimizations) - **Minimum iOS version**: iOS 26.0+ (required for AlarmKit, Liquid Glass, and latest SwiftUI features)
- **Target devices**: iPhone and iPad with full adaptive layout support - **Target devices**: iPhone and iPad with full adaptive layout support
- **Orientation support**: Portrait and landscape with dynamic type support - **Orientation support**: Portrait and landscape with dynamic type support
- **Accessibility**: Full VoiceOver and Dynamic Type support - **Accessibility**: Full VoiceOver and Dynamic Type support
### Modern iOS Technology Stack ### Modern iOS Technology Stack
- **SwiftUI**: Latest declarative UI framework with iOS 18+ and iOS 26 features - **SwiftUI**: Latest declarative UI framework with iOS 26 Liquid Glass design
- **Observation Framework**: Modern @Observable pattern for state management - **Observation Framework**: Modern @Observable pattern for state management
- **SwiftData**: Advanced data persistence with iOS 18+ SwiftData features - **SwiftData**: Advanced data persistence with iOS 26 SwiftData features
- **AlarmKit**: System-level alarms that cut through Focus and Silent modes (iOS 26+)
- **Async/Await**: Modern concurrency patterns throughout - **Async/Await**: Modern concurrency patterns throughout
- **Structured Concurrency**: Task groups and actors for complex operations - **Structured Concurrency**: Task groups and actors for complex operations
- **Swift 6**: Latest language features and safety improvements - **Swift 6.2**: Latest language features with approachable concurrency
- **iOS 26 Features**: Latest platform capabilities where available
### Dependencies ### Dependencies
- **AudioPlaybackKit**: Local Swift package for reusable audio functionality - **AudioPlaybackKit**: Local Swift package for reusable audio functionality
@ -776,22 +776,22 @@ The following simulators are available for testing:
### Project Information ### Project Information
- **Created**: September 7, 2025 - **Created**: September 7, 2025
- **Framework**: SwiftUI with iOS 18.0+ target (latest stable features) - **Framework**: SwiftUI with iOS 26.0+ target
- **Architecture**: Modern SwiftUI with @Observable pattern, MVVM, and Swift Package modularity - **Architecture**: Modern SwiftUI with @Observable pattern, MVVM, and Swift Package modularity
- **Package Architecture**: AudioPlaybackKit Swift package for reusable audio functionality - **Package Architecture**: AudioPlaybackKit Swift package for reusable audio functionality
- **Testing**: Comprehensive unit and UI test targets with Swift Testing - **Testing**: Comprehensive unit and UI test targets with Swift Testing
- **Version control**: Git repository with feature branch workflow - **Version control**: Git repository with feature branch workflow
- **Performance**: Optimized for battery life and smooth operation - **Performance**: Optimized for battery life and smooth operation
- **Modern iOS**: Uses latest iOS 18+ and iOS 26 features with Swift 6 language improvements - **Modern iOS**: Uses iOS 26 features including AlarmKit and Liquid Glass with Swift 6.2
- **Code Reusability**: Modular Swift package architecture enables code reuse across projects - **Code Reusability**: Modular Swift package architecture enables code reuse across projects
### Modern iOS Development Practices ### Modern iOS Development Practices
- **Swift 6**: Latest language features including strict concurrency checking - **Swift 6.2**: Latest language features with approachable concurrency
- **Async/Await**: Modern concurrency patterns throughout the codebase - **Async/Await**: Modern concurrency patterns throughout the codebase
- **Observation Framework**: @Observable for reactive state management - **Observation Framework**: @Observable for reactive state management
- **SwiftUI Navigation**: Latest NavigationStack and navigation APIs with iOS 18+ features - **SwiftUI Navigation**: Latest NavigationStack and navigation APIs with iOS 26 features
- **Accessibility**: Full VoiceOver and Dynamic Type support with iOS 26 enhancements - **Accessibility**: Full VoiceOver and Dynamic Type support with iOS 26 enhancements
- **Adaptive Layout**: Support for all device sizes and orientations - **Adaptive Layout**: Support for all device sizes and orientations with Liquid Glass
- **Performance**: Optimized for 120Hz ProMotion displays and iOS 26 performance improvements - **Performance**: Optimized for 120Hz ProMotion displays and iOS 26 performance improvements
- **Memory Management**: ARC with proper weak references and cleanup - **Memory Management**: ARC with proper weak references and cleanup
- **Error Handling**: Result types and proper error propagation - **Error Handling**: Result types and proper error propagation

View File

@ -29,7 +29,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Glow and opacity controls for low-light comfort - Glow and opacity controls for low-light comfort
- Clock tab hides the status bar for a distraction-free display - Clock tab hides the status bar for a distraction-free display
- Selectable animation styles: None, Spring, Bounce, and Glitch - Selectable animation styles: None, Spring, Bounce, and Glitch
- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons - Modern iOS 26 animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons
- Automatic full-screen: UI fades out after 5 seconds of inactivity, tap to restore - Automatic full-screen: UI fades out after 5 seconds of inactivity, tap to restore
- Optional mini-controls for alarms and white noise directly on the clock face - Optional mini-controls for alarms and white noise directly on the clock face

View File

@ -7,12 +7,12 @@
<key>TheNoiseClock.xcscheme_^#shared#^_</key> <key>TheNoiseClock.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>3</integer> <integer>1</integer>
</dict> </dict>
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key> <key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@ -13,7 +13,7 @@ struct ContentView: View {
// MARK: - Properties // MARK: - Properties
private enum Tab: Hashable, CustomStringConvertible { private enum AppTab: Hashable, CustomStringConvertible {
case clock case clock
case alarms case alarms
case noise case noise
@ -29,7 +29,7 @@ struct ContentView: View {
} }
} }
@State private var selectedTab: Tab = .clock @State private var selectedTab: AppTab = .clock
@State private var clockViewModel = ClockViewModel() @State private var clockViewModel = ClockViewModel()
@State private var alarmViewModel = AlarmViewModel() @State private var alarmViewModel = AlarmViewModel()
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock") @State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
@ -48,48 +48,40 @@ struct ContentView: View {
ZStack { ZStack {
// Main tab content // Main tab content
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
NavigationStack { Tab("Clock", systemImage: "clock", value: AppTab.clock) {
// Pass isOnClockTab so ClockView can make the right tab bar decision NavigationStack {
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode // Pass isOnClockTab so ClockView can make the right tab bar decision
// This prevents race conditions on tab switch // Tab bar hides ONLY when: isOnClockTab && isDisplayMode
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab) // This prevents race conditions on tab switch
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
}
} }
.tabItem {
Label("Clock", systemImage: "clock")
}
.tag(Tab.clock)
NavigationStack { Tab("Alarms", systemImage: "alarm", value: AppTab.alarms) {
AlarmView(viewModel: alarmViewModel) NavigationStack {
AlarmView(viewModel: alarmViewModel)
}
} }
.tabItem {
Label("Alarms", systemImage: "alarm")
}
.tag(Tab.alarms)
NavigationStack { Tab("Noise", systemImage: "waveform", value: AppTab.noise) {
NoiseView() NavigationStack {
NoiseView()
}
} }
.tabItem {
Label("Noise", systemImage: "waveform")
}
.tag(Tab.noise)
NavigationStack { Tab("Settings", systemImage: "gearshape", value: AppTab.settings) {
ClockSettingsView( NavigationStack {
style: clockViewModel.style, ClockSettingsView(
onCommit: { newStyle in style: clockViewModel.style,
clockViewModel.updateStyle(newStyle) onCommit: { newStyle in
}, clockViewModel.updateStyle(newStyle)
onResetOnboarding: { },
onboardingState.reset() onResetOnboarding: {
} onboardingState.reset()
) }
)
}
} }
.tabItem {
Label("Settings", systemImage: "gearshape")
}
.tag(Tab.settings)
} }
.onChange(of: selectedTab) { oldValue, newValue in .onChange(of: selectedTab) { oldValue, newValue in
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)") Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
@ -100,7 +92,7 @@ struct ContentView: View {
clockViewModel.setFullScreenMode(false) clockViewModel.setFullScreenMode(false)
} }
} }
.accentColor(AppAccent.primary) .tint(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea()) .background(Color.Branding.primary.ignoresSafeArea())
// Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island. // Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island.
// No in-app alarm screen is needed - users interact with alarms via the system UI. // No in-app alarm screen is needed - users interact with alarms via the system UI.

View File

@ -97,7 +97,7 @@ class AlarmSoundService {
} }
/// Get alarm sound categories /// Get alarm sound categories
func getAlarmSoundCategories() -> [TheNoiseClock.SoundCategory] { func getAlarmSoundCategories() -> [SoundCategory] {
return [.alarm] return [.alarm]
} }

View File

@ -27,7 +27,7 @@ struct AddAlarmView: View {
@State private var volume: Float = 1.0 @State private var volume: Float = 1.0
var body: some View { var body: some View {
NavigationView { NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
// Time picker section at top // Time picker section at top
TimePickerSection(selectedTime: $newAlarmTime) TimePickerSection(selectedTime: $newAlarmTime)
@ -48,12 +48,12 @@ struct AddAlarmView: View {
NavigationLink(destination: LabelEditView(label: $alarmLabel)) { NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
HStack { HStack {
Image(systemName: "textformat") Image(systemName: "textformat")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Label") Text("Label")
Spacer() Spacer()
Text(alarmLabel) Text(alarmLabel)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
@ -61,12 +61,12 @@ struct AddAlarmView: View {
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) { NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
HStack { HStack {
Image(systemName: "message") Image(systemName: "message")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Message") Text("Message")
Spacer() Spacer()
Text(notificationMessage) Text(notificationMessage)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
} }
} }
@ -75,12 +75,12 @@ struct AddAlarmView: View {
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) { NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
HStack { HStack {
Image(systemName: "music.note") Image(systemName: "music.note")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Sound") Text("Sound")
Spacer() Spacer()
Text(getSoundDisplayName(selectedSoundName)) Text(getSoundDisplayName(selectedSoundName))
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
@ -88,12 +88,12 @@ struct AddAlarmView: View {
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) { NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
HStack { HStack {
Image(systemName: "clock.arrow.circlepath") Image(systemName: "clock.arrow.circlepath")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Snooze") Text("Snooze")
Spacer() Spacer()
Text("for \(snoozeDuration) min") Text("for \(snoozeDuration) min")
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
} }
@ -107,7 +107,7 @@ struct AddAlarmView: View {
Button("Cancel") { Button("Cancel") {
isPresented = false isPresented = false
} }
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@ -127,7 +127,7 @@ struct AddAlarmView: View {
isPresented = false isPresented = false
} }
} }
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
} }

View File

@ -26,15 +26,15 @@ struct AlarmRowView: View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(alarm.formattedTime()) Text(alarm.formattedTime())
.font(.headline) .font(.headline)
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Text(alarm.label) Text(alarm.label)
.font(.subheadline) .font(.subheadline)
.foregroundColor(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
Text("\(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))") Text("\(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
.font(.caption) .font(.caption)
.foregroundColor(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
if alarm.isEnabled && !isKeepAwakeEnabled { if alarm.isEnabled && !isKeepAwakeEnabled {
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {

View File

@ -27,18 +27,18 @@ struct EmptyAlarmsView: View {
Image(systemName: "alarm.fill") Image(systemName: "alarm.fill")
.font(.system(size: 40)) .font(.system(size: 40))
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.symbolEffect(.bounce, value: true) .symbolEffect(.bounce, value: true)
} }
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
Text("No Alarms Set") Text("No Alarms Set")
.typography(.title2Bold) .typography(.title2Bold)
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Text("Create an alarm to wake up gently on your own terms.") Text("Create an alarm to wake up gently on your own terms.")
.typography(.body) .typography(.body)
.foregroundColor(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
} }
@ -54,7 +54,7 @@ struct EmptyAlarmsView: View {
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium) .padding(.vertical, Design.Spacing.medium)
.background(AppAccent.primary) .background(AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.shadow(color: AppAccent.primary.opacity(0.3), radius: 8, x: 0, y: 4) .shadow(color: AppAccent.primary.opacity(0.3), radius: 8, x: 0, y: 4)
} }
.padding(.top, Design.Spacing.medium) .padding(.top, Design.Spacing.medium)

View File

@ -21,11 +21,11 @@ struct SnoozeSelectionView: View {
ForEach(snoozeOptions, id: \.self) { duration in ForEach(snoozeOptions, id: \.self) { duration in
HStack { HStack {
Text("\(duration) minutes") Text("\(duration) minutes")
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Spacer() Spacer()
if snoozeDuration == duration { if snoozeDuration == duration {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())

View File

@ -28,11 +28,11 @@ struct SoundSelectionView: View {
HStack { HStack {
Text(sound.name) Text(sound.name)
.font(.body) .font(.body)
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Spacer() Spacer()
if selectedSound == sound.fileName { if selectedSound == sound.fileName {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@ -62,7 +62,7 @@ struct SoundSelectionView: View {
} }
}) { }) {
Image(systemName: isPlaying && currentlyPlayingSound == selectedSound ? "stop.fill" : "play.fill") Image(systemName: isPlaying && currentlyPlayingSound == selectedSound ? "stop.fill" : "play.fill")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
} }
} }
} }

View File

@ -15,15 +15,15 @@ struct TimeUntilAlarmSection: View {
VStack(spacing: 4) { VStack(spacing: 4) {
HStack { HStack {
Image(systemName: "calendar") Image(systemName: "calendar")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
Text(timeUntilAlarm) Text(timeUntilAlarm)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
Text(dayText) Text(dayText)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 12)

View File

@ -46,7 +46,7 @@ struct EditAlarmView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
NavigationView { NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
// Time picker section at top // Time picker section at top
TimePickerSection(selectedTime: $alarmTime) TimePickerSection(selectedTime: $alarmTime)
@ -136,7 +136,7 @@ struct EditAlarmView: View {
Button("Cancel") { Button("Cancel") {
dismiss() dismiss()
} }
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@ -158,7 +158,7 @@ struct EditAlarmView: View {
dismiss() dismiss()
} }
} }
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
} }

View File

@ -11,7 +11,7 @@ import Bedrock
/// Clock customization settings and data model /// Clock customization settings and data model
@Observable @Observable
class ClockStyle: Codable, Equatable { final class ClockStyle: Codable, Equatable {
// MARK: - Time Format Settings // MARK: - Time Format Settings
var use24Hour: Bool = false var use24Hour: Bool = false

View File

@ -14,7 +14,7 @@ import Bedrock
/// ViewModel for clock display and management /// ViewModel for clock display and management
@Observable @Observable
class ClockViewModel { final class ClockViewModel {
// MARK: - Properties // MARK: - Properties
private(set) var currentTime = Date() private(set) var currentTime = Date()

View File

@ -24,9 +24,9 @@ struct BatteryOverlayView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: batteryIcon) Image(systemName: batteryIcon)
.foregroundColor(batteryColor) .foregroundStyle(batteryColor)
Text("\(batteryLevel)%") Text("\(batteryLevel)%")
.foregroundColor(color) .foregroundStyle(color)
} }
.opacity(clamped) .opacity(clamped)
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
@ -75,212 +75,15 @@ struct BatteryOverlayView: View {
} }
// MARK: - Previews // MARK: - Previews
#Preview("Battery Overlay - Full Charge") {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 100,
isCharging: false
)
}
#Preview("Battery Overlay - High Charge") { #Preview("Battery Overlay - All States") {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 75,
isCharging: false
)
}
#Preview("Battery Overlay - Medium Charge") {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 50,
isCharging: false
)
}
#Preview("Battery Overlay - Low Charge") {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 25,
isCharging: false
)
}
#Preview("Battery Overlay - Critical Charge") {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 10,
isCharging: false
)
}
#Preview("Battery Overlay - Charging") {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 85,
isCharging: true
)
}
#Preview("Battery Overlay - Charging States") {
VStack(spacing: 20) { VStack(spacing: 20) {
BatteryOverlayView( BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 100, isCharging: false)
color: .white, BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 50, isCharging: false)
opacity: 1.0, BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 15, isCharging: false)
batteryLevel: 75, BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 5, isCharging: false)
isCharging: false BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 75, isCharging: true)
)
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 75,
isCharging: true
)
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 25,
isCharging: false
)
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 25,
isCharging: true
)
} }
.padding() .padding()
.background(Color.black) .background(Color.black)
} }
#Preview("Battery Overlay - Different Colors") {
VStack(spacing: 20) {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 75,
isCharging: false
)
BatteryOverlayView(
color: .blue,
opacity: 1.0,
batteryLevel: 50,
isCharging: false
)
BatteryOverlayView(
color: .green,
opacity: 1.0,
batteryLevel: 25,
isCharging: false
)
BatteryOverlayView(
color: .red,
opacity: 1.0,
batteryLevel: 10,
isCharging: false
)
}
.padding()
.background(Color.black)
}
#Preview("Battery Overlay - Different Opacities") {
VStack(spacing: 20) {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 75,
isCharging: false
)
BatteryOverlayView(
color: .white,
opacity: 0.7,
batteryLevel: 50,
isCharging: false
)
BatteryOverlayView(
color: .white,
opacity: 0.5,
batteryLevel: 25,
isCharging: false
)
BatteryOverlayView(
color: .white,
opacity: 0.3,
batteryLevel: 10,
isCharging: false
)
}
.padding()
.background(Color.black)
}
#Preview("Battery Overlay - Dark Background") {
BatteryOverlayView(
color: .white,
opacity: 1.0,
batteryLevel: 75,
isCharging: false
)
.padding()
.background(Color.black)
}
#Preview("Battery Overlay - Light Background") {
BatteryOverlayView(
color: .black,
opacity: 1.0,
batteryLevel: 50,
isCharging: false
)
.padding()
.background(Color.white)
}
#Preview("Battery Overlay - Clock Context") {
ZStack {
// Simulate clock background
Color.black
.ignoresSafeArea()
VStack {
Spacer()
// Simulate clock display
Text("12:34")
.font(.system(size: 80, weight: .bold, design: .rounded))
.foregroundColor(.white)
Spacer()
// Battery overlay at bottom
HStack {
Spacer()
BatteryOverlayView(
color: .white,
opacity: 0.8,
batteryLevel: 75,
isCharging: false
)
Spacer()
}
.padding(.bottom, 50)
}
}
}

View File

@ -25,7 +25,7 @@ struct DateOverlayView: View {
let clamped = ColorUtils.clampOpacity(opacity) let clamped = ColorUtils.clampOpacity(opacity)
Text(dateString) Text(dateString)
.foregroundColor(color) .foregroundStyle(color)
.opacity(clamped) .opacity(clamped)
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.onAppear { .onAppear {

View File

@ -65,7 +65,7 @@ struct DigitView: View {
private var glowText: some View { private var glowText: some View {
baseText baseText
.foregroundColor(digitColor) .foregroundStyle(digitColor)
.blur(radius: glowRadius) .blur(radius: glowRadius)
.opacity(glowOpacity) .opacity(glowOpacity)
.modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity)) .modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity))
@ -73,7 +73,7 @@ struct DigitView: View {
private var mainText: some View { private var mainText: some View {
baseText baseText
.foregroundColor(digitColor) .foregroundStyle(digitColor)
.opacity(opacity) .opacity(opacity)
.modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize)) .modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize))
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text") .debugBorder(Self.debugShowBorders, color: .orange, label: "Text")

View File

@ -34,12 +34,12 @@ struct NextAlarmOverlay: View {
Text(alarmString) Text(alarmString)
.typography(.calloutEmphasis) .typography(.calloutEmphasis)
} }
.foregroundColor(color) .foregroundStyle(color)
.opacity(opacity) .opacity(opacity)
.padding(.horizontal, Design.Spacing.small) .padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall) .padding(.vertical, Design.Spacing.xxSmall)
.background(AppSurface.overlay.opacity(0.3)) .background(AppSurface.overlay.opacity(0.3))
.cornerRadius(Design.CornerRadius.small) .clipShape(.rect(cornerRadius: Design.CornerRadius.small))
} }
} }
} }

View File

@ -27,21 +27,21 @@ struct NoiseMiniPlayer: View {
Button(action: onToggle) { Button(action: onToggle) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill") Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 14, weight: .bold))
.foregroundColor(.white) .foregroundStyle(.white)
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
.background(isPlaying ? AppAccent.primary.opacity(0.8) : AppAccent.primary.opacity(0.8)) .background(AppAccent.primary.opacity(0.8))
.clipShape(Circle()) .clipShape(Circle())
} }
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(isPlaying ? "Playing" : "Paused") Text(isPlaying ? "Playing" : "Paused")
.font(.system(size: 8, weight: .bold)) .font(.system(size: 8, weight: .bold))
.foregroundColor(color.opacity(0.6)) .foregroundStyle(color.opacity(0.6))
.textCase(.uppercase) .textCase(.uppercase)
Text(name) Text(name)
.typography(.calloutEmphasis) .typography(.calloutEmphasis)
.foregroundColor(color) .foregroundStyle(color)
.lineLimit(1) .lineLimit(1)
} }
} }
@ -49,7 +49,7 @@ struct NoiseMiniPlayer: View {
.padding(.trailing, Design.Spacing.medium) .padding(.trailing, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xxSmall) .padding(.vertical, Design.Spacing.xxSmall)
.background(AppSurface.overlay.opacity(0.3)) .background(AppSurface.overlay.opacity(0.3))
.cornerRadius(Design.CornerRadius.appLarge) .clipShape(.rect(cornerRadius: Design.CornerRadius.appLarge))
.opacity(opacity) .opacity(opacity)
} }
} }

View File

@ -170,7 +170,7 @@ struct TimeDisplayView: View {
size: max(12, fontSize * 0.18) size: max(12, fontSize * 0.18)
) )
) )
.foregroundColor(digitColor) .foregroundStyle(digitColor)
.opacity(clockOpacity) .opacity(clockOpacity)
.padding(.horizontal, max(6, fontSize * 0.05)) .padding(.horizontal, max(6, fontSize * 0.05))
.padding(.vertical, max(3, fontSize * 0.03)) .padding(.vertical, max(3, fontSize * 0.03))

View File

@ -17,7 +17,7 @@ struct SoundCategoryView: View {
@Binding var selectedSound: Sound? @Binding var selectedSound: Sound?
@State private var selectedCategory: SoundCategory = .all @State private var selectedCategory: SoundCategory = .all
@Binding var searchText: String @Binding var searchText: String
@State private var viewModel = SoundViewModel() @State private var previewViewModel = SoundViewModel()
// MARK: - Computed Properties // MARK: - Computed Properties
private var filteredSounds: [Sound] { private var filteredSounds: [Sound] {
@ -103,13 +103,13 @@ struct SoundCategoryView: View {
SoundCard( SoundCard(
sound: sound, sound: sound,
isSelected: selectedSound?.id == sound.id, isSelected: selectedSound?.id == sound.id,
isPreviewing: viewModel.isPreviewing && viewModel.previewSound?.id == sound.id, isPreviewing: previewViewModel.isPreviewing && previewViewModel.previewSound?.id == sound.id,
onSelect: { onSelect: {
viewModel.selectSound(sound) previewViewModel.selectSound(sound)
selectedSound = sound selectedSound = sound
}, },
onPreview: { onPreview: {
viewModel.previewSound(sound) previewViewModel.previewSound(sound)
} }
) )
} }
@ -144,8 +144,8 @@ struct CategoryTab: View {
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small) .padding(.vertical, Design.Spacing.small)
.background(isSelected ? AppAccent.primary : AppSurface.card) .background(isSelected ? AppAccent.primary : AppSurface.card)
.foregroundColor(isSelected ? .white : AppTextColors.primary) .foregroundStyle(isSelected ? .white : AppTextColors.primary)
.cornerRadius(Design.CornerRadius.medium) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: 1) .stroke(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: 1)
@ -173,7 +173,7 @@ struct SoundCard: View {
ZStack { ZStack {
Image(systemName: soundIcon) Image(systemName: soundIcon)
.font(.title3) .font(.title3)
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary) .foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.primary)
if isPreviewing { if isPreviewing {
Circle() Circle()
@ -189,7 +189,7 @@ struct SoundCard: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(sound.name) Text(sound.name)
.styled(.subheadingEmphasis) .styled(.subheadingEmphasis)
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary) .foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.primary)
.lineLimit(1) .lineLimit(1)
Text(sound.description) Text(sound.description)

View File

@ -27,11 +27,11 @@ struct SoundControlView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(sound.name) Text(sound.name)
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Text(sound.description) Text(sound.description)
.font(.caption) .font(.caption)
.foregroundColor(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
.lineLimit(2) .lineLimit(2)
} }
@ -40,7 +40,7 @@ struct SoundControlView: View {
// Category badge // Category badge
Text(sound.category.capitalized) Text(sound.category.capitalized)
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
.foregroundColor(.white) .foregroundStyle(.white)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(AppAccent.primary, in: Capsule()) .background(AppAccent.primary, in: Capsule())
@ -60,11 +60,11 @@ struct SoundControlView: View {
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
Image(systemName: isPlaying ? "stop.fill" : "play.fill") Image(systemName: isPlaying ? "stop.fill" : "play.fill")
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.foregroundColor(.white) .foregroundStyle(.white)
Text(isPlaying ? "Stop Sound" : "Play Sound") Text(isPlaying ? "Stop Sound" : "Play Sound")
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
.foregroundColor(.white) .foregroundStyle(.white)
} }
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.medium) .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.medium)
.background( .background(

View File

@ -42,22 +42,22 @@ struct NoiseView: View {
HStack { HStack {
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundColor(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
TextField("Search sounds", text: $searchText) TextField("Search sounds", text: $searchText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
if !searchText.isEmpty { if !searchText.isEmpty {
Button(action: { searchText = "" }) { Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundColor(AppTextColors.tertiary) .foregroundStyle(AppTextColors.tertiary)
} }
} }
} }
.padding(Design.Spacing.small) .padding(Design.Spacing.small)
.background(AppSurface.overlay) .background(AppSurface.overlay)
.cornerRadius(Design.CornerRadius.medium) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.medium) .padding(.top, Design.Spacing.medium)
@ -118,18 +118,18 @@ struct NoiseView: View {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.title) .font(.title)
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.symbolEffect(.variableColor.iterative, options: .repeating) .symbolEffect(.variableColor.iterative, options: .repeating)
} }
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Ready for Sleep?") Text("Ready for Sleep?")
.typography(.title3Bold) .typography(.title3Bold)
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Text("Select a soothing sound below to begin your relaxation journey.") Text("Select a soothing sound below to begin your relaxation journey.")
.typography(.caption) .typography(.caption)
.foregroundColor(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
} }
@ -184,18 +184,18 @@ struct NoiseView: View {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.title) .font(.title)
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.symbolEffect(.variableColor.iterative, options: .repeating) .symbolEffect(.variableColor.iterative, options: .repeating)
} }
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Ready for Sleep?") Text("Ready for Sleep?")
.typography(.title3Bold) .typography(.title3Bold)
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Text("Select a soothing sound to begin.") Text("Select a soothing sound to begin.")
.typography(.caption) .typography(.caption)
.foregroundColor(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
} }

View File

@ -0,0 +1,78 @@
//
// OnboardingBottomControls.swift
// TheNoiseClock
//
// Bottom navigation controls for onboarding flow.
//
import SwiftUI
import Bedrock
/// Bottom controls with page indicators and navigation buttons
struct OnboardingBottomControls: View {
@Binding var currentPage: Int
let totalPages: Int
let onSkip: () -> Void
let onFinish: () -> Void
var body: some View {
VStack(spacing: Design.Spacing.large) {
// Page indicators
HStack(spacing: Design.Spacing.small) {
ForEach(0..<totalPages, id: \.self) { index in
Capsule()
.fill(index == currentPage ? AppAccent.primary : AppTextColors.tertiary)
.frame(width: index == currentPage ? 24 : 8, height: 8)
.animation(.easeInOut(duration: 0.2), value: currentPage)
}
}
// Navigation buttons
HStack(spacing: Design.Spacing.large) {
Button {
if currentPage > 0 {
withAnimation { currentPage -= 1 }
} else {
onSkip()
}
} label: {
Text(currentPage == 0 ? "Skip" : "Back")
.typography(.bodyEmphasis)
.foregroundStyle(AppTextColors.secondary)
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
}
Button {
if currentPage < totalPages - 1 {
withAnimation { currentPage += 1 }
} else {
onFinish()
}
} label: {
Text(currentPage == totalPages - 1 ? "Get Started" : "Next")
.typography(.bodyEmphasis)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
}
}
}
}
// MARK: - Preview
#Preview {
OnboardingBottomControls(
currentPage: .constant(0),
totalPages: 4,
onSkip: {},
onFinish: {}
)
.padding()
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,44 @@
//
// OnboardingFeatureRow.swift
// TheNoiseClock
//
// Reusable feature highlight row for onboarding pages.
//
import SwiftUI
import Bedrock
/// Reusable row displaying an icon and text for onboarding feature highlights
struct OnboardingFeatureRow: View {
let icon: String
let text: String
var iconSize: CGFloat = 20
var iconWidth: CGFloat = 28
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: iconSize))
.foregroundStyle(AppAccent.primary)
.frame(width: iconWidth)
Text(text)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
}
.frame(maxWidth: 320, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Design.Spacing.xxLarge)
}
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
OnboardingFeatureRow(icon: "moon.stars.fill", text: "Fall asleep to soothing sounds")
OnboardingFeatureRow(icon: "alarm.fill", text: "Wake up gently, on your terms")
OnboardingFeatureRow(icon: "clock.fill", text: "Automatic full-screen display")
}
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,57 @@
//
// OnboardingGetStartedPage.swift
// TheNoiseClock
//
// Final "Get Started" page for onboarding.
//
import SwiftUI
import Bedrock
/// Page 4: Get Started with quick tips
struct OnboardingGetStartedPage: View {
var body: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
ZStack {
Circle()
.fill(AppStatus.success.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60, weight: .medium))
.foregroundStyle(AppStatus.success)
}
Text("You're ready!")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
Text("Your alarms will work even in silent mode and Focus mode. The interface will automatically fade out to give you a clean view of the time!")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
VStack(alignment: .leading, spacing: Design.Spacing.small) {
OnboardingFeatureRow(icon: "alarm.fill", text: "Create your first alarm")
OnboardingFeatureRow(icon: "clock.fill", text: "Wait 5s for full screen")
OnboardingFeatureRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
}
.padding(.top, Design.Spacing.medium)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Preview
#Preview {
OnboardingGetStartedPage()
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,179 @@
//
// OnboardingPermissionsPage.swift
// TheNoiseClock
//
// AlarmKit permissions page for onboarding.
//
import SwiftUI
import Bedrock
/// Page 3: AlarmKit permission request
struct OnboardingPermissionsPage: View {
@Binding var alarmKitPermissionGranted: Bool
@Binding var keepAwakeEnabled: Bool
let onAdvanceToFinal: () -> Void
var body: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
ZStack {
Circle()
.fill(AppAccent.primary.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "alarm.waves.left.and.right.fill")
.font(.system(size: 50, weight: .medium))
.foregroundStyle(AppAccent.primary)
.symbolEffect(.pulse, options: .repeating)
}
Text("Alarms that actually work")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
VStack(alignment: .leading, spacing: Design.Spacing.small) {
OnboardingFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
OnboardingFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
OnboardingFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
}
.padding(.top, Design.Spacing.medium)
permissionButton
.padding(.top, Design.Spacing.large)
keepAwakeSection
.padding(.top, Design.Spacing.medium)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
keepAwakeEnabled = isKeepAwakeEnabled()
}
}
// MARK: - Permission Button
@ViewBuilder
private var permissionButton: some View {
if alarmKitPermissionGranted {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
Text("Alarms enabled!")
}
.foregroundStyle(AppStatus.success)
.typography(.bodyEmphasis)
.padding(Design.Spacing.medium)
.background(AppStatus.success.opacity(0.15))
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} else {
Button {
requestAlarmKitPermission()
} label: {
HStack {
Image(systemName: "alarm.fill")
Text("Enable Alarms")
}
.typography(.bodyEmphasis)
.foregroundStyle(.white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
}
}
// MARK: - Keep Awake Section
private var keepAwakeSection: some View {
VStack(spacing: Design.Spacing.small) {
Text("Want the clock always visible?")
.typography(.callout)
.foregroundStyle(AppTextColors.tertiary)
.multilineTextAlignment(.center)
Button {
enableKeepAwake()
} label: {
HStack(spacing: Design.Spacing.small) {
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
}
.typography(.callout)
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.disabled(keepAwakeEnabled)
}
}
// MARK: - Actions
private func requestAlarmKitPermission() {
Task {
let granted = await AlarmKitService.shared.requestAuthorization()
withAnimation(.spring(duration: 0.3)) {
alarmKitPermissionGranted = granted
}
if granted {
try? await Task.sleep(for: .milliseconds(800))
onAdvanceToFinal()
}
}
}
private func enableKeepAwake() {
let style = loadClockStyle()
style.keepAwake = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
withAnimation(.spring(duration: 0.3)) {
keepAwakeEnabled = true
}
}
private func isKeepAwakeEnabled() -> Bool {
loadClockStyle().keepAwake
}
private func loadClockStyle() -> ClockStyle {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle()
}
return decoded
}
private func saveClockStyle(_ style: ClockStyle) {
if let data = try? JSONEncoder().encode(style) {
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
}
}
}
// MARK: - Preview
#Preview {
OnboardingPermissionsPage(
alarmKitPermissionGranted: .constant(false),
keepAwakeEnabled: .constant(false),
onAdvanceToFinal: {}
)
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,84 @@
//
// OnboardingWelcomePage.swift
// TheNoiseClock
//
// Welcome page with live clock preview for onboarding.
//
import SwiftUI
import Bedrock
/// Page 1: Welcome with live clock preview
struct OnboardingWelcomePage: View {
var body: some View {
VStack(spacing: Design.Spacing.large) {
Spacer()
TimelineView(.periodic(from: .now, by: 1.0)) { context in
OnboardingClockText(date: context.date)
}
.padding(.bottom, Design.Spacing.medium)
Text("The Noise Clock")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
Text("Your beautiful bedside companion")
.typography(.title3)
.foregroundStyle(AppTextColors.secondary)
VStack(spacing: Design.Spacing.medium) {
OnboardingFeatureRow(
icon: "moon.stars.fill",
text: "Fall asleep to soothing sounds"
)
OnboardingFeatureRow(
icon: "alarm.fill",
text: "Wake up gently, on your terms"
)
OnboardingFeatureRow(
icon: "clock.fill",
text: "Automatic full-screen display"
)
}
.padding(.top, Design.Spacing.large)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Onboarding Clock Text
/// Separate view for TimelineView content to avoid view builder issues
struct OnboardingClockText: View {
let date: Date
private static let formatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "h:mm"
return df
}()
private var timeString: String {
Self.formatter.string(from: date)
}
var body: some View {
Text(timeString)
.font(.system(size: 80, weight: .bold, design: .rounded))
.foregroundStyle(AppAccent.primary)
.contentTransition(.numericText())
.animation(.snappy(duration: 0.3), value: timeString)
.shadow(color: AppAccent.primary.opacity(0.5), radius: 20)
}
}
// MARK: - Preview
#Preview {
OnboardingWelcomePage()
.preferredColorScheme(.dark)
}

View File

@ -31,423 +31,54 @@ struct OnboardingView: View {
var body: some View { var body: some View {
ZStack { ZStack {
// Background
AppSurface.primary AppSurface.primary
.ignoresSafeArea() .ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
// Page content
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
welcomeWithClockPage OnboardingWelcomePage()
.tag(0) .tag(0)
whiteNoisePage OnboardingPageView(
.tag(1) icon: "waveform",
iconColor: AppAccent.primary,
title: "Soothing Sounds",
description: "Choose from a variety of white noise, rain, and ambient sounds to help you drift off to sleep."
)
.tag(1)
permissionsPage OnboardingPermissionsPage(
.tag(2) alarmKitPermissionGranted: $alarmKitPermissionGranted,
keepAwakeEnabled: $keepAwakeEnabled,
onAdvanceToFinal: {
withAnimation { currentPage = 3 }
}
)
.tag(2)
getStartedPage OnboardingGetStartedPage()
.tag(3) .tag(3)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: currentPage) .animation(.easeInOut(duration: 0.3), value: currentPage)
// Bottom controls OnboardingBottomControls(
bottomControls currentPage: $currentPage,
.padding(.horizontal, Design.Spacing.xLarge) totalPages: totalPages,
.padding(.bottom, Design.Spacing.xxLarge) onSkip: onComplete,
onFinish: {
withAnimation(.spring(duration: 0.45, bounce: 0.2)) {
onComplete()
}
}
)
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.bottom, Design.Spacing.xxLarge)
} }
.frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
} }
// MARK: - Page 1: Welcome with Live Clock Preview
private var welcomeWithClockPage: some View {
VStack(spacing: Design.Spacing.large) {
Spacer()
// Live clock preview - immediate value using TimelineView
TimelineView(.periodic(from: .now, by: 1.0)) { context in
OnboardingClockText(date: context.date)
}
.padding(.bottom, Design.Spacing.medium)
Text("The Noise Clock")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
Text("Your beautiful bedside companion")
.typography(.title3)
.foregroundStyle(AppTextColors.secondary)
// Quick feature highlights - benefit focused
VStack(spacing: Design.Spacing.medium) {
featureHighlight(
icon: "moon.stars.fill",
text: "Fall asleep to soothing sounds"
)
featureHighlight(
icon: "alarm.fill",
text: "Wake up gently, on your terms"
)
featureHighlight(
icon: "clock.fill",
text: "Automatic full-screen display"
)
}
.padding(.top, Design.Spacing.large)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func featureHighlight(icon: String, text: String) -> some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundStyle(AppAccent.primary)
.frame(width: 28)
Text(text)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
}
.frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading
.frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent
.padding(.horizontal, Design.Spacing.xxLarge)
}
// MARK: - Page 2: White Noise
private var whiteNoisePage: some View {
OnboardingPageView(
icon: "waveform",
iconColor: AppAccent.primary,
title: "Soothing Sounds",
description: "Choose from a variety of white noise, rain, and ambient sounds to help you drift off to sleep."
)
}
// MARK: - Page 3: AlarmKit Permissions
private var permissionsPage: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Alarm icon with animated waves
ZStack {
Circle()
.fill(AppAccent.primary.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "alarm.waves.left.and.right.fill")
.font(.system(size: 50, weight: .medium))
.foregroundStyle(AppAccent.primary)
.symbolEffect(.pulse, options: .repeating)
}
Text("Alarms that actually work")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
// Feature bullets
VStack(alignment: .leading, spacing: Design.Spacing.small) {
alarmFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
alarmFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
alarmFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
}
.padding(.top, Design.Spacing.medium)
// Permission button or success state
permissionButton
.padding(.top, Design.Spacing.large)
// Optional: Keep Awake for bedside clock mode
keepAwakeSection
.padding(.top, Design.Spacing.medium)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
keepAwakeEnabled = isKeepAwakeEnabled()
}
}
private var keepAwakeSection: some View {
VStack(spacing: Design.Spacing.small) {
Text("Want the clock always visible?")
.typography(.callout)
.foregroundStyle(AppTextColors.tertiary)
.multilineTextAlignment(.center)
Button {
enableKeepAwake()
} label: {
HStack(spacing: Design.Spacing.small) {
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
}
.typography(.callout)
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
.cornerRadius(Design.CornerRadius.medium)
}
.disabled(keepAwakeEnabled)
}
}
private func alarmFeatureRow(icon: String, text: String) -> some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 18))
.foregroundStyle(AppAccent.primary)
.frame(width: 28)
Text(text)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
}
.frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading
.frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent
.padding(.horizontal, Design.Spacing.xxLarge)
}
private var permissionButton: some View {
Group {
if alarmKitPermissionGranted {
// Success state
HStack(spacing: Design.Spacing.small) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
Text("Alarms enabled!")
}
.foregroundStyle(AppStatus.success)
.typography(.bodyEmphasis)
.padding(Design.Spacing.medium)
.background(AppStatus.success.opacity(0.15))
.cornerRadius(Design.CornerRadius.medium)
} else {
// Request AlarmKit authorization
Button {
requestAlarmKitPermission()
} label: {
HStack {
Image(systemName: "alarm.fill")
Text("Enable Alarms")
}
.typography(.bodyEmphasis)
.foregroundStyle(.white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
}
}
}
// MARK: - Page 4: Get Started (Quick Win)
private var getStartedPage: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Celebration icon
ZStack {
Circle()
.fill(AppStatus.success.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60, weight: .medium))
.foregroundStyle(AppStatus.success)
}
Text("You're ready!")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
Text("Your alarms will work even in silent mode and Focus mode. The interface will automatically fade out to give you a clean view of the time!")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
// Quick tips
VStack(alignment: .leading, spacing: Design.Spacing.small) {
tipRow(icon: "alarm.fill", text: "Create your first alarm")
tipRow(icon: "clock.fill", text: "Wait 5s for full screen")
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
}
.padding(.top, Design.Spacing.medium)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func tipRow(icon: String, text: String) -> some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text(text)
.typography(.callout)
.foregroundStyle(AppTextColors.secondary)
}
.frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading
.frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent
.padding(.horizontal, Design.Spacing.xxLarge)
}
// MARK: - Bottom Controls
private var bottomControls: some View {
VStack(spacing: Design.Spacing.large) {
// Page indicators
HStack(spacing: Design.Spacing.small) {
ForEach(0..<totalPages, id: \.self) { index in
Capsule()
.fill(index == currentPage ? AppAccent.primary : AppTextColors.tertiary)
.frame(width: index == currentPage ? 24 : 8, height: 8)
.animation(.easeInOut(duration: 0.2), value: currentPage)
}
}
// Navigation buttons
HStack(spacing: Design.Spacing.large) {
// Back / Skip button
Button {
if currentPage > 0 {
withAnimation {
currentPage -= 1
}
} else {
onComplete()
}
} label: {
Text(currentPage == 0 ? "Skip" : "Back")
.typography(.bodyEmphasis)
.foregroundStyle(AppTextColors.secondary)
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
}
// Next / Get Started button
Button {
if currentPage < totalPages - 1 {
withAnimation {
currentPage += 1
}
} else {
triggerCelebration()
}
} label: {
Text(currentPage == totalPages - 1 ? "Get Started" : "Next")
.typography(.bodyEmphasis)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
.background(AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
}
}
}
// MARK: - Actions
private func requestAlarmKitPermission() {
Task {
// Request AlarmKit authorization (iOS 26+)
let granted = await AlarmKitService.shared.requestAuthorization()
withAnimation(.spring(duration: 0.3)) {
alarmKitPermissionGranted = granted
}
// Auto-advance after permission granted
if granted {
try? await Task.sleep(for: .milliseconds(800))
withAnimation {
currentPage = 3
}
}
}
}
private func enableKeepAwake() {
var style = loadClockStyle()
style.keepAwake = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
withAnimation(.spring(duration: 0.3)) {
keepAwakeEnabled = true
}
}
private func isKeepAwakeEnabled() -> Bool {
loadClockStyle().keepAwake
}
private func loadClockStyle() -> ClockStyle {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle()
}
return decoded
}
private func saveClockStyle(_ style: ClockStyle) {
if let data = try? JSONEncoder().encode(style) {
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
}
}
private func triggerCelebration() {
// Snappier transition for a better feel
withAnimation(.spring(duration: 0.45, bounce: 0.2)) {
onComplete()
}
}
}
// MARK: - Onboarding Clock Text
/// Separate view for TimelineView content to avoid view builder issues
private struct OnboardingClockText: View {
let date: Date
private var timeString: String {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm"
return formatter.string(from: date)
}
var body: some View {
Text(timeString)
.font(.system(size: 80, weight: .bold, design: .rounded))
.foregroundStyle(AppAccent.primary)
.contentTransition(.numericText())
.animation(.snappy(duration: 0.3), value: timeString)
.shadow(color: AppAccent.primary.opacity(0.5), radius: 20)
}
} }
// MARK: - Preview // MARK: - Preview

View File

@ -39,11 +39,11 @@ struct SettingsSelectionView<T: Hashable>: View {
HStack { HStack {
Text(toString(option)) Text(toString(option))
.typography(.body) .typography(.body)
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
Spacer() Spacer()
if selection == option { if selection == option {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundColor(AppAccent.primary) .foregroundStyle(AppAccent.primary)
.font(.body.bold()) .font(.body.bold())
} }
} }

View File

@ -199,170 +199,3 @@ struct FontUtils {
return baseName + (weightSuffix.isEmpty ? "" : "-" + weightSuffix) return baseName + (weightSuffix.isEmpty ? "" : "-" + weightSuffix)
} }
} }
private struct TestContentView: View {
@State private var digit: String = "138"
@State private var previewFontSize: CGFloat = 1000
@State private var fontWeight: Font.Weight = .bold
@State private var fontDesign: Font.Design = .rounded
private var date: Date {
let hours = ["13"].randomElement() ?? "08"
let minutes = ["38"].randomElement() ?? "33"
let timeString = "\(hours):\(minutes)"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let todayString = formatter.string(from: Date())
let fullDateString = "\(todayString) \(timeString)"
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.date(from: fullDateString) ?? Date()
}
var body: some View {
let newDate = date
return ScrollView {
VStack(spacing: 20) {
ForEach(FontFamily.allCases, id: \.rawValue) { (font: FontFamily) in
VStack {
Text(font.rawValue)
.font(Font.system(size: 16, weight: Font.Weight.bold))
.padding(.bottom, 5)
TimeDisplayView(date: newDate,
use24Hour: true,
showSeconds: false,
showAmPm: true,
digitColor: .primary,
glowIntensity: 0.5,
clockOpacity: 1.0,
fontFamily: font,
fontWeight: fontWeight,
fontDesign: fontDesign,
forceHorizontalMode: true,
isDisplayMode: false,
animationStyle: .spring)
TimeSegment(
text: digit,
fontSize: .constant(129), // CGFloat
opacity: 1.0, // Double
digitColor: Color.primary, // Color
glowIntensity: 0.5, // Double
fontFamily: font, // FontFamily
fontWeight: fontWeight,
fontDesign: fontDesign,
isDisplayMode: false,
animationStyle: .spring
)
}
.frame(width: 400, height: 200)
.border(Color.black)
}
}
.padding()
}
}
}
struct FontCombinationsPreview: View {
// All designs
private let designs: [Font.Design] = Font.Design.allCases
private let columns: [GridItem] = [
GridItem(.flexible(), spacing: 20),
GridItem(.flexible(), spacing: 20),
GridItem(.flexible(), spacing: 20)
]
var body: some View {
// Precompute local copies to avoid type-checker stress
let fonts: [FontFamily] = FontFamily.allCases.sorted {
$0.rawValue.localizedCaseInsensitiveCompare($1.rawValue)
== .orderedAscending
}
let designsLocal: [Font.Design] = designs
return ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(fonts, id: \.self) { font in
ForEach(font.fontWeights, id: \.self) { weight in
ForEach(designsLocal, id: \.self) { design in
FontSampleCell(font: font, weight: weight, design: design)
}
}
}
}
.padding()
}
}
}
private struct FontSampleCell: View {
let font: FontFamily
let weight: Font.Weight
let design: Font.Design
var body: some View {
VStack(alignment: .center, spacing: 5) {
Text("\(font.rawValue), \(weight.rawValue), \(design.rawValue)")
.font(.system(size: 12, weight: .medium))
.multilineTextAlignment(.center)
.padding(.bottom, 5)
TimeSegment(
text: "38",
fontSize: .constant(129), // CGFloat
opacity: 1.0, // Double
digitColor: Color.primary, // Color
glowIntensity: 0.5, // Double
fontFamily: font, // FontFamily
fontWeight: weight,
fontDesign: design,
isDisplayMode: false,
animationStyle: .spring)
.frame(width: 100, height: 100)
.border(Color.black)
}
}
}
#Preview {
FontCombinationsPreview()
}
// MARK: - Descriptions for Font.Weight and Font.Design
private extension Font.Weight {
var description: String {
switch self {
case .ultraLight: return "ultraLight"
case .thin: return "thin"
case .light: return "light"
case .regular: return "regular"
case .medium: return "medium"
case .semibold: return "semibold"
case .bold: return "bold"
case .heavy: return "heavy"
case .black: return "black"
default:
// Fallback for any future/unknown cases
return "custom"
}
}
}
private extension Font.Design {
var description: String {
switch self {
case .default: return "default"
case .serif: return "serif"
case .rounded: return "rounded"
case .monospaced: return "monospaced"
@unknown default:
return "unknown"
}
}
}

View File

@ -30,8 +30,8 @@ extension View {
self self
.padding(Design.Spacing.medium) .padding(Design.Spacing.medium)
.background(isEnabled ? color : color.opacity(Design.Opacity.light)) .background(isEnabled ? color : color.opacity(Design.Opacity.light))
.foregroundColor(.white) .foregroundStyle(.white)
.cornerRadius(Design.CornerRadius.small) .clipShape(.rect(cornerRadius: Design.CornerRadius.small))
.disabled(!isEnabled) .disabled(!isEnabled)
} }
@ -68,7 +68,7 @@ extension View {
func sectionTitleStyle() -> some View { func sectionTitleStyle() -> some View {
self self
.font(.title2.weight(.bold)) .font(.title2.weight(.bold))
.foregroundColor(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
} }
/// Center content horizontally with spacers /// Center content horizontally with spacers