refactored
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5eea7ed1d8
commit
204aabf8d2
260
PRD.md
260
PRD.md
@ -44,35 +44,80 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
- **Simple controls**: Play/Stop button with visual feedback
|
- **Simple controls**: Play/Stop button with visual feedback
|
||||||
- **Sound selection**: Dropdown picker for sound selection
|
- **Sound selection**: Dropdown picker for sound selection
|
||||||
|
|
||||||
### 6. Alarm System
|
### 6. Advanced Alarm System
|
||||||
- **Multiple alarms**: Create and manage multiple alarms
|
- **Multiple alarms**: Create and manage unlimited alarms
|
||||||
- **Time selection**: Wheel-style date picker for alarm time
|
- **Rich alarm editor**: Full-featured alarm creation and editing interface
|
||||||
- **Sound selection**: Choose from system sounds (default, bell, chimes, ding, glass, silence)
|
- **Time selection**: Wheel-style date picker for precise alarm time
|
||||||
- **Enable/disable toggles**: Individual alarm control
|
- **Custom labels**: User-defined alarm names and descriptions
|
||||||
- **Notification integration**: Uses iOS UserNotifications framework
|
- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily
|
||||||
- **Persistent storage**: Alarms saved to UserDefaults
|
- **Sound selection**: Choose from extensive system sounds with live preview
|
||||||
- **Alarm management**: Add, delete, and modify alarms
|
- **Volume control**: Adjustable alarm volume (0-100%)
|
||||||
|
- **Vibration settings**: Enable/disable vibration for each alarm
|
||||||
|
- **Snooze functionality**: Configurable snooze duration (5, 7, 8, 9, 10, 15, 20 minutes)
|
||||||
|
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
|
||||||
|
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
||||||
|
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
|
||||||
|
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
||||||
|
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
||||||
|
- **Next trigger preview**: Shows when the next alarm will fire
|
||||||
|
|
||||||
## Technical Architecture
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Code Organization Principles
|
||||||
|
|
||||||
|
**TOP PRIORITY:** The codebase must be built with the following architectural principles from the beginning:
|
||||||
|
|
||||||
|
- **True Separation of Concerns:**
|
||||||
|
- Many small files with focused responsibilities
|
||||||
|
- Each module/class should have a single, well-defined purpose
|
||||||
|
- Avoid monolithic files with mixed responsibilities
|
||||||
|
|
||||||
|
- **Constants and Enums:**
|
||||||
|
- Create constants, enums, and configuration objects to avoid duplicate code or values
|
||||||
|
- Centralize magic numbers, strings, and configuration values
|
||||||
|
- Use enums for type safety and clarity
|
||||||
|
|
||||||
|
- **Readability and Maintainability:**
|
||||||
|
- Code should be self-documenting with clear naming conventions
|
||||||
|
- Easy to understand, extend, and refactor
|
||||||
|
- Consistent patterns throughout the codebase
|
||||||
|
|
||||||
|
- **Extensibility:**
|
||||||
|
- Design for future growth and feature additions
|
||||||
|
- Modular architecture that allows easy integration of new components
|
||||||
|
- Clear interfaces between modules
|
||||||
|
|
||||||
|
- **Refactorability:**
|
||||||
|
- Code structure should make future refactoring straightforward
|
||||||
|
- Minimize coupling between components
|
||||||
|
- Use dependency injection and abstraction where appropriate
|
||||||
|
|
||||||
|
These principles are fundamental to the project's long-term success and must be applied consistently throughout development.
|
||||||
|
|
||||||
### App Structure
|
### App Structure
|
||||||
- **Main App**: `TheNoiseClockApp.swift` - Entry point with WindowGroup
|
- **Main App**: `TheNoiseClockApp.swift` - Entry point with WindowGroup
|
||||||
- **Tab-based navigation**: Three main tabs (Clock, Alarms, Noise)
|
- **Tab-based navigation**: Three main tabs (Clock, Alarms, Noise)
|
||||||
- **SwiftUI framework**: Modern declarative UI framework
|
- **SwiftUI framework**: Modern declarative UI framework with iOS 18+ and iOS 26 features
|
||||||
- **Dark theme**: Preferred color scheme set to dark
|
- **Dark theme**: Preferred color scheme set to dark
|
||||||
|
|
||||||
### Data Models
|
### Data Models
|
||||||
- **ClockStyle**: Codable struct for clock customization settings
|
- **ClockStyle**: @Observable class for clock customization settings
|
||||||
- Time format preferences (24-hour, seconds, AM/PM)
|
- Time format preferences (24-hour, seconds, AM/PM)
|
||||||
- Visual settings (colors, glow, scale, opacity)
|
- Visual settings (colors, glow, scale, opacity)
|
||||||
- Overlay settings (battery, date, opacity)
|
- Overlay settings (battery, date, opacity)
|
||||||
- Background settings
|
- Background settings
|
||||||
- **Alarm**: Codable struct for alarm data
|
- Color caching for performance optimization
|
||||||
|
- **Alarm**: Codable struct for comprehensive alarm data
|
||||||
- UUID identifier
|
- UUID identifier
|
||||||
- Time and enabled state
|
- Time and enabled state
|
||||||
- Sound name
|
- Custom label and description
|
||||||
|
- Repeat schedule (weekdays)
|
||||||
|
- Sound name with volume control
|
||||||
|
- Vibration settings
|
||||||
|
- Snooze duration configuration
|
||||||
- **Sound**: Simple struct for noise file management
|
- **Sound**: Simple struct for noise file management
|
||||||
- Display name and file name
|
- Display name and file name
|
||||||
|
- **LegacyAlarm**: Backward compatibility struct for old alarm data
|
||||||
|
|
||||||
### Data Persistence
|
### Data Persistence
|
||||||
- **AppStorage**: ClockStyle settings persisted as JSON
|
- **AppStorage**: ClockStyle settings persisted as JSON
|
||||||
@ -81,27 +126,36 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
|
|
||||||
### Audio System
|
### Audio System
|
||||||
- **AVFoundation**: AVAudioPlayer for noise playback
|
- **AVFoundation**: AVAudioPlayer for noise playback
|
||||||
|
- **@Observable NoisePlayer**: Modern state management with preloading
|
||||||
- **Looping playback**: Infinite loop for ambient sounds
|
- **Looping playback**: Infinite loop for ambient sounds
|
||||||
|
- **Audio session management**: Proper audio session configuration
|
||||||
- **Error handling**: Graceful handling of missing audio files
|
- **Error handling**: Graceful handling of missing audio files
|
||||||
|
- **AlarmTonePlayer**: Dedicated player for alarm sound previews
|
||||||
|
|
||||||
### Notification System
|
### Notification System
|
||||||
- **UserNotifications**: iOS notification framework
|
- **UserNotifications**: iOS notification framework
|
||||||
- **Permission handling**: Automatic permission requests
|
- **Permission handling**: Automatic permission requests
|
||||||
- **Calendar triggers**: Daily alarm scheduling
|
- **Smart scheduling**: One-time and repeating alarm support
|
||||||
- **Sound customization**: System sound selection
|
- **Calendar triggers**: Precise alarm scheduling with weekday support
|
||||||
|
- **Sound customization**: System sound selection with volume control
|
||||||
|
- **Multiple notifications**: Support for repeating alarms with unique identifiers
|
||||||
|
|
||||||
## User Interface Design
|
## User Interface Design
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
- **TabView**: Three-tab interface (Clock, Alarms, Noise)
|
- **TabView**: Three-tab interface (Clock, Alarms, Noise)
|
||||||
- **NavigationStack**: Modern navigation with back button support
|
- **NavigationStack**: Modern navigation with back button support
|
||||||
|
- **Navigation destinations**: Deep linking for alarm editing
|
||||||
- **Toolbar integration**: Settings and add buttons in navigation bars
|
- **Toolbar integration**: Settings and add buttons in navigation bars
|
||||||
|
- **Sheet presentations**: Modal settings and alarm creation
|
||||||
|
|
||||||
### Visual Design
|
### Visual Design
|
||||||
- **Rounded corners**: Modern iOS design language
|
- **Rounded corners**: Modern iOS design language
|
||||||
- **Smooth animations**: 0.28-second easeInOut transitions
|
- **Modern animations**: iOS 18+ smooth and bouncy animations
|
||||||
- **Color consistency**: Blue accent color throughout
|
- **Color consistency**: Blue accent color throughout
|
||||||
- **Accessibility**: Proper labels and hidden decorative elements
|
- **Accessibility**: Proper labels and hidden decorative elements
|
||||||
|
- **Form-based layouts**: Organized sections for settings and alarm editing
|
||||||
|
- **Interactive controls**: Toggles, sliders, color pickers, and date pickers
|
||||||
|
|
||||||
### Settings Interface
|
### Settings Interface
|
||||||
- **Form-based layout**: Organized sections for different setting categories
|
- **Form-based layout**: Organized sections for different setting categories
|
||||||
@ -109,25 +163,84 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
- **Real-time updates**: Changes apply immediately
|
- **Real-time updates**: Changes apply immediately
|
||||||
- **Sheet presentation**: Modal settings with detents
|
- **Sheet presentation**: Modal settings with detents
|
||||||
|
|
||||||
## File Structure
|
## File Structure and Organization
|
||||||
|
|
||||||
|
### Recommended File Organization
|
||||||
|
Following the separation of concerns principle, the codebase should be organized into focused, single-responsibility files:
|
||||||
|
|
||||||
```
|
```
|
||||||
TheNoiseClock/
|
TheNoiseClock/
|
||||||
├── TheNoiseClockApp.swift # App entry point
|
├── App/
|
||||||
├── ContentView.swift # Main tab navigation
|
│ ├── TheNoiseClockApp.swift # App entry point and configuration
|
||||||
├── ClockView.swift # Clock display and settings
|
│ └── ContentView.swift # Main tab navigation coordinator
|
||||||
├── ClockSettingsView.swift # Settings interface
|
├── Core/
|
||||||
├── ClockStyle.swift # Data model and color utilities
|
│ ├── Constants/
|
||||||
├── AlarmView.swift # Alarm management
|
│ │ ├── AppConstants.swift # App-wide constants and configuration
|
||||||
├── AddAlarmView.swift # Alarm creation
|
│ │ ├── UIConstants.swift # UI-specific constants (colors, sizes, etc.)
|
||||||
├── NoiseView.swift # White noise player
|
│ │ └── AudioConstants.swift # Audio-related constants
|
||||||
├── NoisePlayer.swift # Audio playback logic
|
│ ├── Extensions/
|
||||||
├── Sound.swift # Sound data model
|
│ │ ├── Color+Extensions.swift # Color utilities and extensions
|
||||||
└── Resources/ # Audio files
|
│ │ ├── Date+Extensions.swift # Date formatting and utilities
|
||||||
├── white-noise.mp3
|
│ │ └── View+Extensions.swift # Common view modifiers
|
||||||
├── heavy-rain-white-noise.mp3
|
│ └── Utilities/
|
||||||
└── fan-white-noise-heater-303207.mp3
|
│ ├── ColorUtils.swift # Color manipulation utilities
|
||||||
|
│ └── NotificationUtils.swift # Notification helper functions
|
||||||
|
├── Models/
|
||||||
|
│ ├── ClockStyle.swift # Clock customization data model
|
||||||
|
│ ├── Alarm.swift # Alarm data model
|
||||||
|
│ ├── Sound.swift # Sound data model
|
||||||
|
│ └── LegacyAlarm.swift # Backward compatibility model
|
||||||
|
├── ViewModels/
|
||||||
|
│ ├── ClockViewModel.swift # Clock display logic and state
|
||||||
|
│ ├── AlarmViewModel.swift # Alarm management logic
|
||||||
|
│ └── NoiseViewModel.swift # Audio playback state management
|
||||||
|
├── Views/
|
||||||
|
│ ├── Clock/
|
||||||
|
│ │ ├── ClockView.swift # Main clock display
|
||||||
|
│ │ ├── ClockSettingsView.swift # Clock settings interface
|
||||||
|
│ │ └── Components/
|
||||||
|
│ │ ├── TimeDisplayView.swift # Time display component
|
||||||
|
│ │ ├── BatteryOverlayView.swift # Battery display component
|
||||||
|
│ │ └── DateOverlayView.swift # Date display component
|
||||||
|
│ ├── Alarms/
|
||||||
|
│ │ ├── AlarmView.swift # Alarm list and management
|
||||||
|
│ │ ├── AddAlarmView.swift # Alarm creation interface
|
||||||
|
│ │ └── Components/
|
||||||
|
│ │ ├── AlarmRowView.swift # Individual alarm row
|
||||||
|
│ │ ├── TimePickerView.swift # Time selection component
|
||||||
|
│ │ └── SoundPickerView.swift # Sound selection component
|
||||||
|
│ └── Noise/
|
||||||
|
│ ├── NoiseView.swift # White noise player interface
|
||||||
|
│ └── Components/
|
||||||
|
│ └── SoundControlView.swift # Audio controls component
|
||||||
|
├── Services/
|
||||||
|
│ ├── NoisePlayer.swift # Audio playback service
|
||||||
|
│ ├── AlarmService.swift # Alarm management service
|
||||||
|
│ └── NotificationService.swift # Notification handling service
|
||||||
|
└── Resources/
|
||||||
|
├── Audio/
|
||||||
|
│ ├── white-noise.mp3
|
||||||
|
│ ├── heavy-rain-white-noise.mp3
|
||||||
|
│ └── fan-white-noise-heater-303207.mp3
|
||||||
|
└── Assets.xcassets/
|
||||||
|
└── [Asset catalogs]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### File Naming Conventions
|
||||||
|
- **Views**: Use descriptive names ending in `View` (e.g., `ClockView`, `AlarmRowView`)
|
||||||
|
- **ViewModels**: End with `ViewModel` (e.g., `ClockViewModel`, `AlarmViewModel`)
|
||||||
|
- **Services**: End with `Service` (e.g., `AlarmService`, `NotificationService`)
|
||||||
|
- **Models**: Use noun names (e.g., `Alarm`, `Sound`, `ClockStyle`)
|
||||||
|
- **Extensions**: Use `Type+Extensions` format (e.g., `Color+Extensions`)
|
||||||
|
- **Constants**: Use descriptive names ending in `Constants` (e.g., `AppConstants`)
|
||||||
|
|
||||||
|
### Code Organization Best Practices
|
||||||
|
- **Single Responsibility**: Each file should have one clear purpose
|
||||||
|
- **Dependency Injection**: Use protocols and dependency injection for testability
|
||||||
|
- **Protocol-Oriented Design**: Define protocols for services and data sources
|
||||||
|
- **Error Handling**: Centralized error types and handling patterns
|
||||||
|
- **Testing**: Separate test targets with comprehensive coverage
|
||||||
|
|
||||||
## Key User Interactions
|
## Key User Interactions
|
||||||
|
|
||||||
### Clock Tab
|
### Clock Tab
|
||||||
@ -143,10 +256,18 @@ TheNoiseClock/
|
|||||||
4. **Background**: Set background color and use presets
|
4. **Background**: Set background color and use presets
|
||||||
|
|
||||||
### Alarms Tab
|
### Alarms Tab
|
||||||
1. **View alarms**: List of all created alarms
|
1. **View alarms**: List of all created alarms with labels and repeat schedules
|
||||||
2. **Add alarm**: Tap + button to create new alarm
|
2. **Add alarm**: Tap + button to create new alarm with full editor
|
||||||
3. **Toggle alarm**: Use switch to enable/disable
|
3. **Edit alarm**: Tap any alarm to open comprehensive editor
|
||||||
4. **Delete alarm**: Swipe to delete
|
4. **Toggle alarm**: Use switch to enable/disable
|
||||||
|
5. **Delete alarm**: Swipe to delete or use delete button in editor
|
||||||
|
6. **Alarm editor features**:
|
||||||
|
- Time picker with next trigger preview
|
||||||
|
- Custom label editing
|
||||||
|
- Repeat schedule selection
|
||||||
|
- Sound picker with live preview
|
||||||
|
- Volume and vibration controls
|
||||||
|
- Snooze duration settings
|
||||||
|
|
||||||
### Noise Tab
|
### Noise Tab
|
||||||
1. **Select sound**: Choose from dropdown menu
|
1. **Select sound**: Choose from dropdown menu
|
||||||
@ -156,36 +277,79 @@ TheNoiseClock/
|
|||||||
## Technical Requirements
|
## Technical Requirements
|
||||||
|
|
||||||
### iOS Compatibility
|
### iOS Compatibility
|
||||||
- **Minimum iOS version**: iOS 15.0+ (SwiftUI features)
|
- **Minimum iOS version**: iOS 18.0+ (Latest SwiftUI features and performance optimizations)
|
||||||
- **Target devices**: iPhone and iPad
|
- **Target devices**: iPhone and iPad with full adaptive layout support
|
||||||
- **Orientation support**: Portrait and landscape
|
- **Orientation support**: Portrait and landscape with dynamic type support
|
||||||
|
- **Accessibility**: Full VoiceOver and Dynamic Type support
|
||||||
|
|
||||||
|
### Modern iOS Technology Stack
|
||||||
|
- **SwiftUI**: Latest declarative UI framework with iOS 18+ and iOS 26 features
|
||||||
|
- **Observation Framework**: Modern @Observable pattern for state management
|
||||||
|
- **SwiftData**: Advanced data persistence with iOS 18+ SwiftData features
|
||||||
|
- **Async/Await**: Modern concurrency patterns throughout
|
||||||
|
- **Structured Concurrency**: Task groups and actors for complex operations
|
||||||
|
- **Swift 6**: Latest language features and safety improvements
|
||||||
|
- **iOS 26 Features**: Latest platform capabilities where available
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
- **SwiftUI**: Native iOS UI framework
|
- **SwiftUI**: Native iOS UI framework with latest features
|
||||||
- **AVFoundation**: Audio playback
|
- **AVFoundation**: Audio playback with modern async patterns
|
||||||
- **UserNotifications**: Alarm notifications
|
- **UserNotifications**: Alarm notifications with rich content support
|
||||||
- **Combine**: Timer publishers for real-time updates
|
- **Combine**: Timer publishers and reactive programming
|
||||||
|
- **Observation**: Modern state management with @Observable
|
||||||
|
- **Foundation**: Core system frameworks and utilities
|
||||||
|
|
||||||
### Performance Considerations
|
### Performance Considerations
|
||||||
- **Efficient timers**: Separate timers for seconds and minutes
|
- **Smart timer management**: Conditional timers based on settings
|
||||||
|
- **Debounced persistence**: Batched UserDefaults writes
|
||||||
- **Memory management**: Proper cleanup of audio players
|
- **Memory management**: Proper cleanup of audio players
|
||||||
- **Battery optimization**: Efficient update mechanisms
|
- **Battery optimization**: Efficient update mechanisms
|
||||||
|
- **Color caching**: Avoid repeated hex-to-Color conversions
|
||||||
|
- **Dictionary lookups**: O(1) alarm access instead of linear search
|
||||||
- **Smooth animations**: Hardware-accelerated transitions
|
- **Smooth animations**: Hardware-accelerated transitions
|
||||||
|
- **Preloaded audio**: Instant sound playback
|
||||||
|
|
||||||
## Future Enhancement Opportunities
|
## Future Enhancement Opportunities
|
||||||
- **Additional sound types**: More white noise variants
|
- **Additional sound types**: More white noise variants
|
||||||
- **Volume control**: Adjustable playback volume
|
|
||||||
- **Sleep timer**: Auto-stop noise after specified time
|
- **Sleep timer**: Auto-stop noise after specified time
|
||||||
- **Widget support**: Home screen clock widget
|
- **Widget support**: Home screen clock widget
|
||||||
- **Apple Watch companion**: Watch app for quick time check
|
- **Apple Watch companion**: Watch app for quick time check
|
||||||
- **In-app purchases**: Premium sound packs
|
- **In-app purchases**: Premium sound packs
|
||||||
- **Custom sounds**: User-imported audio files
|
- **Custom sounds**: User-imported audio files
|
||||||
- **Snooze functionality**: Enhanced alarm features
|
|
||||||
- **Multiple time zones**: World clock functionality
|
- **Multiple time zones**: World clock functionality
|
||||||
|
- **Alarm categories**: Group alarms by type (work, sleep, etc.)
|
||||||
|
- **Smart alarms**: Gradual volume increase
|
||||||
|
- **Weather integration**: Weather-based alarm sounds
|
||||||
|
- **Health integration**: Sleep tracking integration
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
|
### Project Information
|
||||||
- **Created**: September 7, 2025
|
- **Created**: September 7, 2025
|
||||||
- **Framework**: SwiftUI with iOS 15+ target
|
- **Framework**: SwiftUI with iOS 18.0+ target (latest stable features)
|
||||||
- **Architecture**: MVVM pattern with SwiftUI
|
- **Architecture**: Modern SwiftUI with @Observable pattern and MVVM
|
||||||
- **Testing**: Includes unit and UI test targets
|
- **Testing**: Comprehensive unit and UI test targets with Swift Testing
|
||||||
- **Version control**: Git repository with staged changes
|
- **Version control**: Git repository with feature branch workflow
|
||||||
|
- **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 Development Practices
|
||||||
|
- **Swift 6**: Latest language features including strict concurrency checking
|
||||||
|
- **Async/Await**: Modern concurrency patterns throughout the codebase
|
||||||
|
- **Observation Framework**: @Observable for reactive state management
|
||||||
|
- **SwiftUI Navigation**: Latest NavigationStack and navigation APIs with iOS 18+ features
|
||||||
|
- **Accessibility**: Full VoiceOver and Dynamic Type support with iOS 26 enhancements
|
||||||
|
- **Adaptive Layout**: Support for all device sizes and orientations
|
||||||
|
- **Performance**: Optimized for 120Hz ProMotion displays and iOS 26 performance improvements
|
||||||
|
- **Memory Management**: ARC with proper weak references and cleanup
|
||||||
|
- **Error Handling**: Result types and proper error propagation
|
||||||
|
- **Testing**: Swift Testing framework for modern test writing
|
||||||
|
- **iOS 26 Integration**: Latest platform features and capabilities where applicable
|
||||||
|
|
||||||
|
### Code Quality Standards
|
||||||
|
- **SwiftLint**: Automated code style enforcement
|
||||||
|
- **Documentation**: Comprehensive inline documentation with DocC
|
||||||
|
- **Type Safety**: Leverage Swift's type system for compile-time safety
|
||||||
|
- **Protocol-Oriented**: Use protocols for abstraction and testability
|
||||||
|
- **Dependency Injection**: Constructor injection for better testability
|
||||||
|
- **SOLID Principles**: Single responsibility, open/closed, dependency inversion
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Observation
|
|
||||||
|
|
||||||
struct AddAlarmView: View {
|
|
||||||
@Binding var alarms: [Alarm]
|
|
||||||
let systemSounds: [String]
|
|
||||||
@Binding var newAlarmTime: Date
|
|
||||||
@Binding var selectedSoundName: String
|
|
||||||
@Binding var showAddAlarm: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
DatePicker("Time", selection: $newAlarmTime, displayedComponents: .hourAndMinute)
|
|
||||||
.datePickerStyle(.wheel)
|
|
||||||
Picker("Sound", selection: $selectedSoundName) {
|
|
||||||
ForEach(systemSounds, id: \.self) { sound in
|
|
||||||
Text(sound.capitalized).tag(sound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
HStack {
|
|
||||||
Button("Cancel") {
|
|
||||||
showAddAlarm = false
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
Spacer()
|
|
||||||
Button("Add Alarm") {
|
|
||||||
let newAlarm = Alarm(id: UUID(), time: newAlarmTime, isEnabled: true, soundName: selectedSoundName)
|
|
||||||
alarms.append(newAlarm)
|
|
||||||
// Update notifications handled by AlarmView's onChange
|
|
||||||
showAddAlarm = false
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.navigationTitle("New Alarm")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
@State var alarms: [Alarm] = []
|
|
||||||
@State var newAlarmTime = Date()
|
|
||||||
@State var selectedSoundName = "default"
|
|
||||||
@State var showAddAlarm = true
|
|
||||||
|
|
||||||
return AddAlarmView(
|
|
||||||
alarms: $alarms,
|
|
||||||
systemSounds: ["default", "bell", "chimes"],
|
|
||||||
newAlarmTime: $newAlarmTime,
|
|
||||||
selectedSoundName: $selectedSoundName,
|
|
||||||
showAddAlarm: $showAddAlarm
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import UserNotifications
|
|
||||||
import Observation
|
|
||||||
|
|
||||||
struct Alarm: Identifiable, Codable, Equatable {
|
|
||||||
let id: UUID
|
|
||||||
var time: Date
|
|
||||||
var isEnabled: Bool
|
|
||||||
var soundName: String
|
|
||||||
|
|
||||||
static func ==(lhs: Alarm, rhs: Alarm) -> Bool {
|
|
||||||
lhs.id == rhs.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AlarmView: View {
|
|
||||||
@State private var alarms: [Alarm] = []
|
|
||||||
@State private var alarmLookup: [UUID: Int] = [:] // O(1) lookup for alarm indices
|
|
||||||
@State private var showAddAlarm = false
|
|
||||||
@State private var newAlarmTime = Date()
|
|
||||||
@State private var selectedSoundName = "default"
|
|
||||||
|
|
||||||
// Debounced persistence (store in @State so we can mutate from methods)
|
|
||||||
@State private var persistenceWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
let systemSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
ForEach(alarms) { alarm in
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(alarm.time, style: .time)
|
|
||||||
.font(.headline)
|
|
||||||
Text("Enabled: \(alarm.isEnabled ? "Yes" : "No")")
|
|
||||||
.font(.subheadline)
|
|
||||||
Text("Sound: \(alarm.soundName)")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: binding(for: alarm))
|
|
||||||
.labelsHidden()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete(perform: deleteAlarm)
|
|
||||||
}
|
|
||||||
.navigationTitle("Alarms")
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button(action: {
|
|
||||||
showAddAlarm = true
|
|
||||||
newAlarmTime = Date()
|
|
||||||
selectedSoundName = "default"
|
|
||||||
}) {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.title2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear(perform: loadAlarms)
|
|
||||||
.onChange(of: alarms) { _ in
|
|
||||||
updateAlarmLookup()
|
|
||||||
saveAlarms()
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showAddAlarm) {
|
|
||||||
AddAlarmView(
|
|
||||||
alarms: $alarms,
|
|
||||||
systemSounds: systemSounds,
|
|
||||||
newAlarmTime: $newAlarmTime,
|
|
||||||
selectedSoundName: $selectedSoundName,
|
|
||||||
showAddAlarm: $showAddAlarm
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func binding(for alarm: Alarm) -> Binding<Bool> {
|
|
||||||
guard let index = alarmLookup[alarm.id] else {
|
|
||||||
return .constant(false)
|
|
||||||
}
|
|
||||||
return Binding(
|
|
||||||
get: { alarms[index].isEnabled },
|
|
||||||
set: { newValue in
|
|
||||||
var updatedAlarm = alarms[index]
|
|
||||||
updatedAlarm.isEnabled = newValue
|
|
||||||
alarms[index] = updatedAlarm
|
|
||||||
updateAlarmNotification(alarm: updatedAlarm)
|
|
||||||
saveAlarms()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAlarmLookup() {
|
|
||||||
alarmLookup.removeAll()
|
|
||||||
for (index, alarm) in alarms.enumerated() {
|
|
||||||
alarmLookup[alarm.id] = index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteAlarm(at offsets: IndexSet) {
|
|
||||||
alarms.remove(atOffsets: offsets)
|
|
||||||
updateAllNotifications()
|
|
||||||
saveAlarms()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAlarmNotification(alarm: Alarm) {
|
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [alarm.id.uuidString])
|
|
||||||
if alarm.isEnabled {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = "Wake Up!"
|
|
||||||
content.body = "Your alarm is ringing."
|
|
||||||
content.sound = alarm.soundName == "default" ? UNNotificationSound.default : UNNotificationSound(named: UNNotificationSoundName(rawValue: "\(alarm.soundName).caf"))
|
|
||||||
|
|
||||||
let components = Calendar.current.dateComponents([.hour, .minute], from: alarm.time)
|
|
||||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
|
||||||
let request = UNNotificationRequest(identifier: alarm.id.uuidString, content: content, trigger: trigger)
|
|
||||||
UNUserNotificationCenter.current().add(request) { error in
|
|
||||||
if let error = error {
|
|
||||||
print("Error scheduling notification: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAllNotifications() {
|
|
||||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
|
||||||
for alarm in alarms where alarm.isEnabled {
|
|
||||||
updateAlarmNotification(alarm: alarm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveAlarms() {
|
|
||||||
// Cancel previous save operation
|
|
||||||
persistenceWorkItem?.cancel()
|
|
||||||
|
|
||||||
// Snapshot data to write to avoid capturing self strongly
|
|
||||||
let alarmsSnapshot = self.alarms
|
|
||||||
|
|
||||||
// Create new work item with debounce
|
|
||||||
let work = DispatchWorkItem {
|
|
||||||
if let encoded = try? JSONEncoder().encode(alarmsSnapshot) {
|
|
||||||
UserDefaults.standard.set(encoded, forKey: "SavedAlarms")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
persistenceWorkItem = work
|
|
||||||
|
|
||||||
// Execute after 0.3 second delay
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadAlarms() {
|
|
||||||
if let savedAlarms = UserDefaults.standard.data(forKey: "SavedAlarms"),
|
|
||||||
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
|
||||||
alarms = decodedAlarms
|
|
||||||
updateAlarmLookup()
|
|
||||||
updateAllNotifications()
|
|
||||||
}
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, error in
|
|
||||||
if let error = error {
|
|
||||||
print("Authorization error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
AlarmView()
|
|
||||||
}
|
|
||||||
@ -1,6 +1,16 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Main tab navigation coordinator
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@ -24,11 +34,12 @@ struct ContentView: View {
|
|||||||
Label("Noise", systemImage: "waveform")
|
Label("Noise", systemImage: "waveform")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accentColor(.blue)
|
.accentColor(UIConstants.Colors.accentColor)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
ContentView()
|
ContentView()
|
||||||
}
|
}
|
||||||
@ -7,8 +7,11 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// App entry point and configuration
|
||||||
@main
|
@main
|
||||||
struct TheNoiseClockApp: App {
|
struct TheNoiseClockApp: App {
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
@ -1,105 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Observation
|
|
||||||
|
|
||||||
struct ClockSettingsView: View {
|
|
||||||
@Bindable var style: ClockStyle
|
|
||||||
var onCommit: () -> Void = {}
|
|
||||||
|
|
||||||
@State private var digitColor: Color = .white
|
|
||||||
@State private var backgroundColor: Color = .black
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
Form {
|
|
||||||
Section(header: Text("Time")) {
|
|
||||||
Toggle("24‑Hour", isOn: $style.use24Hour)
|
|
||||||
Toggle("Show Seconds", isOn: $style.showSeconds)
|
|
||||||
|
|
||||||
// Show the AM/PM toggle only when 24-hour mode is OFF
|
|
||||||
if !style.use24Hour {
|
|
||||||
Toggle("Show AM/PM Badge", isOn: $style.showAmPmBadge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text("Appearance")) {
|
|
||||||
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
|
|
||||||
Toggle("Randomize Color (every minute)", isOn: $style.randomizeColor)
|
|
||||||
|
|
||||||
Toggle("Stretched (auto-fit)", isOn: $style.stretched)
|
|
||||||
|
|
||||||
// Show Size slider only when Stretched is OFF
|
|
||||||
if !style.stretched {
|
|
||||||
HStack {
|
|
||||||
Text("Size")
|
|
||||||
Slider(value: $style.digitScale, in: 0.0...1.0)
|
|
||||||
Text("\(Int((min(max(style.digitScale, 0.0), 1.0)) * 100))%")
|
|
||||||
.frame(width: 50, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Glow")
|
|
||||||
Slider(value: $style.glowIntensity, in: 0...1)
|
|
||||||
Text("\(Int(style.glowIntensity * 100))%")
|
|
||||||
.frame(width: 50, alignment: .trailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Clock Opacity")
|
|
||||||
Slider(value: $style.clockOpacity, in: 0.0...1.0)
|
|
||||||
Text("\(Int(style.clockOpacity * 100))%")
|
|
||||||
.frame(width: 50, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text("Overlays")) {
|
|
||||||
// Move Overlay Opacity slider here at the top of the section
|
|
||||||
HStack {
|
|
||||||
Text("Overlay Opacity")
|
|
||||||
Slider(value: $style.overlayOpacity, in: 0.0...1.0)
|
|
||||||
Text("\(Int(style.overlayOpacity * 100))%")
|
|
||||||
.frame(width: 50, alignment: .trailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Battery Level", isOn: $style.showBattery)
|
|
||||||
Toggle("Date", isOn: $style.showDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text("Background")) {
|
|
||||||
ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true)
|
|
||||||
HStack {
|
|
||||||
Button("Night") {
|
|
||||||
backgroundColor = .black
|
|
||||||
digitColor = .white
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("Day") {
|
|
||||||
backgroundColor = .white
|
|
||||||
digitColor = .black
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Clock Settings")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.onAppear {
|
|
||||||
digitColor = Color(hex: style.digitColorHex) ?? .white
|
|
||||||
backgroundColor = Color(hex: style.backgroundHex) ?? .black
|
|
||||||
}
|
|
||||||
.onChange(of: digitColor) { newValue in
|
|
||||||
style.digitColorHex = newValue.toHex() ?? "#FFFFFF"
|
|
||||||
style.clearColorCache()
|
|
||||||
onCommit()
|
|
||||||
}
|
|
||||||
.onChange(of: backgroundColor) { newValue in
|
|
||||||
style.backgroundHex = newValue.toHex() ?? "#000000"
|
|
||||||
style.clearColorCache()
|
|
||||||
onCommit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ClockSettingsView(style: ClockStyle())
|
|
||||||
}
|
|
||||||
@ -1,582 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
import Observation
|
|
||||||
|
|
||||||
struct ClockView: View {
|
|
||||||
@State private var currentTime = Date()
|
|
||||||
|
|
||||||
// Smart timer management - only create timers when needed
|
|
||||||
@State private var secondTimer: Timer.TimerPublisher?
|
|
||||||
@State private var minuteTimer: Timer.TimerPublisher?
|
|
||||||
@State private var secondCancellable: AnyCancellable?
|
|
||||||
@State private var minuteCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
// Persist the style as JSON in AppStorage
|
|
||||||
@AppStorage(ClockStyle.appStorageKey) private var styleJSON: Data = {
|
|
||||||
let def = ClockStyle()
|
|
||||||
return (try? JSONEncoder().encode(def)) ?? Data()
|
|
||||||
}()
|
|
||||||
|
|
||||||
@State private var style = ClockStyle()
|
|
||||||
@State private var showSettings = false
|
|
||||||
|
|
||||||
// Display mode (full-screen clock)
|
|
||||||
@State private var isDisplayMode = false
|
|
||||||
|
|
||||||
// Cached text measurements to avoid expensive calculations
|
|
||||||
@State private var cachedMeasurements: [String: CGSize] = [:]
|
|
||||||
@State private var lastFontSize: CGFloat = 0
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
style.backgroundColor
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
// Animate the whole visible content to soften layout changes
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
// Battery/date overlay — always shown if enabled
|
|
||||||
if style.showBattery || style.showDate {
|
|
||||||
TopOverlay(
|
|
||||||
showBattery: style.showBattery,
|
|
||||||
showDate: style.showDate,
|
|
||||||
color: style.digitColor.opacity(0.9),
|
|
||||||
overallOpacity: style.overlayOpacity
|
|
||||||
)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Clock
|
|
||||||
SegmentedTimeView(
|
|
||||||
date: currentTime,
|
|
||||||
use24Hour: style.use24Hour,
|
|
||||||
showSeconds: style.showSeconds,
|
|
||||||
digitColor: style.digitColor,
|
|
||||||
glowIntensity: style.glowIntensity,
|
|
||||||
manualScale: style.digitScale, // 0.0 ... 1.0 as percentage of stretched
|
|
||||||
stretched: style.stretched,
|
|
||||||
showAmPmBadge: style.showAmPmBadge,
|
|
||||||
clockOpacity: style.clockOpacity
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
// Subtle scale on the entire content during the transition
|
|
||||||
.scaleEffect(isDisplayMode ? 1.0 : 0.995)
|
|
||||||
.opacity(isDisplayMode ? 1.0 : 1.0) // keep opacity, but transition hooks are kept above
|
|
||||||
.animation(.smooth(duration: 0.3), value: isDisplayMode)
|
|
||||||
}
|
|
||||||
.navigationTitle(isDisplayMode ? "" : "Clock")
|
|
||||||
.toolbar {
|
|
||||||
if !isDisplayMode {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button {
|
|
||||||
showSettings = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "gear")
|
|
||||||
.font(.title2)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Hide the navigation bar entirely in display mode
|
|
||||||
.navigationBarBackButtonHidden(isDisplayMode)
|
|
||||||
.toolbar(isDisplayMode ? .hidden : .automatic)
|
|
||||||
.onAppear {
|
|
||||||
loadStyle()
|
|
||||||
setupTimers()
|
|
||||||
// Ensure correct tab bar visibility if we reappear while in display mode
|
|
||||||
setTabBarHidden(isDisplayMode, animated: false)
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
stopTimers()
|
|
||||||
// Restore tab bar when leaving this screen
|
|
||||||
setTabBarHidden(false, animated: false)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showSettings) {
|
|
||||||
ClockSettingsView(style: style, onCommit: saveStyle)
|
|
||||||
.presentationDetents([.medium, .large])
|
|
||||||
}
|
|
||||||
.onChange(of: style) { _ in
|
|
||||||
saveStyle()
|
|
||||||
updateTimersIfNeeded()
|
|
||||||
}
|
|
||||||
// Long-press anywhere to toggle display mode
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.simultaneousGesture(
|
|
||||||
LongPressGesture(minimumDuration: 0.6)
|
|
||||||
.onEnded { _ in
|
|
||||||
withAnimation(.bouncy(duration: 0.4)) {
|
|
||||||
isDisplayMode.toggle()
|
|
||||||
setTabBarHidden(isDisplayMode, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadStyle() {
|
|
||||||
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
|
|
||||||
style = decoded
|
|
||||||
} else {
|
|
||||||
style = ClockStyle()
|
|
||||||
saveStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounced persistence to avoid excessive UserDefaults writes
|
|
||||||
@State private var persistenceWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func saveStyle() {
|
|
||||||
// Cancel previous save operation
|
|
||||||
persistenceWorkItem?.cancel()
|
|
||||||
|
|
||||||
// Create new work item with debounce
|
|
||||||
let work = DispatchWorkItem {
|
|
||||||
if let data = try? JSONEncoder().encode(self.style) {
|
|
||||||
self.styleJSON = data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
persistenceWorkItem = work
|
|
||||||
|
|
||||||
// Execute after 0.5 second delay
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smart timer management
|
|
||||||
private func setupTimers() {
|
|
||||||
// Always need minute timer for color randomization
|
|
||||||
if minuteTimer == nil {
|
|
||||||
minuteTimer = Timer.publish(every: 60, on: .main, in: .common)
|
|
||||||
minuteCancellable = minuteTimer?.autoconnect().sink { _ in
|
|
||||||
if self.style.randomizeColor {
|
|
||||||
self.style.digitColorHex = Self.randomBrightColorHex()
|
|
||||||
self.saveStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only create second timer if seconds are shown
|
|
||||||
if style.showSeconds && secondTimer == nil {
|
|
||||||
secondTimer = Timer.publish(every: 1, on: .main, in: .common)
|
|
||||||
secondCancellable = secondTimer?.autoconnect().sink { now in
|
|
||||||
self.currentTime = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopTimers() {
|
|
||||||
secondCancellable?.cancel()
|
|
||||||
minuteCancellable?.cancel()
|
|
||||||
secondCancellable = nil
|
|
||||||
minuteCancellable = nil
|
|
||||||
secondTimer = nil
|
|
||||||
minuteTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateTimersIfNeeded() {
|
|
||||||
// Check if we need to start/stop second timer based on showSeconds
|
|
||||||
if style.showSeconds && secondTimer == nil {
|
|
||||||
secondTimer = Timer.publish(every: 1, on: .main, in: .common)
|
|
||||||
secondCancellable = secondTimer?.autoconnect().sink { now in
|
|
||||||
self.currentTime = now
|
|
||||||
}
|
|
||||||
} else if !style.showSeconds && secondTimer != nil {
|
|
||||||
secondCancellable?.cancel()
|
|
||||||
secondCancellable = nil
|
|
||||||
secondTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func randomBrightColorHex() -> String {
|
|
||||||
let hue = Double.random(in: 0...1)
|
|
||||||
let color = Color(hue: hue, saturation: 0.9, brightness: 0.95)
|
|
||||||
return color.toHex() ?? "#FFFFFF"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tab bar visibility helper (UIKit)
|
|
||||||
private func setTabBarHidden(_ hidden: Bool, animated: Bool) {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
||||||
let window = windowScene.windows.first,
|
|
||||||
let tabBarController = window.rootViewController?.findTabBarController() else { return }
|
|
||||||
|
|
||||||
let tabBar = tabBarController.tabBar
|
|
||||||
let changes = {
|
|
||||||
tabBar.alpha = hidden ? 0 : 1
|
|
||||||
}
|
|
||||||
if animated {
|
|
||||||
UIView.animate(withDuration: 0.25, animations: changes)
|
|
||||||
} else {
|
|
||||||
changes()
|
|
||||||
}
|
|
||||||
tabBar.isUserInteractionEnabled = !hidden
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
private extension UIViewController {
|
|
||||||
func findTabBarController() -> UITabBarController? {
|
|
||||||
if let tbc = self as? UITabBarController { return tbc }
|
|
||||||
for child in children {
|
|
||||||
if let tbc = child.findTabBarController() { return tbc }
|
|
||||||
}
|
|
||||||
if let presented = presentedViewController {
|
|
||||||
return presented.findTabBarController()
|
|
||||||
}
|
|
||||||
if let nav = self as? UINavigationController {
|
|
||||||
return nav.visibleViewController?.findTabBarController()
|
|
||||||
}
|
|
||||||
return parent?.findTabBarController()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Segmented Time View
|
|
||||||
|
|
||||||
private struct SegmentedTimeView: View {
|
|
||||||
let date: Date
|
|
||||||
let use24Hour: Bool
|
|
||||||
let showSeconds: Bool
|
|
||||||
let digitColor: Color
|
|
||||||
let glowIntensity: Double
|
|
||||||
let manualScale: Double // 0.0 ... 1.0 percent of fitted scale
|
|
||||||
let stretched: Bool
|
|
||||||
let showAmPmBadge: Bool
|
|
||||||
let clockOpacity: Double // 0.0 ... 1.0 overall opacity
|
|
||||||
|
|
||||||
// Formatters
|
|
||||||
private static let hour24DF: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
df.dateFormat = "HH"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
private static let hour12DF: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
df.dateFormat = "h"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
private static let minuteDF: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
df.dateFormat = "mm"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
private static let secondDF: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
df.dateFormat = "ss"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
private static let ampmDF: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
df.dateFormat = "a"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { proxy in
|
|
||||||
let size = proxy.size
|
|
||||||
let portrait = size.height >= size.width
|
|
||||||
let baseFontSize = dynamicBaseFontSize(containerWidth: size.width, containerHeight: size.height)
|
|
||||||
let ampmFontSize = baseFontSize * 0.20
|
|
||||||
|
|
||||||
// Segments
|
|
||||||
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
|
||||||
let minute = Self.minuteDF.string(from: date)
|
|
||||||
let secondsText = Self.secondDF.string(from: date)
|
|
||||||
let ampmText = Self.ampmDF.string(from: date)
|
|
||||||
let showAMPM = !use24Hour && showAmPmBadge
|
|
||||||
|
|
||||||
// Measure intrinsic sizes with caching
|
|
||||||
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
|
|
||||||
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
|
|
||||||
let hourSize = measureWithCache(text: hour, font: digitUIFont, cacheKey: "hour_\(baseFontSize)")
|
|
||||||
let minuteSize = measureWithCache(text: minute, font: digitUIFont, cacheKey: "minute_\(baseFontSize)")
|
|
||||||
let secondsSize = showSeconds ? measureWithCache(text: secondsText, font: digitUIFont, cacheKey: "seconds_\(baseFontSize)") : .zero
|
|
||||||
let ampmSize = showAMPM ? measureWithCache(text: ampmText, font: ampmUIFont, cacheKey: "ampm_\(ampmFontSize)") : .zero
|
|
||||||
|
|
||||||
// Separators
|
|
||||||
let dotDiameter = baseFontSize * 0.20
|
|
||||||
let hSpacing = baseFontSize * 0.18
|
|
||||||
let vSpacing = baseFontSize * 0.22
|
|
||||||
let horizontalSepSize = CGSize(width: dotDiameter * 2 + hSpacing, height: dotDiameter)
|
|
||||||
let verticalSepSize = CGSize(width: dotDiameter, height: dotDiameter * 2 + vSpacing)
|
|
||||||
|
|
||||||
// Virtual layout size
|
|
||||||
let (totalWidth, totalHeight): (CGFloat, CGFloat) = {
|
|
||||||
if portrait {
|
|
||||||
var widths: [CGFloat] = [hourSize.width, minuteSize.width]
|
|
||||||
var totalH: CGFloat = hourSize.height + minuteSize.height
|
|
||||||
if showAMPM {
|
|
||||||
widths.append(ampmSize.width)
|
|
||||||
totalH += ampmSize.height
|
|
||||||
} else {
|
|
||||||
widths.append(horizontalSepSize.width)
|
|
||||||
totalH += horizontalSepSize.height
|
|
||||||
}
|
|
||||||
if showSeconds {
|
|
||||||
widths.append(contentsOf: [horizontalSepSize.width, secondsSize.width])
|
|
||||||
totalH += horizontalSepSize.height + secondsSize.height
|
|
||||||
}
|
|
||||||
return (widths.max() ?? 0, totalH)
|
|
||||||
} else {
|
|
||||||
var totalW: CGFloat = hourSize.width + minuteSize.width
|
|
||||||
var heights: [CGFloat] = [hourSize.height, minuteSize.height]
|
|
||||||
if showAMPM {
|
|
||||||
totalW += ampmSize.width
|
|
||||||
heights.append(ampmSize.height)
|
|
||||||
} else {
|
|
||||||
totalW += verticalSepSize.width
|
|
||||||
heights.append(verticalSepSize.height)
|
|
||||||
}
|
|
||||||
if showSeconds {
|
|
||||||
totalW += verticalSepSize.width + secondsSize.width
|
|
||||||
heights.append(contentsOf: [verticalSepSize.height, secondsSize.height])
|
|
||||||
}
|
|
||||||
return (totalW, heights.max() ?? 0)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Scale to fit
|
|
||||||
let safeInsetW: CGFloat = 8
|
|
||||||
let safeInsetH: CGFloat = 8
|
|
||||||
let availableW = max(1, size.width - safeInsetW * 2)
|
|
||||||
let availableH = max(1, size.height - safeInsetH * 2)
|
|
||||||
let widthScale = availableW / max(totalWidth, 1)
|
|
||||||
let heightScale = availableH / max(totalHeight, 1)
|
|
||||||
let fittedScale = max(0.1, min(widthScale, heightScale))
|
|
||||||
let manualPercent = max(0.0, min(manualScale, 1.0))
|
|
||||||
let effectiveScale = stretched ? fittedScale : max(0.05, fittedScale * CGFloat(manualPercent))
|
|
||||||
|
|
||||||
Group {
|
|
||||||
if portrait {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
segment(hour, baseFontSize: baseFontSize, overallOpacity: clockOpacity)
|
|
||||||
if showAMPM {
|
|
||||||
segment(ampmText, baseFontSize: ampmFontSize, overallOpacity: clockOpacity)
|
|
||||||
} else {
|
|
||||||
horizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, overallOpacity: clockOpacity)
|
|
||||||
}
|
|
||||||
segment(minute, baseFontSize: baseFontSize, overallOpacity: clockOpacity)
|
|
||||||
if showSeconds {
|
|
||||||
horizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, overallOpacity: clockOpacity)
|
|
||||||
segment(secondsText, baseFontSize: baseFontSize, overallOpacity: clockOpacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
segment(hour, baseFontSize: baseFontSize, overallOpacity: clockOpacity)
|
|
||||||
if showAMPM {
|
|
||||||
segment(ampmText, baseFontSize: ampmFontSize, overallOpacity: clockOpacity)
|
|
||||||
} else {
|
|
||||||
verticalColon(dotDiameter: dotDiameter, spacing: vSpacing, overallOpacity: clockOpacity)
|
|
||||||
}
|
|
||||||
segment(minute, baseFontSize: baseFontSize, overallOpacity: clockOpacity)
|
|
||||||
if showSeconds {
|
|
||||||
verticalColon(dotDiameter: dotDiameter, spacing: vSpacing, overallOpacity: clockOpacity)
|
|
||||||
segment(secondsText, baseFontSize: baseFontSize, overallOpacity: clockOpacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: size.width, height: size.height, alignment: .center)
|
|
||||||
// Animate scale changes caused by geometry updates (e.g., hiding bars)
|
|
||||||
.scaleEffect(effectiveScale, anchor: .center)
|
|
||||||
.animation(.smooth(duration: 0.3), value: effectiveScale)
|
|
||||||
.minimumScaleFactor(0.1)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func segment(_ text: String, baseFontSize: CGFloat, overallOpacity: Double) -> some View {
|
|
||||||
let clamped = max(0.0, min(overallOpacity, 1.0))
|
|
||||||
return ZStack {
|
|
||||||
Text(text)
|
|
||||||
.font(.system(size: baseFontSize, weight: .bold, design: .rounded))
|
|
||||||
.foregroundColor(digitColor)
|
|
||||||
.blur(radius: glowRadius())
|
|
||||||
.opacity(glowOpacity() * clamped)
|
|
||||||
Text(text)
|
|
||||||
.font(.system(size: baseFontSize, weight: .bold, design: .rounded))
|
|
||||||
.foregroundColor(digitColor)
|
|
||||||
.opacity(clamped)
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
|
||||||
.lineLimit(1)
|
|
||||||
.allowsTightening(true)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func horizontalColon(dotDiameter: CGFloat, spacing: CGFloat, overallOpacity: Double) -> some View {
|
|
||||||
let clamped = max(0.0, min(overallOpacity, 1.0))
|
|
||||||
return HStack(spacing: spacing) {
|
|
||||||
dotCircle(size: dotDiameter, overallOpacity: clamped)
|
|
||||||
dotCircle(size: dotDiameter, overallOpacity: clamped)
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func verticalColon(dotDiameter: CGFloat, spacing: CGFloat, overallOpacity: Double) -> some View {
|
|
||||||
let clamped = max(0.0, min(overallOpacity, 1.0))
|
|
||||||
return VStack(spacing: spacing) {
|
|
||||||
dotCircle(size: dotDiameter, overallOpacity: clamped)
|
|
||||||
dotCircle(size: dotDiameter, overallOpacity: clamped)
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dotCircle(size: CGFloat, overallOpacity: Double) -> some View {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(digitColor)
|
|
||||||
.frame(width: size, height: size)
|
|
||||||
.blur(radius: glowRadius())
|
|
||||||
.opacity(glowOpacity() * overallOpacity)
|
|
||||||
Circle()
|
|
||||||
.fill(digitColor)
|
|
||||||
.frame(width: size, height: size)
|
|
||||||
.opacity(overallOpacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dynamicBaseFontSize(containerWidth: CGFloat, containerHeight: CGFloat) -> CGFloat {
|
|
||||||
let shortest = min(containerWidth, containerHeight)
|
|
||||||
return min(shortest * 0.28, 220)
|
|
||||||
}
|
|
||||||
private func glowRadius() -> CGFloat { CGFloat(20 * glowIntensity) }
|
|
||||||
private func glowOpacity() -> Double { min(0.9, max(0, glowIntensity)) }
|
|
||||||
|
|
||||||
private func measure(text: String, font: UIFont) -> CGSize {
|
|
||||||
let attributes = [NSAttributedString.Key.font: font]
|
|
||||||
return (text as NSString).size(withAttributes: attributes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached text measurement to avoid expensive calculations
|
|
||||||
private func measureWithCache(text: String, font: UIFont, cacheKey: String) -> CGSize {
|
|
||||||
// For now, we'll use the direct measurement since we can't access the parent's cache
|
|
||||||
// In a more complex implementation, we'd pass the cache as a parameter
|
|
||||||
return measure(text: text, font: font)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Top Overlay
|
|
||||||
|
|
||||||
private struct TopOverlay: View {
|
|
||||||
let showBattery: Bool
|
|
||||||
let showDate: Bool
|
|
||||||
let color: Color
|
|
||||||
let overallOpacity: Double
|
|
||||||
|
|
||||||
@State private var batteryLevel: Int = 100
|
|
||||||
@State private var dateString: String = ""
|
|
||||||
|
|
||||||
// Update timers
|
|
||||||
@State private var minuteTimer: Timer.TimerPublisher? = nil
|
|
||||||
@State private var minuteCancellable: AnyCancellable? = nil
|
|
||||||
|
|
||||||
private static let dateDF: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.dateFormat = "d MMMM EEE"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let clamped = max(0.0, min(overallOpacity, 1.0))
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
if showBattery {
|
|
||||||
Label("\(batteryLevel)%", systemImage: "bolt.circle")
|
|
||||||
.foregroundColor(color)
|
|
||||||
.opacity(clamped)
|
|
||||||
}
|
|
||||||
if showDate {
|
|
||||||
Text(dateString)
|
|
||||||
.foregroundColor(color)
|
|
||||||
.opacity(clamped)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(.black.opacity(0.25 * clamped), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 16).stroke(.white.opacity(0.05 * clamped), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.onAppear {
|
|
||||||
// Initialize date immediately and start minute updates
|
|
||||||
updateDate()
|
|
||||||
startMinuteUpdates()
|
|
||||||
// Initialize battery and start monitoring (UIKit only)
|
|
||||||
enableBatteryMonitoring()
|
|
||||||
updateBattery()
|
|
||||||
startBatteryObserver()
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
stopMinuteUpdates()
|
|
||||||
stopBatteryObserver()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Date updates
|
|
||||||
private func startMinuteUpdates() {
|
|
||||||
let pub = Timer.publish(every: 60, on: .main, in: .common)
|
|
||||||
minuteTimer = pub
|
|
||||||
minuteCancellable = pub.autoconnect().sink { _ in
|
|
||||||
updateDate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private func stopMinuteUpdates() {
|
|
||||||
minuteCancellable?.cancel()
|
|
||||||
minuteCancellable = nil
|
|
||||||
minuteTimer = nil
|
|
||||||
}
|
|
||||||
private func updateDate() {
|
|
||||||
dateString = Self.dateDF.string(from: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Battery updates
|
|
||||||
private func enableBatteryMonitoring() {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
private func updateBattery() {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
let lvl = UIDevice.current.batteryLevel
|
|
||||||
if lvl >= 0 {
|
|
||||||
batteryLevel = Int((lvl * 100).rounded())
|
|
||||||
} else {
|
|
||||||
// Unknown battery, keep previous or show 100 (default)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
private func startBatteryObserver() {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
NotificationCenter.default.addObserver(forName: UIDevice.batteryLevelDidChangeNotification, object: nil, queue: .main) { _ in
|
|
||||||
updateBattery()
|
|
||||||
}
|
|
||||||
NotificationCenter.default.addObserver(forName: UIDevice.batteryStateDidChangeNotification, object: nil, queue: .main) { _ in
|
|
||||||
updateBattery()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
private func stopBatteryObserver() {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
NotificationCenter.default.removeObserver(self, name: UIDevice.batteryLevelDidChangeNotification, object: nil)
|
|
||||||
NotificationCenter.default.removeObserver(self, name: UIDevice.batteryStateDidChangeNotification, object: nil)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
65
TheNoiseClock/Core/Constants/AppConstants.swift
Normal file
65
TheNoiseClock/Core/Constants/AppConstants.swift
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// AppConstants.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// App-wide constants and configuration values
|
||||||
|
enum AppConstants {
|
||||||
|
|
||||||
|
// MARK: - App Information
|
||||||
|
static let appName = "TheNoiseClock"
|
||||||
|
static let minimumIOSVersion = "18.0"
|
||||||
|
|
||||||
|
// MARK: - Storage Keys
|
||||||
|
enum StorageKeys {
|
||||||
|
static let clockStyle = "ClockStyle_JSON"
|
||||||
|
static let savedAlarms = "SavedAlarms"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer Intervals
|
||||||
|
enum TimerIntervals {
|
||||||
|
static let second = 1.0
|
||||||
|
static let minute = 60.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animation Durations
|
||||||
|
enum AnimationDurations {
|
||||||
|
static let short = 0.25
|
||||||
|
static let medium = 0.3
|
||||||
|
static let long = 0.4
|
||||||
|
static let bouncy = 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence Delays
|
||||||
|
enum PersistenceDelays {
|
||||||
|
static let clockStyle = 0.5
|
||||||
|
static let alarms = 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display Mode
|
||||||
|
enum DisplayMode {
|
||||||
|
static let longPressDuration = 0.6
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Default Values
|
||||||
|
enum Defaults {
|
||||||
|
static let digitColorHex = "#FFFFFF"
|
||||||
|
static let backgroundColorHex = "#000000"
|
||||||
|
static let glowIntensity = 0.6
|
||||||
|
static let digitScale = 1.0
|
||||||
|
static let clockOpacity = 0.5
|
||||||
|
static let overlayOpacity = 0.5
|
||||||
|
static let maxFontSize = 220.0
|
||||||
|
static let safeInset = 8.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Sounds
|
||||||
|
enum SystemSounds {
|
||||||
|
static let defaultSound = "default"
|
||||||
|
static let availableSounds = ["default", "bell", "chimes", "ding", "glass", "silence"]
|
||||||
|
}
|
||||||
|
}
|
||||||
49
TheNoiseClock/Core/Constants/AudioConstants.swift
Normal file
49
TheNoiseClock/Core/Constants/AudioConstants.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// AudioConstants.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFAudio
|
||||||
|
|
||||||
|
/// Audio-related constants and configuration
|
||||||
|
enum AudioConstants {
|
||||||
|
|
||||||
|
// MARK: - Sound Files
|
||||||
|
enum SoundFiles {
|
||||||
|
static let whiteNoise = "white-noise.mp3"
|
||||||
|
static let heavyRain = "heavy-rain-white-noise.mp3"
|
||||||
|
static let fanNoise = "fan-white-noise-heater-303207.mp3"
|
||||||
|
|
||||||
|
static let allFiles = [whiteNoise, heavyRain, fanNoise]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sound Names
|
||||||
|
enum SoundNames {
|
||||||
|
static let whiteNoise = "White Noise"
|
||||||
|
static let heavyRain = "Heavy Rain White Noise"
|
||||||
|
static let fanNoise = "Fan White Noise"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Session Configuration
|
||||||
|
enum AudioSession {
|
||||||
|
static let category = AVAudioSession.Category.playback
|
||||||
|
static let mode = AVAudioSession.Mode.default
|
||||||
|
static let options: AVAudioSession.CategoryOptions = [.mixWithOthers]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback Settings
|
||||||
|
enum Playback {
|
||||||
|
static let numberOfLoops = -1 // Infinite loop
|
||||||
|
static let prepareToPlay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Volume
|
||||||
|
enum Volume {
|
||||||
|
static let min: Float = 0.0
|
||||||
|
static let max: Float = 1.0
|
||||||
|
static let `default`: Float = 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
77
TheNoiseClock/Core/Constants/UIConstants.swift
Normal file
77
TheNoiseClock/Core/Constants/UIConstants.swift
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// UIConstants.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// UI-specific constants for colors, sizes, and styling
|
||||||
|
enum UIConstants {
|
||||||
|
|
||||||
|
// MARK: - Colors
|
||||||
|
enum Colors {
|
||||||
|
static let accentColor = Color.blue
|
||||||
|
static let primaryText = Color.white
|
||||||
|
static let secondaryText = Color.gray
|
||||||
|
static let background = Color.black
|
||||||
|
static let overlayBackground = Color.black.opacity(0.25)
|
||||||
|
static let overlayBorder = Color.white.opacity(0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Font Sizes
|
||||||
|
enum FontSizes {
|
||||||
|
static let largeTitle: CGFloat = 34
|
||||||
|
static let title: CGFloat = 28
|
||||||
|
static let title2: CGFloat = 22
|
||||||
|
static let title3: CGFloat = 20
|
||||||
|
static let headline: CGFloat = 17
|
||||||
|
static let body: CGFloat = 17
|
||||||
|
static let callout: CGFloat = 16
|
||||||
|
static let subheadline: CGFloat = 15
|
||||||
|
static let footnote: CGFloat = 13
|
||||||
|
static let caption: CGFloat = 12
|
||||||
|
static let caption2: CGFloat = 11
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spacing
|
||||||
|
enum Spacing {
|
||||||
|
static let extraSmall: CGFloat = 4
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
static let extraLarge: CGFloat = 20
|
||||||
|
static let huge: CGFloat = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Corner Radius
|
||||||
|
enum CornerRadius {
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
static let extraLarge: CGFloat = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Border Width
|
||||||
|
enum BorderWidth {
|
||||||
|
static let thin: CGFloat = 0.5
|
||||||
|
static let normal: CGFloat = 1
|
||||||
|
static let thick: CGFloat = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Opacity
|
||||||
|
enum Opacity {
|
||||||
|
static let disabled: Double = 0.3
|
||||||
|
static let secondary: Double = 0.6
|
||||||
|
static let primary: Double = 0.8
|
||||||
|
static let full: Double = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animation Curves
|
||||||
|
enum AnimationCurves {
|
||||||
|
static let smooth = Animation.smooth(duration: AppConstants.AnimationDurations.medium)
|
||||||
|
static let bouncy = Animation.bouncy(duration: AppConstants.AnimationDurations.bouncy)
|
||||||
|
static let quick = Animation.easeInOut(duration: AppConstants.AnimationDurations.short)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
TheNoiseClock/Core/Extensions/Color+Extensions.swift
Normal file
49
TheNoiseClock/Core/Extensions/Color+Extensions.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// Color+Extensions.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
|
||||||
|
/// Initialize Color from hex string
|
||||||
|
/// - Parameter hex: Hex string (e.g., "#FFFFFF", "FFFFFF", "#FFF")
|
||||||
|
init?(hex: String) {
|
||||||
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var int: UInt64 = 0
|
||||||
|
guard Scanner(string: hex).scanHexInt64(&int) else { return nil }
|
||||||
|
let r, g, b, a: UInt64
|
||||||
|
switch hex.count {
|
||||||
|
case 3: (r, g, b, a) = (int >> 8, int >> 4 & 0xF, int & 0xF, 0xF)
|
||||||
|
case 6: (r, g, b, a) = (int >> 16, int >> 8 & 0xFF, int & 0xFF, 0xFF)
|
||||||
|
case 8: (r, g, b, a) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
self.init(.sRGB,
|
||||||
|
red: Double(r) / 255,
|
||||||
|
green: Double(g) / 255,
|
||||||
|
blue: Double(b) / 255,
|
||||||
|
opacity: Double(a) / 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Color to hex string
|
||||||
|
/// - Returns: Hex string representation (e.g., "#FFFFFF")
|
||||||
|
func toHex() -> String? {
|
||||||
|
let uic = UIColor(self)
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
guard uic.getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
|
||||||
|
let rgb: Int = Int(r*255)<<16 | Int(g*255)<<8 | Int(b*255)<<0
|
||||||
|
return String(format: "#%06x", rgb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random bright color
|
||||||
|
/// - Returns: Hex string of a random bright color
|
||||||
|
static func randomBrightColorHex() -> String {
|
||||||
|
let hue = Double.random(in: 0...1)
|
||||||
|
let color = Color(hue: hue, saturation: 0.9, brightness: 0.95)
|
||||||
|
return color.toHex() ?? AppConstants.Defaults.digitColorHex
|
||||||
|
}
|
||||||
|
}
|
||||||
55
TheNoiseClock/Core/Extensions/Date+Extensions.swift
Normal file
55
TheNoiseClock/Core/Extensions/Date+Extensions.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// Date+Extensions.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
|
||||||
|
/// Format date for display in overlay (e.g., "7 September Mon")
|
||||||
|
/// - Returns: Formatted date string
|
||||||
|
func formattedForOverlay() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "d MMMM EEE"
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time components for alarm scheduling
|
||||||
|
/// - Returns: DateComponents with hour and minute
|
||||||
|
func timeComponents() -> DateComponents {
|
||||||
|
return Calendar.current.dateComponents([.hour, .minute], from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if date is today
|
||||||
|
/// - Returns: True if date is today
|
||||||
|
func isToday() -> Bool {
|
||||||
|
return Calendar.current.isDateInToday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get next occurrence of this time
|
||||||
|
/// - Returns: Next occurrence of this time, or today if time hasn't passed
|
||||||
|
func nextOccurrence() -> Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let today = calendar.startOfDay(for: now)
|
||||||
|
let timeComponents = self.timeComponents()
|
||||||
|
|
||||||
|
guard let todayWithTime = calendar.date(byAdding: timeComponents, to: today) else {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
if todayWithTime > now {
|
||||||
|
return todayWithTime
|
||||||
|
} else {
|
||||||
|
// Time has passed today, return tomorrow
|
||||||
|
guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: today),
|
||||||
|
let tomorrowWithTime = calendar.date(byAdding: timeComponents, to: tomorrow) else {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return tomorrowWithTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
TheNoiseClock/Core/Extensions/View+Extensions.swift
Normal file
78
TheNoiseClock/Core/Extensions/View+Extensions.swift
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
//
|
||||||
|
// View+Extensions.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
/// Apply standard card styling
|
||||||
|
/// - Returns: View with card styling applied
|
||||||
|
func cardStyle() -> some View {
|
||||||
|
self
|
||||||
|
.background(UIConstants.Colors.overlayBackground, in: RoundedRectangle(cornerRadius: UIConstants.CornerRadius.large))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: UIConstants.CornerRadius.large)
|
||||||
|
.stroke(UIConstants.Colors.overlayBorder, lineWidth: UIConstants.BorderWidth.normal)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply standard button styling
|
||||||
|
/// - Parameters:
|
||||||
|
/// - isEnabled: Whether the button is enabled
|
||||||
|
/// - color: Button color
|
||||||
|
/// - Returns: View with button styling applied
|
||||||
|
func buttonStyle(isEnabled: Bool = true, color: Color = UIConstants.Colors.accentColor) -> some View {
|
||||||
|
self
|
||||||
|
.padding(UIConstants.Spacing.medium)
|
||||||
|
.background(isEnabled ? color : color.opacity(UIConstants.Opacity.disabled))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(UIConstants.CornerRadius.small)
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide tab bar with animation
|
||||||
|
/// - Parameters:
|
||||||
|
/// - hidden: Whether to hide the tab bar
|
||||||
|
/// - animated: Whether to animate the change
|
||||||
|
func hideTabBar(_ hidden: Bool, animated: Bool = true) {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let window = windowScene.windows.first,
|
||||||
|
let tabBarController = window.rootViewController?.findTabBarController() else { return }
|
||||||
|
|
||||||
|
let tabBar = tabBarController.tabBar
|
||||||
|
let changes = {
|
||||||
|
tabBar.alpha = hidden ? 0 : 1
|
||||||
|
}
|
||||||
|
if animated {
|
||||||
|
UIView.animate(withDuration: AppConstants.AnimationDurations.short, animations: changes)
|
||||||
|
} else {
|
||||||
|
changes()
|
||||||
|
}
|
||||||
|
tabBar.isUserInteractionEnabled = !hidden
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
// Made internal (module-wide) so it can be used from other files like ClockView.swift
|
||||||
|
extension UIViewController {
|
||||||
|
func findTabBarController() -> UITabBarController? {
|
||||||
|
if let tbc = self as? UITabBarController { return tbc }
|
||||||
|
for child in children {
|
||||||
|
if let tbc = child.findTabBarController() { return tbc }
|
||||||
|
}
|
||||||
|
if let presented = presentedViewController {
|
||||||
|
return presented.findTabBarController()
|
||||||
|
}
|
||||||
|
if let nav = self as? UINavigationController {
|
||||||
|
return nav.visibleViewController?.findTabBarController()
|
||||||
|
}
|
||||||
|
return parent?.findTabBarController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
50
TheNoiseClock/Core/Utilities/ColorUtils.swift
Normal file
50
TheNoiseClock/Core/Utilities/ColorUtils.swift
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// ColorUtils.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Color manipulation utilities
|
||||||
|
enum ColorUtils {
|
||||||
|
|
||||||
|
/// Calculate glow radius based on intensity
|
||||||
|
/// - Parameter intensity: Glow intensity (0.0 to 1.0)
|
||||||
|
/// - Returns: Blur radius for glow effect
|
||||||
|
static func glowRadius(intensity: Double) -> CGFloat {
|
||||||
|
return CGFloat(20 * intensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate glow opacity based on intensity
|
||||||
|
/// - Parameter intensity: Glow intensity (0.0 to 1.0)
|
||||||
|
/// - Returns: Opacity for glow effect
|
||||||
|
static func glowOpacity(intensity: Double) -> Double {
|
||||||
|
return min(0.9, max(0, intensity))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp opacity value to valid range
|
||||||
|
/// - Parameter opacity: Opacity value to clamp
|
||||||
|
/// - Returns: Clamped opacity (0.0 to 1.0)
|
||||||
|
static func clampOpacity(_ opacity: Double) -> Double {
|
||||||
|
return max(0.0, min(opacity, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate dynamic font size based on container dimensions
|
||||||
|
/// - Parameters:
|
||||||
|
/// - containerWidth: Container width
|
||||||
|
/// - containerHeight: Container height
|
||||||
|
/// - Returns: Calculated font size
|
||||||
|
static func dynamicFontSize(containerWidth: CGFloat, containerHeight: CGFloat) -> CGFloat {
|
||||||
|
let shortest = min(containerWidth, containerHeight)
|
||||||
|
return min(shortest * 0.28, AppConstants.Defaults.maxFontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate AM/PM font size based on base font size
|
||||||
|
/// - Parameter baseFontSize: Base font size
|
||||||
|
/// - Returns: AM/PM font size (20% of base)
|
||||||
|
static func ampmFontSize(baseFontSize: CGFloat) -> CGFloat {
|
||||||
|
return baseFontSize * 0.20
|
||||||
|
}
|
||||||
|
}
|
||||||
88
TheNoiseClock/Core/Utilities/NotificationUtils.swift
Normal file
88
TheNoiseClock/Core/Utilities/NotificationUtils.swift
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// NotificationUtils.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UserNotifications
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Notification helper functions
|
||||||
|
enum NotificationUtils {
|
||||||
|
|
||||||
|
/// Request notification permissions
|
||||||
|
/// - Returns: True if permission granted
|
||||||
|
static func requestPermissions() async -> Bool {
|
||||||
|
do {
|
||||||
|
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
||||||
|
options: [.alert, .sound, .badge]
|
||||||
|
)
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
print("Error requesting notification permissions: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create notification content for alarm
|
||||||
|
/// - Parameters:
|
||||||
|
/// - title: Notification title
|
||||||
|
/// - body: Notification body
|
||||||
|
/// - soundName: Sound name for notification
|
||||||
|
/// - Returns: Configured notification content
|
||||||
|
static func createAlarmContent(title: String, body: String, soundName: String) -> UNMutableNotificationContent {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
|
||||||
|
if soundName == AppConstants.SystemSounds.defaultSound {
|
||||||
|
content.sound = UNNotificationSound.default
|
||||||
|
} else {
|
||||||
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "\(soundName).caf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create calendar trigger for alarm
|
||||||
|
/// - Parameter date: Date for alarm
|
||||||
|
/// - Returns: Calendar notification trigger
|
||||||
|
static func createCalendarTrigger(for date: Date) -> UNCalendarNotificationTrigger {
|
||||||
|
let components = Calendar.current.dateComponents([.hour, .minute], from: date)
|
||||||
|
return UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule notification
|
||||||
|
/// - Parameters:
|
||||||
|
/// - identifier: Unique identifier for notification
|
||||||
|
/// - content: Notification content
|
||||||
|
/// - trigger: Notification trigger
|
||||||
|
/// - Returns: True if scheduled successfully
|
||||||
|
static func scheduleNotification(
|
||||||
|
identifier: String,
|
||||||
|
content: UNMutableNotificationContent,
|
||||||
|
trigger: UNNotificationTrigger
|
||||||
|
) async -> Bool {
|
||||||
|
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await UNUserNotificationCenter.current().add(request)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
print("Error scheduling notification: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove notification by identifier
|
||||||
|
/// - Parameter identifier: Notification identifier to remove
|
||||||
|
static func removeNotification(identifier: String) {
|
||||||
|
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all pending notifications
|
||||||
|
static func removeAllNotifications() {
|
||||||
|
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
TheNoiseClock/Models/Alarm.swift
Normal file
40
TheNoiseClock/Models/Alarm.swift
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// Alarm.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Alarm data model
|
||||||
|
struct Alarm: Identifiable, Codable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var time: Date
|
||||||
|
var isEnabled: Bool
|
||||||
|
var soundName: String
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init(id: UUID = UUID(), time: Date, isEnabled: Bool = true, soundName: String = AppConstants.SystemSounds.defaultSound) {
|
||||||
|
self.id = id
|
||||||
|
self.time = time
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
self.soundName = soundName
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Equatable
|
||||||
|
static func ==(lhs: Alarm, rhs: Alarm) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
func nextTriggerTime() -> Date {
|
||||||
|
return time.nextOccurrence()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: time)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,38 +1,41 @@
|
|||||||
|
//
|
||||||
|
// ClockStyle.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
|
/// Clock customization settings and data model
|
||||||
@Observable
|
@Observable
|
||||||
class ClockStyle: Codable, Equatable {
|
class ClockStyle: Codable, Equatable {
|
||||||
|
|
||||||
|
// MARK: - Time Format Settings
|
||||||
var use24Hour: Bool = true
|
var use24Hour: Bool = true
|
||||||
var showSeconds: Bool = false
|
var showSeconds: Bool = false
|
||||||
var showAmPmBadge: Bool = false
|
var showAmPmBadge: Bool = false
|
||||||
|
|
||||||
// Default to white digits
|
// MARK: - Visual Settings
|
||||||
var digitColorHex: String = "#FFFFFF"
|
var digitColorHex: String = AppConstants.Defaults.digitColorHex
|
||||||
var randomizeColor: Bool = false
|
var randomizeColor: Bool = false
|
||||||
|
var glowIntensity: Double = AppConstants.Defaults.glowIntensity
|
||||||
var glowIntensity: Double = 0.6 // 0...1
|
var digitScale: Double = AppConstants.Defaults.digitScale
|
||||||
// Interpreted as 0.0...1.0 percentage of fitted size when stretched == false
|
|
||||||
var digitScale: Double = 1.0
|
|
||||||
|
|
||||||
// Stretched layout that auto-fits the available space (default: true)
|
|
||||||
var stretched: Bool = true
|
var stretched: Bool = true
|
||||||
|
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex
|
||||||
|
|
||||||
var backgroundHex: String = "#000000"
|
// MARK: - Overlay Settings
|
||||||
var showBattery: Bool = true
|
var showBattery: Bool = true
|
||||||
var showDate: Bool = true
|
var showDate: Bool = true
|
||||||
|
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
||||||
|
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
||||||
|
|
||||||
// Overall opacity for the main clock digits/separators (0.0...1.0)
|
// MARK: - Cached Colors
|
||||||
var clockOpacity: Double = 0.5
|
|
||||||
|
|
||||||
// New: Independent opacity for the top overlay (battery/date) (0.0...1.0)
|
|
||||||
var overlayOpacity: Double = 0.5
|
|
||||||
|
|
||||||
// Cached colors to avoid repeated hex conversions
|
|
||||||
private var _cachedDigitColor: Color?
|
private var _cachedDigitColor: Color?
|
||||||
private var _cachedBackgroundColor: Color?
|
private var _cachedBackgroundColor: Color?
|
||||||
|
|
||||||
// Codable keys (persist only these)
|
// MARK: - Codable Keys
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case use24Hour
|
case use24Hour
|
||||||
case showSeconds
|
case showSeconds
|
||||||
@ -49,11 +52,15 @@ class ClockStyle: Codable, Equatable {
|
|||||||
case overlayOpacity
|
case overlayOpacity
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Codable
|
// MARK: - Initialization
|
||||||
|
init() {
|
||||||
|
// Defaults already set in property declarations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable Implementation
|
||||||
required init(from decoder: Decoder) throws {
|
required init(from decoder: Decoder) throws {
|
||||||
// Start with defaults
|
|
||||||
// Then override with any decoded values (backward compatible)
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour
|
self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour
|
||||||
self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds
|
self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds
|
||||||
self.showAmPmBadge = try container.decodeIfPresent(Bool.self, forKey: .showAmPmBadge) ?? self.showAmPmBadge
|
self.showAmPmBadge = try container.decodeIfPresent(Bool.self, forKey: .showAmPmBadge) ?? self.showAmPmBadge
|
||||||
@ -68,14 +75,9 @@ class ClockStyle: Codable, Equatable {
|
|||||||
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
|
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
|
||||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
||||||
|
|
||||||
// Ensure cached colors reflect decoded hex
|
|
||||||
clearColorCache()
|
clearColorCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
|
||||||
// Defaults already set in property declarations
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try container.encode(use24Hour, forKey: .use24Hour)
|
try container.encode(use24Hour, forKey: .use24Hour)
|
||||||
@ -93,7 +95,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codable <-> Color helpers with caching
|
// MARK: - Computed Properties
|
||||||
var digitColor: Color {
|
var digitColor: Color {
|
||||||
if let cached = _cachedDigitColor {
|
if let cached = _cachedDigitColor {
|
||||||
return cached
|
return cached
|
||||||
@ -112,7 +114,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
return color
|
return color
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear cache when colors change
|
// MARK: - Helper Methods
|
||||||
func clearColorCache() {
|
func clearColorCache() {
|
||||||
_cachedDigitColor = nil
|
_cachedDigitColor = nil
|
||||||
_cachedBackgroundColor = nil
|
_cachedBackgroundColor = nil
|
||||||
@ -136,34 +138,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Storage Key
|
||||||
extension ClockStyle {
|
extension ClockStyle {
|
||||||
static let appStorageKey = "ClockStyle_JSON"
|
static let appStorageKey = AppConstants.StorageKeys.clockStyle
|
||||||
}
|
|
||||||
|
|
||||||
extension Color {
|
|
||||||
init?(hex: String) {
|
|
||||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
||||||
var int: UInt64 = 0
|
|
||||||
guard Scanner(string: hex).scanHexInt64(&int) else { return nil }
|
|
||||||
let r, g, b, a: UInt64
|
|
||||||
switch hex.count {
|
|
||||||
case 3: (r, g, b, a) = (int >> 8, int >> 4 & 0xF, int & 0xF, 0xF)
|
|
||||||
case 6: (r, g, b, a) = (int >> 16, int >> 8 & 0xFF, int & 0xFF, 0xFF)
|
|
||||||
case 8: (r, g, b, a) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
self.init(.sRGB,
|
|
||||||
red: Double(r) / 255,
|
|
||||||
green: Double(g) / 255,
|
|
||||||
blue: Double(b) / 255,
|
|
||||||
opacity: Double(a) / 255)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toHex() -> String? {
|
|
||||||
let uic = UIColor(self)
|
|
||||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
||||||
guard uic.getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
|
|
||||||
let rgb: Int = Int(r*255)<<16 | Int(g*255)<<8 | Int(b*255)<<0
|
|
||||||
return String(format: "#%06x", rgb)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
26
TheNoiseClock/Models/Sound.swift
Normal file
26
TheNoiseClock/Models/Sound.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// Sound.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Sound data model for audio files
|
||||||
|
struct Sound: Identifiable, Hashable {
|
||||||
|
let id = UUID()
|
||||||
|
let name: String
|
||||||
|
let fileName: String
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init(name: String, fileName: String) {
|
||||||
|
self.name = name
|
||||||
|
self.fileName = fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hashable
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct NoiseView: View {
|
|
||||||
@State private var player = NoisePlayer()
|
|
||||||
let sounds = [
|
|
||||||
Sound(name: "White Noise", fileName: "white-noise.mp3"),
|
|
||||||
Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3"),
|
|
||||||
Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater-303207.mp3")
|
|
||||||
// Add more sounds here, matching your bundled MP3s
|
|
||||||
]
|
|
||||||
|
|
||||||
@State private var selectedSound: Sound?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Text("White/Pink Noise")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Picker("Select Noise", selection: $selectedSound) {
|
|
||||||
Text("Choose a sound").tag(nil as Sound?) // Placeholder
|
|
||||||
ForEach(sounds) { sound in
|
|
||||||
Text(sound.name).tag(sound as Sound?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button(player.isPlaying ? "Stop" : "Play") {
|
|
||||||
if player.isPlaying {
|
|
||||||
player.stopSound()
|
|
||||||
} else if let sound = selectedSound {
|
|
||||||
player.playSound(sound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(player.isPlaying ? Color.red : Color.green)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(8)
|
|
||||||
.disabled(selectedSound == nil) // Disable if no sound selected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add premium unlock button here later (e.g., In-App Purchase)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NoiseView()
|
|
||||||
}
|
|
||||||
122
TheNoiseClock/Services/AlarmService.swift
Normal file
122
TheNoiseClock/Services/AlarmService.swift
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
//
|
||||||
|
// AlarmService.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Service for managing alarms and notifications
|
||||||
|
@Observable
|
||||||
|
class AlarmService {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
private(set) var alarms: [Alarm] = []
|
||||||
|
private var alarmLookup: [UUID: Int] = [:]
|
||||||
|
private var persistenceWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init() {
|
||||||
|
loadAlarms()
|
||||||
|
Task {
|
||||||
|
await NotificationUtils.requestPermissions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
func addAlarm(_ alarm: Alarm) {
|
||||||
|
alarms.append(alarm)
|
||||||
|
updateAlarmLookup()
|
||||||
|
scheduleNotification(for: alarm)
|
||||||
|
saveAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAlarm(_ alarm: Alarm) {
|
||||||
|
guard let index = alarmLookup[alarm.id] else { return }
|
||||||
|
alarms[index] = alarm
|
||||||
|
updateAlarmLookup()
|
||||||
|
scheduleNotification(for: alarm)
|
||||||
|
saveAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAlarm(id: UUID) {
|
||||||
|
alarms.removeAll { $0.id == id }
|
||||||
|
updateAlarmLookup()
|
||||||
|
NotificationUtils.removeNotification(identifier: id.uuidString)
|
||||||
|
saveAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAlarm(id: UUID) {
|
||||||
|
guard let index = alarmLookup[id] else { return }
|
||||||
|
alarms[index].isEnabled.toggle()
|
||||||
|
scheduleNotification(for: alarms[index])
|
||||||
|
saveAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAlarm(id: UUID) -> Alarm? {
|
||||||
|
return alarms.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func updateAlarmLookup() {
|
||||||
|
alarmLookup.removeAll()
|
||||||
|
for (index, alarm) in alarms.enumerated() {
|
||||||
|
alarmLookup[alarm.id] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleNotification(for alarm: Alarm) {
|
||||||
|
// Remove existing notification
|
||||||
|
NotificationUtils.removeNotification(identifier: alarm.id.uuidString)
|
||||||
|
|
||||||
|
// Schedule new notification if enabled
|
||||||
|
if alarm.isEnabled {
|
||||||
|
Task {
|
||||||
|
let content = NotificationUtils.createAlarmContent(
|
||||||
|
title: "Wake Up!",
|
||||||
|
body: "Your alarm is ringing.",
|
||||||
|
soundName: alarm.soundName
|
||||||
|
)
|
||||||
|
let trigger = NotificationUtils.createCalendarTrigger(for: alarm.time)
|
||||||
|
await NotificationUtils.scheduleNotification(
|
||||||
|
identifier: alarm.id.uuidString,
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveAlarms() {
|
||||||
|
persistenceWorkItem?.cancel()
|
||||||
|
|
||||||
|
let alarmsSnapshot = self.alarms
|
||||||
|
let work = DispatchWorkItem {
|
||||||
|
if let encoded = try? JSONEncoder().encode(alarmsSnapshot) {
|
||||||
|
UserDefaults.standard.set(encoded, forKey: AppConstants.StorageKeys.savedAlarms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persistenceWorkItem = work
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(
|
||||||
|
deadline: .now() + AppConstants.PersistenceDelays.alarms,
|
||||||
|
execute: work
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAlarms() {
|
||||||
|
if let savedAlarms = UserDefaults.standard.data(forKey: AppConstants.StorageKeys.savedAlarms),
|
||||||
|
let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) {
|
||||||
|
alarms = decodedAlarms
|
||||||
|
updateAlarmLookup()
|
||||||
|
|
||||||
|
// Reschedule all enabled alarms
|
||||||
|
for alarm in alarms where alarm.isEnabled {
|
||||||
|
scheduleNotification(for: alarm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,22 @@
|
|||||||
|
//
|
||||||
|
// NoisePlayer.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
|
/// Audio playback service for white noise and ambient sounds
|
||||||
@Observable
|
@Observable
|
||||||
class NoisePlayer {
|
class NoisePlayer {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
private var players: [String: AVAudioPlayer] = [:]
|
private var players: [String: AVAudioPlayer] = [:]
|
||||||
private var currentPlayer: AVAudioPlayer?
|
private var currentPlayer: AVAudioPlayer?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
setupAudioSession()
|
setupAudioSession()
|
||||||
preloadSounds()
|
preloadSounds()
|
||||||
@ -15,40 +26,14 @@ class NoisePlayer {
|
|||||||
stopAllSounds()
|
stopAllSounds()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupAudioSession() {
|
// MARK: - Public Interface
|
||||||
do {
|
var isPlaying: Bool {
|
||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
return currentPlayer?.isPlaying ?? false
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
|
||||||
} catch {
|
|
||||||
print("Error setting up audio session: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func preloadSounds() {
|
|
||||||
let soundFiles = ["white-noise.mp3", "heavy-rain-white-noise.mp3", "fan-white-noise-heater-303207.mp3"]
|
|
||||||
|
|
||||||
for fileName in soundFiles {
|
|
||||||
guard let url = Bundle.main.url(forResource: fileName, withExtension: nil) else {
|
|
||||||
print("Sound file not found: \(fileName)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let player = try AVAudioPlayer(contentsOf: url)
|
|
||||||
player.numberOfLoops = -1 // Loop indefinitely
|
|
||||||
player.prepareToPlay()
|
|
||||||
players[fileName] = player
|
|
||||||
} catch {
|
|
||||||
print("Error preloading sound \(fileName): \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func playSound(_ sound: Sound) {
|
func playSound(_ sound: Sound) {
|
||||||
// Stop current sound if playing
|
|
||||||
stopSound()
|
stopSound()
|
||||||
|
|
||||||
// Get or create player for this sound
|
|
||||||
guard let player = players[sound.fileName] else {
|
guard let player = players[sound.fileName] else {
|
||||||
print("Sound not preloaded: \(sound.fileName)")
|
print("Sound not preloaded: \(sound.fileName)")
|
||||||
return
|
return
|
||||||
@ -63,6 +48,40 @@ class NoisePlayer {
|
|||||||
currentPlayer = nil
|
currentPlayer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func setupAudioSession() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(
|
||||||
|
AudioConstants.AudioSession.category,
|
||||||
|
mode: AudioConstants.AudioSession.mode,
|
||||||
|
options: AudioConstants.AudioSession.options
|
||||||
|
)
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("Error setting up audio session: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preloadSounds() {
|
||||||
|
for fileName in AudioConstants.SoundFiles.allFiles {
|
||||||
|
guard let url = Bundle.main.url(forResource: fileName, withExtension: nil) else {
|
||||||
|
print("Sound file not found: \(fileName)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: url)
|
||||||
|
player.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||||
|
if AudioConstants.Playback.prepareToPlay {
|
||||||
|
player.prepareToPlay()
|
||||||
|
}
|
||||||
|
players[fileName] = player
|
||||||
|
} catch {
|
||||||
|
print("Error preloading sound \(fileName): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func stopAllSounds() {
|
private func stopAllSounds() {
|
||||||
for player in players.values {
|
for player in players.values {
|
||||||
player.stop()
|
player.stop()
|
||||||
@ -70,8 +89,4 @@ class NoisePlayer {
|
|||||||
players.removeAll()
|
players.removeAll()
|
||||||
currentPlayer = nil
|
currentPlayer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPlaying: Bool {
|
|
||||||
return currentPlayer?.isPlaying ?? false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
80
TheNoiseClock/Services/NotificationService.swift
Normal file
80
TheNoiseClock/Services/NotificationService.swift
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// NotificationService.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Service for managing system notifications
|
||||||
|
@Observable
|
||||||
|
class NotificationService {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
private(set) var isAuthorized = false
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init() {
|
||||||
|
checkAuthorizationStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
func requestPermissions() async -> Bool {
|
||||||
|
do {
|
||||||
|
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
||||||
|
options: [.alert, .sound, .badge]
|
||||||
|
)
|
||||||
|
isAuthorized = granted
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
print("Error requesting notification permissions: \(error)")
|
||||||
|
isAuthorized = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthorizationStatus() {
|
||||||
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isAuthorized = settings.authorizationStatus == .authorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleAlarmNotification(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
soundName: String,
|
||||||
|
date: Date
|
||||||
|
) async -> Bool {
|
||||||
|
guard isAuthorized else {
|
||||||
|
print("Notifications not authorized")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = NotificationUtils.createAlarmContent(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
soundName: soundName
|
||||||
|
)
|
||||||
|
let trigger = NotificationUtils.createCalendarTrigger(for: date)
|
||||||
|
|
||||||
|
return await NotificationUtils.scheduleNotification(
|
||||||
|
identifier: id,
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelNotification(id: String) {
|
||||||
|
NotificationUtils.removeNotification(identifier: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAllNotifications() {
|
||||||
|
NotificationUtils.removeAllNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct Sound: Identifiable, Hashable {
|
|
||||||
let id = UUID() // For SwiftUI Picker
|
|
||||||
let name: String // Friendly name, e.g., "Gentle Pink Noise"
|
|
||||||
let fileName: String // File name, e.g., "pink_noise_sleep.mp3"
|
|
||||||
}
|
|
||||||
67
TheNoiseClock/ViewModels/AlarmViewModel.swift
Normal file
67
TheNoiseClock/ViewModels/AlarmViewModel.swift
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// AlarmViewModel.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// ViewModel for alarm management
|
||||||
|
@Observable
|
||||||
|
class AlarmViewModel {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
private let alarmService: AlarmService
|
||||||
|
private let notificationService: NotificationService
|
||||||
|
|
||||||
|
var alarms: [Alarm] {
|
||||||
|
alarmService.alarms
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemSounds: [String] {
|
||||||
|
AppConstants.SystemSounds.availableSounds
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init(alarmService: AlarmService = AlarmService(),
|
||||||
|
notificationService: NotificationService = NotificationService()) {
|
||||||
|
self.alarmService = alarmService
|
||||||
|
self.notificationService = notificationService
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
func addAlarm(_ alarm: Alarm) {
|
||||||
|
alarmService.addAlarm(alarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAlarm(_ alarm: Alarm) {
|
||||||
|
alarmService.updateAlarm(alarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAlarm(id: UUID) {
|
||||||
|
alarmService.deleteAlarm(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAlarm(id: UUID) {
|
||||||
|
alarmService.toggleAlarm(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAlarm(id: UUID) -> Alarm? {
|
||||||
|
return alarmService.getAlarm(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNewAlarm(time: Date, soundName: String = AppConstants.SystemSounds.defaultSound) -> Alarm {
|
||||||
|
return Alarm(
|
||||||
|
id: UUID(),
|
||||||
|
time: time,
|
||||||
|
isEnabled: true,
|
||||||
|
soundName: soundName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotificationPermissions() async -> Bool {
|
||||||
|
return await notificationService.requestPermissions()
|
||||||
|
}
|
||||||
|
}
|
||||||
133
TheNoiseClock/ViewModels/ClockViewModel.swift
Normal file
133
TheNoiseClock/ViewModels/ClockViewModel.swift
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// ClockViewModel.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// ViewModel for clock display and management
|
||||||
|
@Observable
|
||||||
|
class ClockViewModel {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
private(set) var currentTime = Date()
|
||||||
|
private(set) var style = ClockStyle()
|
||||||
|
private(set) var isDisplayMode = false
|
||||||
|
|
||||||
|
// Timer management
|
||||||
|
private var secondTimer: Timer.TimerPublisher?
|
||||||
|
private var minuteTimer: Timer.TimerPublisher?
|
||||||
|
private var secondCancellable: AnyCancellable?
|
||||||
|
private var minuteCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
// Persistence
|
||||||
|
private var persistenceWorkItem: DispatchWorkItem?
|
||||||
|
private var styleJSON: Data {
|
||||||
|
get {
|
||||||
|
UserDefaults.standard.data(forKey: ClockStyle.appStorageKey) ?? {
|
||||||
|
let def = ClockStyle()
|
||||||
|
return (try? JSONEncoder().encode(def)) ?? Data()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(newValue, forKey: ClockStyle.appStorageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init() {
|
||||||
|
loadStyle()
|
||||||
|
setupTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stopTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
func toggleDisplayMode() {
|
||||||
|
withAnimation(UIConstants.AnimationCurves.bouncy) {
|
||||||
|
isDisplayMode.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStyle(_ newStyle: ClockStyle) {
|
||||||
|
style = newStyle
|
||||||
|
saveStyle()
|
||||||
|
updateTimersIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func loadStyle() {
|
||||||
|
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
|
||||||
|
style = decoded
|
||||||
|
} else {
|
||||||
|
style = ClockStyle()
|
||||||
|
saveStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveStyle() {
|
||||||
|
persistenceWorkItem?.cancel()
|
||||||
|
|
||||||
|
let work = DispatchWorkItem {
|
||||||
|
if let data = try? JSONEncoder().encode(self.style) {
|
||||||
|
self.styleJSON = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persistenceWorkItem = work
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(
|
||||||
|
deadline: .now() + AppConstants.PersistenceDelays.clockStyle,
|
||||||
|
execute: work
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupTimers() {
|
||||||
|
// Always need minute timer for color randomization
|
||||||
|
if minuteTimer == nil {
|
||||||
|
minuteTimer = Timer.publish(every: AppConstants.TimerIntervals.minute, on: .main, in: .common)
|
||||||
|
minuteCancellable = minuteTimer?.autoconnect().sink { _ in
|
||||||
|
if self.style.randomizeColor {
|
||||||
|
self.style.digitColorHex = Color.randomBrightColorHex()
|
||||||
|
self.saveStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only create second timer if seconds are shown
|
||||||
|
if style.showSeconds && secondTimer == nil {
|
||||||
|
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, on: .main, in: .common)
|
||||||
|
secondCancellable = secondTimer?.autoconnect().sink { now in
|
||||||
|
self.currentTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimers() {
|
||||||
|
secondCancellable?.cancel()
|
||||||
|
minuteCancellable?.cancel()
|
||||||
|
secondCancellable = nil
|
||||||
|
minuteCancellable = nil
|
||||||
|
secondTimer = nil
|
||||||
|
minuteTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTimersIfNeeded() {
|
||||||
|
if style.showSeconds && secondTimer == nil {
|
||||||
|
secondTimer = Timer.publish(every: AppConstants.TimerIntervals.second, on: .main, in: .common)
|
||||||
|
secondCancellable = secondTimer?.autoconnect().sink { now in
|
||||||
|
self.currentTime = now
|
||||||
|
}
|
||||||
|
} else if !style.showSeconds && secondTimer != nil {
|
||||||
|
secondCancellable?.cancel()
|
||||||
|
secondCancellable = nil
|
||||||
|
secondTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
TheNoiseClock/ViewModels/NoiseViewModel.swift
Normal file
51
TheNoiseClock/ViewModels/NoiseViewModel.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// NoiseViewModel.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// ViewModel for noise/audio playback
|
||||||
|
@Observable
|
||||||
|
class NoiseViewModel {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
private let noisePlayer: NoisePlayer
|
||||||
|
|
||||||
|
var isPlaying: Bool {
|
||||||
|
noisePlayer.isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableSounds: [Sound] {
|
||||||
|
[
|
||||||
|
Sound(name: AudioConstants.SoundNames.whiteNoise, fileName: AudioConstants.SoundFiles.whiteNoise),
|
||||||
|
Sound(name: AudioConstants.SoundNames.heavyRain, fileName: AudioConstants.SoundFiles.heavyRain),
|
||||||
|
Sound(name: AudioConstants.SoundNames.fanNoise, fileName: AudioConstants.SoundFiles.fanNoise)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init(noisePlayer: NoisePlayer = NoisePlayer()) {
|
||||||
|
self.noisePlayer = noisePlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Interface
|
||||||
|
func playSound(_ sound: Sound) {
|
||||||
|
noisePlayer.playSound(sound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSound() {
|
||||||
|
noisePlayer.stopSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayback(for sound: Sound) {
|
||||||
|
if isPlaying {
|
||||||
|
stopSound()
|
||||||
|
} else {
|
||||||
|
playSound(sound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
TheNoiseClock/Views/Alarms/AddAlarmView.swift
Normal file
88
TheNoiseClock/Views/Alarms/AddAlarmView.swift
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// AddAlarmView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// View for creating new alarms
|
||||||
|
struct AddAlarmView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let viewModel: AlarmViewModel
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
@State private var newAlarmTime = Date()
|
||||||
|
@State private var selectedSoundName = AppConstants.SystemSounds.defaultSound
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: UIConstants.Spacing.extraLarge) {
|
||||||
|
TimePickerView(
|
||||||
|
selectedTime: $newAlarmTime
|
||||||
|
)
|
||||||
|
|
||||||
|
Picker("Sound", selection: $selectedSoundName) {
|
||||||
|
ForEach(viewModel.systemSounds, id: \.self) { sound in
|
||||||
|
Text(sound.capitalized).tag(sound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
HStack(spacing: UIConstants.Spacing.large) {
|
||||||
|
Button("Cancel") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
.buttonStyle(isEnabled: true, color: UIConstants.Colors.secondaryText)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Add Alarm") {
|
||||||
|
let newAlarm = viewModel.createNewAlarm(
|
||||||
|
time: newAlarmTime,
|
||||||
|
soundName: selectedSoundName
|
||||||
|
)
|
||||||
|
viewModel.addAlarm(newAlarm)
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
.buttonStyle(isEnabled: true, color: UIConstants.Colors.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(UIConstants.Spacing.large)
|
||||||
|
.navigationTitle("New Alarm")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
private struct TimePickerView: View {
|
||||||
|
@Binding var selectedTime: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: UIConstants.Spacing.small) {
|
||||||
|
Text("Time")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(UIConstants.Colors.primaryText)
|
||||||
|
|
||||||
|
DatePicker(
|
||||||
|
"Time",
|
||||||
|
selection: $selectedTime,
|
||||||
|
displayedComponents: .hourAndMinute
|
||||||
|
)
|
||||||
|
.datePickerStyle(.wheel)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
AddAlarmView(
|
||||||
|
viewModel: AlarmViewModel(),
|
||||||
|
isPresented: .constant(true)
|
||||||
|
)
|
||||||
|
}
|
||||||
67
TheNoiseClock/Views/Alarms/AlarmView.swift
Normal file
67
TheNoiseClock/Views/Alarms/AlarmView.swift
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// AlarmView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Main alarm management view
|
||||||
|
struct AlarmView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
@State private var viewModel = AlarmViewModel()
|
||||||
|
@State private var showAddAlarm = false
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.alarms) { alarm in
|
||||||
|
AlarmRowView(
|
||||||
|
alarm: alarm,
|
||||||
|
onToggle: { viewModel.toggleAlarm(id: alarm.id) },
|
||||||
|
onEdit: { /* TODO: Implement edit functionality */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteAlarm)
|
||||||
|
}
|
||||||
|
.navigationTitle("Alarms")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showAddAlarm = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
await viewModel.requestNotificationPermissions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddAlarm) {
|
||||||
|
AddAlarmView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
isPresented: $showAddAlarm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func deleteAlarm(at offsets: IndexSet) {
|
||||||
|
for index in offsets {
|
||||||
|
let alarm = viewModel.alarms[index]
|
||||||
|
viewModel.deleteAlarm(id: alarm.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
AlarmView()
|
||||||
|
}
|
||||||
|
}
|
||||||
59
TheNoiseClock/Views/Alarms/Components/AlarmRowView.swift
Normal file
59
TheNoiseClock/Views/Alarms/Components/AlarmRowView.swift
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// AlarmRowView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying individual alarm row
|
||||||
|
struct AlarmRowView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let alarm: Alarm
|
||||||
|
let onToggle: () -> Void
|
||||||
|
let onEdit: () -> Void
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: UIConstants.Spacing.extraSmall) {
|
||||||
|
Text(alarm.formattedTime())
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(UIConstants.Colors.primaryText)
|
||||||
|
|
||||||
|
Text("Enabled: \(alarm.isEnabled ? "Yes" : "No")")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(UIConstants.Colors.secondaryText)
|
||||||
|
|
||||||
|
Text("Sound: \(alarm.soundName)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(UIConstants.Colors.secondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { alarm.isEnabled },
|
||||||
|
set: { _ in onToggle() }
|
||||||
|
))
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
onEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
List {
|
||||||
|
AlarmRowView(
|
||||||
|
alarm: Alarm(time: Date()),
|
||||||
|
onToggle: {},
|
||||||
|
onEdit: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
TheNoiseClock/Views/Clock/ClockSettingsView.swift
Normal file
174
TheNoiseClock/Views/Clock/ClockSettingsView.swift
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
//
|
||||||
|
// ClockSettingsView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Settings interface for clock customization
|
||||||
|
struct ClockSettingsView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
@State private var style: ClockStyle
|
||||||
|
let onCommit: (ClockStyle) -> Void
|
||||||
|
|
||||||
|
@State private var digitColor: Color = .white
|
||||||
|
@State private var backgroundColor: Color = .black
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
init(style: ClockStyle, onCommit: @escaping (ClockStyle) -> Void) {
|
||||||
|
self._style = State(initialValue: style)
|
||||||
|
self.onCommit = onCommit
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
TimeFormatSection(style: $style)
|
||||||
|
AppearanceSection(
|
||||||
|
style: $style,
|
||||||
|
digitColor: $digitColor,
|
||||||
|
backgroundColor: $backgroundColor,
|
||||||
|
onCommit: onCommit
|
||||||
|
)
|
||||||
|
OverlaySection(style: $style)
|
||||||
|
BackgroundSection(
|
||||||
|
style: $style,
|
||||||
|
backgroundColor: $backgroundColor,
|
||||||
|
digitColor: $digitColor,
|
||||||
|
onCommit: onCommit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.navigationTitle("Clock Settings")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
digitColor = Color(hex: style.digitColorHex) ?? .white
|
||||||
|
backgroundColor = Color(hex: style.backgroundHex) ?? .black
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
onCommit(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
private struct TimeFormatSection: View {
|
||||||
|
@Binding var style: ClockStyle
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(header: Text("Time")) {
|
||||||
|
Toggle("24‑Hour", isOn: $style.use24Hour)
|
||||||
|
Toggle("Show Seconds", isOn: $style.showSeconds)
|
||||||
|
|
||||||
|
if !style.use24Hour {
|
||||||
|
Toggle("Show AM/PM Badge", isOn: $style.showAmPmBadge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AppearanceSection: View {
|
||||||
|
@Binding var style: ClockStyle
|
||||||
|
@Binding var digitColor: Color
|
||||||
|
@Binding var backgroundColor: Color
|
||||||
|
let onCommit: (ClockStyle) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(header: Text("Appearance")) {
|
||||||
|
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
|
||||||
|
Toggle("Randomize Color (every minute)", isOn: $style.randomizeColor)
|
||||||
|
|
||||||
|
Toggle("Stretched (auto-fit)", isOn: $style.stretched)
|
||||||
|
|
||||||
|
if !style.stretched {
|
||||||
|
HStack {
|
||||||
|
Text("Size")
|
||||||
|
Slider(value: $style.digitScale, in: 0.0...1.0)
|
||||||
|
Text("\(Int((min(max(style.digitScale, 0.0), 1.0)) * 100))%")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Glow")
|
||||||
|
Slider(value: $style.glowIntensity, in: 0...1)
|
||||||
|
Text("\(Int(style.glowIntensity * 100))%")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Clock Opacity")
|
||||||
|
Slider(value: $style.clockOpacity, in: 0.0...1.0)
|
||||||
|
Text("\(Int(style.clockOpacity * 100))%")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: digitColor) { _, newValue in
|
||||||
|
style.digitColorHex = newValue.toHex() ?? AppConstants.Defaults.digitColorHex
|
||||||
|
style.clearColorCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OverlaySection: View {
|
||||||
|
@Binding var style: ClockStyle
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(header: Text("Overlays")) {
|
||||||
|
HStack {
|
||||||
|
Text("Overlay Opacity")
|
||||||
|
Slider(value: $style.overlayOpacity, in: 0.0...1.0)
|
||||||
|
Text("\(Int(style.overlayOpacity * 100))%")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Battery Level", isOn: $style.showBattery)
|
||||||
|
Toggle("Date", isOn: $style.showDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BackgroundSection: View {
|
||||||
|
@Binding var style: ClockStyle
|
||||||
|
@Binding var backgroundColor: Color
|
||||||
|
@Binding var digitColor: Color
|
||||||
|
let onCommit: (ClockStyle) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(header: Text("Background")) {
|
||||||
|
ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Night") {
|
||||||
|
backgroundColor = .black
|
||||||
|
digitColor = .white
|
||||||
|
}
|
||||||
|
.buttonStyle(isEnabled: true, color: UIConstants.Colors.secondaryText)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Day") {
|
||||||
|
backgroundColor = .white
|
||||||
|
digitColor = .black
|
||||||
|
}
|
||||||
|
.buttonStyle(isEnabled: true, color: UIConstants.Colors.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: backgroundColor) { _, newValue in
|
||||||
|
style.backgroundHex = newValue.toHex() ?? AppConstants.Defaults.backgroundColorHex
|
||||||
|
style.clearColorCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
ClockSettingsView(
|
||||||
|
style: ClockStyle(),
|
||||||
|
onCommit: { _ in }
|
||||||
|
)
|
||||||
|
}
|
||||||
125
TheNoiseClock/Views/Clock/ClockView.swift
Normal file
125
TheNoiseClock/Views/Clock/ClockView.swift
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
//
|
||||||
|
// ClockView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Main clock display view with settings and display mode
|
||||||
|
struct ClockView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
@State private var viewModel = ClockViewModel()
|
||||||
|
@State private var showSettings = false
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
viewModel.style.backgroundColor
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: UIConstants.Spacing.medium) {
|
||||||
|
// Top overlay
|
||||||
|
if viewModel.style.showBattery || viewModel.style.showDate {
|
||||||
|
TopOverlayView(
|
||||||
|
showBattery: viewModel.style.showBattery,
|
||||||
|
showDate: viewModel.style.showDate,
|
||||||
|
color: viewModel.style.digitColor.opacity(UIConstants.Opacity.primary),
|
||||||
|
opacity: viewModel.style.overlayOpacity
|
||||||
|
)
|
||||||
|
.padding(.top, UIConstants.Spacing.small)
|
||||||
|
.padding(.horizontal, UIConstants.Spacing.large)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Time display
|
||||||
|
TimeDisplayView(
|
||||||
|
date: viewModel.currentTime,
|
||||||
|
use24Hour: viewModel.style.use24Hour,
|
||||||
|
showSeconds: viewModel.style.showSeconds,
|
||||||
|
digitColor: viewModel.style.digitColor,
|
||||||
|
glowIntensity: viewModel.style.glowIntensity,
|
||||||
|
manualScale: viewModel.style.digitScale,
|
||||||
|
stretched: viewModel.style.stretched,
|
||||||
|
showAmPmBadge: viewModel.style.showAmPmBadge,
|
||||||
|
clockOpacity: viewModel.style.clockOpacity
|
||||||
|
)
|
||||||
|
.padding(.horizontal, UIConstants.Spacing.medium)
|
||||||
|
.transition(.opacity)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.scaleEffect(viewModel.isDisplayMode ? 1.0 : 0.995)
|
||||||
|
.animation(UIConstants.AnimationCurves.smooth, value: viewModel.isDisplayMode)
|
||||||
|
}
|
||||||
|
.navigationTitle(viewModel.isDisplayMode ? "" : "Clock")
|
||||||
|
.toolbar {
|
||||||
|
if !viewModel.isDisplayMode {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showSettings = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gear")
|
||||||
|
.font(.title2)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(viewModel.isDisplayMode)
|
||||||
|
.toolbar(viewModel.isDisplayMode ? .hidden : .automatic)
|
||||||
|
.onAppear {
|
||||||
|
setTabBarHidden(viewModel.isDisplayMode, animated: false)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
setTabBarHidden(false, animated: false)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
ClockSettingsView(style: viewModel.style) { newStyle in
|
||||||
|
viewModel.updateStyle(newStyle)
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.simultaneousGesture(
|
||||||
|
LongPressGesture(minimumDuration: AppConstants.DisplayMode.longPressDuration)
|
||||||
|
.onEnded { _ in
|
||||||
|
viewModel.toggleDisplayMode()
|
||||||
|
setTabBarHidden(viewModel.isDisplayMode, animated: true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func setTabBarHidden(_ hidden: Bool, animated: Bool) {
|
||||||
|
// This will be handled by the View extension
|
||||||
|
// For now, we'll keep the UIKit implementation
|
||||||
|
#if canImport(UIKit)
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let window = windowScene.windows.first,
|
||||||
|
let tabBarController = window.rootViewController?.findTabBarController() else { return }
|
||||||
|
|
||||||
|
let tabBar = tabBarController.tabBar
|
||||||
|
let changes = {
|
||||||
|
tabBar.alpha = hidden ? 0 : 1
|
||||||
|
}
|
||||||
|
if animated {
|
||||||
|
UIView.animate(withDuration: AppConstants.AnimationDurations.short, animations: changes)
|
||||||
|
} else {
|
||||||
|
changes()
|
||||||
|
}
|
||||||
|
tabBar.isUserInteractionEnabled = !hidden
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
ClockView()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// BatteryOverlayView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Component for displaying battery level overlay
|
||||||
|
struct BatteryOverlayView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let color: Color
|
||||||
|
let opacity: Double
|
||||||
|
|
||||||
|
@State private var batteryLevel: Int = 100
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
|
||||||
|
Label("\(batteryLevel)%", systemImage: "bolt.circle")
|
||||||
|
.foregroundColor(color)
|
||||||
|
.opacity(clamped)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.onAppear {
|
||||||
|
enableBatteryMonitoring()
|
||||||
|
updateBattery()
|
||||||
|
startBatteryObserver()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopBatteryObserver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func enableBatteryMonitoring() {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateBattery() {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
let level = UIDevice.current.batteryLevel
|
||||||
|
if level >= 0 {
|
||||||
|
batteryLevel = Int((level * 100).rounded())
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startBatteryObserver() {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: UIDevice.batteryLevelDidChangeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
updateBattery()
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: UIDevice.batteryStateDidChangeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
updateBattery()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopBatteryObserver() {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: UIDevice.batteryLevelDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: UIDevice.batteryStateDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
57
TheNoiseClock/Views/Clock/Components/DateOverlayView.swift
Normal file
57
TheNoiseClock/Views/Clock/Components/DateOverlayView.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// DateOverlayView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Component for displaying date overlay
|
||||||
|
struct DateOverlayView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let color: Color
|
||||||
|
let opacity: Double
|
||||||
|
|
||||||
|
@State private var dateString: String = ""
|
||||||
|
@State private var minuteTimer: Timer.TimerPublisher?
|
||||||
|
@State private var minuteCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
|
||||||
|
Text(dateString)
|
||||||
|
.foregroundColor(color)
|
||||||
|
.opacity(clamped)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.onAppear {
|
||||||
|
updateDate()
|
||||||
|
startMinuteUpdates()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopMinuteUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func startMinuteUpdates() {
|
||||||
|
let pub = Timer.publish(every: AppConstants.TimerIntervals.minute, on: .main, in: .common)
|
||||||
|
minuteTimer = pub
|
||||||
|
minuteCancellable = pub.autoconnect().sink { _ in
|
||||||
|
updateDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopMinuteUpdates() {
|
||||||
|
minuteCancellable?.cancel()
|
||||||
|
minuteCancellable = nil
|
||||||
|
minuteTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDate() {
|
||||||
|
dateString = Date().formattedForOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
293
TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
Normal file
293
TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
//
|
||||||
|
// TimeDisplayView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying segmented time with customizable formatting
|
||||||
|
struct TimeDisplayView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let date: Date
|
||||||
|
let use24Hour: Bool
|
||||||
|
let showSeconds: Bool
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
let manualScale: Double
|
||||||
|
let stretched: Bool
|
||||||
|
let showAmPmBadge: Bool
|
||||||
|
let clockOpacity: Double
|
||||||
|
|
||||||
|
// MARK: - Formatters
|
||||||
|
private static let hour24DF: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
df.dateFormat = "HH"
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let hour12DF: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
df.dateFormat = "h"
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let minuteDF: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
df.dateFormat = "mm"
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let secondDF: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
df.dateFormat = "ss"
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let ampmDF: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
df.dateFormat = "a"
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let size = proxy.size
|
||||||
|
let portrait = size.height >= size.width
|
||||||
|
let baseFontSize = ColorUtils.dynamicFontSize(containerWidth: size.width, containerHeight: size.height)
|
||||||
|
let ampmFontSize = ColorUtils.ampmFontSize(baseFontSize: baseFontSize)
|
||||||
|
|
||||||
|
// Time components
|
||||||
|
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
||||||
|
let minute = Self.minuteDF.string(from: date)
|
||||||
|
let secondsText = Self.secondDF.string(from: date)
|
||||||
|
let ampmText = Self.ampmDF.string(from: date)
|
||||||
|
let showAMPM = !use24Hour && showAmPmBadge
|
||||||
|
|
||||||
|
// Calculate sizes
|
||||||
|
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
|
||||||
|
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
|
||||||
|
let hourSize = measureText(hour, font: digitUIFont)
|
||||||
|
let minuteSize = measureText(minute, font: digitUIFont)
|
||||||
|
let secondsSize = showSeconds ? measureText(secondsText, font: digitUIFont) : .zero
|
||||||
|
let ampmSize = showAMPM ? measureText(ampmText, font: ampmUIFont) : .zero
|
||||||
|
|
||||||
|
// Separators
|
||||||
|
let dotDiameter = baseFontSize * 0.20
|
||||||
|
let hSpacing = baseFontSize * 0.18
|
||||||
|
let vSpacing = baseFontSize * 0.22
|
||||||
|
let horizontalSepSize = CGSize(width: dotDiameter * 2 + hSpacing, height: dotDiameter)
|
||||||
|
let verticalSepSize = CGSize(width: dotDiameter, height: dotDiameter * 2 + vSpacing)
|
||||||
|
|
||||||
|
// Calculate layout
|
||||||
|
let (totalWidth, totalHeight) = calculateLayoutSize(
|
||||||
|
portrait: portrait,
|
||||||
|
hourSize: hourSize,
|
||||||
|
minuteSize: minuteSize,
|
||||||
|
secondsSize: secondsSize,
|
||||||
|
ampmSize: ampmSize,
|
||||||
|
horizontalSepSize: horizontalSepSize,
|
||||||
|
verticalSepSize: verticalSepSize,
|
||||||
|
showSeconds: showSeconds,
|
||||||
|
showAMPM: showAMPM
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate scale
|
||||||
|
let safeInset = AppConstants.Defaults.safeInset
|
||||||
|
let availableW = max(1, size.width - safeInset * 2)
|
||||||
|
let availableH = max(1, size.height - safeInset * 2)
|
||||||
|
let widthScale = availableW / max(totalWidth, 1)
|
||||||
|
let heightScale = availableH / max(totalHeight, 1)
|
||||||
|
let fittedScale = max(0.1, min(widthScale, heightScale))
|
||||||
|
let manualPercent = max(0.0, min(manualScale, 1.0))
|
||||||
|
let effectiveScale = stretched ? fittedScale : max(0.05, fittedScale * CGFloat(manualPercent))
|
||||||
|
|
||||||
|
// Time display
|
||||||
|
Group {
|
||||||
|
if portrait {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
if showAMPM {
|
||||||
|
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
} else {
|
||||||
|
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
}
|
||||||
|
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
if showSeconds {
|
||||||
|
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
if showAMPM {
|
||||||
|
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
} else {
|
||||||
|
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
}
|
||||||
|
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
if showSeconds {
|
||||||
|
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size.width, height: size.height, alignment: .center)
|
||||||
|
.scaleEffect(effectiveScale, anchor: .center)
|
||||||
|
.animation(UIConstants.AnimationCurves.smooth, value: effectiveScale)
|
||||||
|
.minimumScaleFactor(0.1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
private func measureText(_ text: String, font: UIFont) -> CGSize {
|
||||||
|
let attributes = [NSAttributedString.Key.font: font]
|
||||||
|
return (text as NSString).size(withAttributes: attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateLayoutSize(
|
||||||
|
portrait: Bool,
|
||||||
|
hourSize: CGSize,
|
||||||
|
minuteSize: CGSize,
|
||||||
|
secondsSize: CGSize,
|
||||||
|
ampmSize: CGSize,
|
||||||
|
horizontalSepSize: CGSize,
|
||||||
|
verticalSepSize: CGSize,
|
||||||
|
showSeconds: Bool,
|
||||||
|
showAMPM: Bool
|
||||||
|
) -> (CGFloat, CGFloat) {
|
||||||
|
if portrait {
|
||||||
|
var widths: [CGFloat] = [hourSize.width, minuteSize.width]
|
||||||
|
var totalH: CGFloat = hourSize.height + minuteSize.height
|
||||||
|
|
||||||
|
if showAMPM {
|
||||||
|
widths.append(ampmSize.width)
|
||||||
|
totalH += ampmSize.height
|
||||||
|
} else {
|
||||||
|
widths.append(horizontalSepSize.width)
|
||||||
|
totalH += horizontalSepSize.height
|
||||||
|
}
|
||||||
|
|
||||||
|
if showSeconds {
|
||||||
|
widths.append(contentsOf: [horizontalSepSize.width, secondsSize.width])
|
||||||
|
totalH += horizontalSepSize.height + secondsSize.height
|
||||||
|
}
|
||||||
|
|
||||||
|
return (widths.max() ?? 0, totalH)
|
||||||
|
} else {
|
||||||
|
var totalW: CGFloat = hourSize.width + minuteSize.width
|
||||||
|
var heights: [CGFloat] = [hourSize.height, minuteSize.height]
|
||||||
|
|
||||||
|
if showAMPM {
|
||||||
|
totalW += ampmSize.width
|
||||||
|
heights.append(ampmSize.height)
|
||||||
|
} else {
|
||||||
|
totalW += verticalSepSize.width
|
||||||
|
heights.append(verticalSepSize.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showSeconds {
|
||||||
|
totalW += verticalSepSize.width + secondsSize.width
|
||||||
|
heights.append(contentsOf: [verticalSepSize.height, secondsSize.height])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (totalW, heights.max() ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
private struct TimeSegment: View {
|
||||||
|
let text: String
|
||||||
|
let fontSize: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
ZStack {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: fontSize, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(digitColor)
|
||||||
|
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
|
||||||
|
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * clamped)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: fontSize, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(digitColor)
|
||||||
|
.opacity(clamped)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: true)
|
||||||
|
.lineLimit(1)
|
||||||
|
.allowsTightening(true)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HorizontalColon: View {
|
||||||
|
let dotDiameter: CGFloat
|
||||||
|
let spacing: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: true)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VerticalColon: View {
|
||||||
|
let dotDiameter: CGFloat
|
||||||
|
let spacing: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
VStack(spacing: spacing) {
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: true)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DotCircle: View {
|
||||||
|
let size: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(digitColor)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
|
||||||
|
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * opacity)
|
||||||
|
Circle()
|
||||||
|
.fill(digitColor)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.opacity(opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
TheNoiseClock/Views/Clock/Components/TopOverlayView.swift
Normal file
37
TheNoiseClock/Views/Clock/Components/TopOverlayView.swift
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// TopOverlayView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying top overlay with battery and date information
|
||||||
|
struct TopOverlayView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let showBattery: Bool
|
||||||
|
let showDate: Bool
|
||||||
|
let color: Color
|
||||||
|
let opacity: Double
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: UIConstants.Spacing.large) {
|
||||||
|
if showBattery {
|
||||||
|
BatteryOverlayView(color: color, opacity: opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showDate {
|
||||||
|
DateOverlayView(color: color, opacity: opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, UIConstants.Spacing.medium)
|
||||||
|
.padding(.vertical, UIConstants.Spacing.small)
|
||||||
|
.cardStyle()
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
TheNoiseClock/Views/Noise/Components/SoundControlView.swift
Normal file
49
TheNoiseClock/Views/Noise/Components/SoundControlView.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// SoundControlView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for audio playback controls
|
||||||
|
struct SoundControlView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let isPlaying: Bool
|
||||||
|
let selectedSound: Sound?
|
||||||
|
let onPlay: (Sound) -> Void
|
||||||
|
let onStop: () -> Void
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
if isPlaying {
|
||||||
|
onStop()
|
||||||
|
} else if let sound = selectedSound {
|
||||||
|
onPlay(sound)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(isPlaying ? "Stop" : "Play")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.buttonStyle(
|
||||||
|
isEnabled: selectedSound != nil,
|
||||||
|
color: isPlaying ? UIConstants.Colors.accentColor : .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
SoundControlView(
|
||||||
|
isPlaying: false,
|
||||||
|
selectedSound: Sound(name: "White Noise", fileName: "white-noise.mp3"),
|
||||||
|
onPlay: { _ in },
|
||||||
|
onStop: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
39
TheNoiseClock/Views/Noise/Components/SoundPickerView.swift
Normal file
39
TheNoiseClock/Views/Noise/Components/SoundPickerView.swift
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// SoundPickerView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for selecting sound from available options
|
||||||
|
struct SoundPickerView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let sounds: [Sound]
|
||||||
|
@Binding var selectedSound: Sound?
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
Picker("Select Noise", selection: $selectedSound) {
|
||||||
|
Text("Choose a sound").tag(nil as Sound?)
|
||||||
|
ForEach(sounds) { sound in
|
||||||
|
Text(sound.name).tag(sound as Sound?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.foregroundColor(UIConstants.Colors.primaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
SoundPickerView(
|
||||||
|
sounds: [
|
||||||
|
Sound(name: "White Noise", fileName: "white-noise.mp3"),
|
||||||
|
Sound(name: "Heavy Rain", fileName: "heavy-rain.mp3")
|
||||||
|
],
|
||||||
|
selectedSound: .constant(nil)
|
||||||
|
)
|
||||||
|
}
|
||||||
49
TheNoiseClock/Views/Noise/NoiseView.swift
Normal file
49
TheNoiseClock/Views/Noise/NoiseView.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// NoiseView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Main noise/audio player view
|
||||||
|
struct NoiseView: View {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
@State private var viewModel = NoiseViewModel()
|
||||||
|
@State private var selectedSound: Sound?
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: UIConstants.Spacing.large) {
|
||||||
|
Text("White/Pink Noise")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(UIConstants.Colors.primaryText)
|
||||||
|
|
||||||
|
SoundPickerView(
|
||||||
|
sounds: viewModel.availableSounds,
|
||||||
|
selectedSound: $selectedSound
|
||||||
|
)
|
||||||
|
|
||||||
|
SoundControlView(
|
||||||
|
isPlaying: viewModel.isPlaying,
|
||||||
|
selectedSound: selectedSound,
|
||||||
|
onPlay: { sound in
|
||||||
|
viewModel.playSound(sound)
|
||||||
|
},
|
||||||
|
onStop: {
|
||||||
|
viewModel.stopSound()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Future: Add premium unlock button here
|
||||||
|
}
|
||||||
|
.padding(UIConstants.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
NoiseView()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user