refactored and moved to ios26 base
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7c16d1e646
commit
7252500c9a
@ -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
26
PRD.md
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,33 +48,28 @@ struct ContentView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
// Main tab content
|
// Main tab content
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
|
Tab("Clock", systemImage: "clock", value: AppTab.clock) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
// Pass isOnClockTab so ClockView can make the right tab bar decision
|
// Pass isOnClockTab so ClockView can make the right tab bar decision
|
||||||
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
|
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
|
||||||
// This prevents race conditions on tab switch
|
// This prevents race conditions on tab switch
|
||||||
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
||||||
}
|
}
|
||||||
.tabItem {
|
|
||||||
Label("Clock", systemImage: "clock")
|
|
||||||
}
|
}
|
||||||
.tag(Tab.clock)
|
|
||||||
|
|
||||||
|
Tab("Alarms", systemImage: "alarm", value: AppTab.alarms) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
AlarmView(viewModel: alarmViewModel)
|
AlarmView(viewModel: alarmViewModel)
|
||||||
}
|
}
|
||||||
.tabItem {
|
|
||||||
Label("Alarms", systemImage: "alarm")
|
|
||||||
}
|
}
|
||||||
.tag(Tab.alarms)
|
|
||||||
|
|
||||||
|
Tab("Noise", systemImage: "waveform", value: AppTab.noise) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
NoiseView()
|
NoiseView()
|
||||||
}
|
}
|
||||||
.tabItem {
|
|
||||||
Label("Noise", systemImage: "waveform")
|
|
||||||
}
|
}
|
||||||
.tag(Tab.noise)
|
|
||||||
|
|
||||||
|
Tab("Settings", systemImage: "gearshape", value: AppTab.settings) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClockSettingsView(
|
ClockSettingsView(
|
||||||
style: clockViewModel.style,
|
style: clockViewModel.style,
|
||||||
@ -86,10 +81,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.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.
|
||||||
|
|||||||
@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -31,422 +31,53 @@ 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
|
|
||||||
.tag(1)
|
|
||||||
|
|
||||||
permissionsPage
|
|
||||||
.tag(2)
|
|
||||||
|
|
||||||
getStartedPage
|
|
||||||
.tag(3)
|
|
||||||
}
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
||||||
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
|
||||||
|
|
||||||
// Bottom controls
|
|
||||||
bottomControls
|
|
||||||
.padding(.horizontal, Design.Spacing.xLarge)
|
|
||||||
.padding(.bottom, Design.Spacing.xxLarge)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
|
||||||
.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(
|
OnboardingPageView(
|
||||||
icon: "waveform",
|
icon: "waveform",
|
||||||
iconColor: AppAccent.primary,
|
iconColor: AppAccent.primary,
|
||||||
title: "Soothing Sounds",
|
title: "Soothing Sounds",
|
||||||
description: "Choose from a variety of white noise, rain, and ambient sounds to help you drift off to sleep."
|
description: "Choose from a variety of white noise, rain, and ambient sounds to help you drift off to sleep."
|
||||||
)
|
)
|
||||||
}
|
.tag(1)
|
||||||
|
|
||||||
// MARK: - Page 3: AlarmKit Permissions
|
OnboardingPermissionsPage(
|
||||||
|
alarmKitPermissionGranted: $alarmKitPermissionGranted,
|
||||||
|
keepAwakeEnabled: $keepAwakeEnabled,
|
||||||
|
onAdvanceToFinal: {
|
||||||
|
withAnimation { currentPage = 3 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
private var permissionsPage: some View {
|
OnboardingGetStartedPage()
|
||||||
VStack(spacing: Design.Spacing.xxLarge) {
|
.tag(3)
|
||||||
Spacer()
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
||||||
|
|
||||||
// Alarm icon with animated waves
|
OnboardingBottomControls(
|
||||||
ZStack {
|
currentPage: $currentPage,
|
||||||
Circle()
|
totalPages: totalPages,
|
||||||
.fill(AppAccent.primary.opacity(0.15))
|
onSkip: onComplete,
|
||||||
.frame(width: 120, height: 120)
|
onFinish: {
|
||||||
|
|
||||||
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)) {
|
withAnimation(.spring(duration: 0.45, bounce: 0.2)) {
|
||||||
onComplete()
|
onComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
// MARK: - Onboarding Clock Text
|
.padding(.bottom, Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
/// Separate view for TimelineView content to avoid view builder issues
|
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
private struct OnboardingClockText: View {
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user