diff --git a/PRD.md b/PRD.md index a2f078b..71f78c7 100644 --- a/PRD.md +++ b/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 - **Sound selection**: Dropdown picker for sound selection -### 6. Alarm System -- **Multiple alarms**: Create and manage multiple alarms -- **Time selection**: Wheel-style date picker for alarm time -- **Sound selection**: Choose from system sounds (default, bell, chimes, ding, glass, silence) -- **Enable/disable toggles**: Individual alarm control -- **Notification integration**: Uses iOS UserNotifications framework -- **Persistent storage**: Alarms saved to UserDefaults -- **Alarm management**: Add, delete, and modify alarms +### 6. Advanced Alarm System +- **Multiple alarms**: Create and manage unlimited alarms +- **Rich alarm editor**: Full-featured alarm creation and editing interface +- **Time selection**: Wheel-style date picker for precise alarm time +- **Custom labels**: User-defined alarm names and descriptions +- **Repeat schedules**: Set alarms to repeat on specific weekdays or daily +- **Sound selection**: Choose from extensive system sounds with live preview +- **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 +### 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 - **Main App**: `TheNoiseClockApp.swift` - Entry point with WindowGroup - **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 ### Data Models -- **ClockStyle**: Codable struct for clock customization settings +- **ClockStyle**: @Observable class for clock customization settings - Time format preferences (24-hour, seconds, AM/PM) - Visual settings (colors, glow, scale, opacity) - Overlay settings (battery, date, opacity) - Background settings -- **Alarm**: Codable struct for alarm data + - Color caching for performance optimization +- **Alarm**: Codable struct for comprehensive alarm data - UUID identifier - 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 - Display name and file name +- **LegacyAlarm**: Backward compatibility struct for old alarm data ### Data Persistence - **AppStorage**: ClockStyle settings persisted as JSON @@ -81,27 +126,36 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di ### Audio System - **AVFoundation**: AVAudioPlayer for noise playback +- **@Observable NoisePlayer**: Modern state management with preloading - **Looping playback**: Infinite loop for ambient sounds +- **Audio session management**: Proper audio session configuration - **Error handling**: Graceful handling of missing audio files +- **AlarmTonePlayer**: Dedicated player for alarm sound previews ### Notification System - **UserNotifications**: iOS notification framework - **Permission handling**: Automatic permission requests -- **Calendar triggers**: Daily alarm scheduling -- **Sound customization**: System sound selection +- **Smart scheduling**: One-time and repeating alarm support +- **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 ### Navigation - **TabView**: Three-tab interface (Clock, Alarms, Noise) - **NavigationStack**: Modern navigation with back button support +- **Navigation destinations**: Deep linking for alarm editing - **Toolbar integration**: Settings and add buttons in navigation bars +- **Sheet presentations**: Modal settings and alarm creation ### Visual Design - **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 - **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 - **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 - **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/ -├── TheNoiseClockApp.swift # App entry point -├── ContentView.swift # Main tab navigation -├── ClockView.swift # Clock display and settings -├── ClockSettingsView.swift # Settings interface -├── ClockStyle.swift # Data model and color utilities -├── AlarmView.swift # Alarm management -├── AddAlarmView.swift # Alarm creation -├── NoiseView.swift # White noise player -├── NoisePlayer.swift # Audio playback logic -├── Sound.swift # Sound data model -└── Resources/ # Audio files - ├── white-noise.mp3 - ├── heavy-rain-white-noise.mp3 - └── fan-white-noise-heater-303207.mp3 +├── App/ +│ ├── TheNoiseClockApp.swift # App entry point and configuration +│ └── ContentView.swift # Main tab navigation coordinator +├── Core/ +│ ├── Constants/ +│ │ ├── AppConstants.swift # App-wide constants and configuration +│ │ ├── UIConstants.swift # UI-specific constants (colors, sizes, etc.) +│ │ └── AudioConstants.swift # Audio-related constants +│ ├── Extensions/ +│ │ ├── Color+Extensions.swift # Color utilities and extensions +│ │ ├── Date+Extensions.swift # Date formatting and utilities +│ │ └── View+Extensions.swift # Common view modifiers +│ └── Utilities/ +│ ├── 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 ### Clock Tab @@ -143,10 +256,18 @@ TheNoiseClock/ 4. **Background**: Set background color and use presets ### Alarms Tab -1. **View alarms**: List of all created alarms -2. **Add alarm**: Tap + button to create new alarm -3. **Toggle alarm**: Use switch to enable/disable -4. **Delete alarm**: Swipe to delete +1. **View alarms**: List of all created alarms with labels and repeat schedules +2. **Add alarm**: Tap + button to create new alarm with full editor +3. **Edit alarm**: Tap any alarm to open comprehensive editor +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 1. **Select sound**: Choose from dropdown menu @@ -156,36 +277,79 @@ TheNoiseClock/ ## Technical Requirements ### iOS Compatibility -- **Minimum iOS version**: iOS 15.0+ (SwiftUI features) -- **Target devices**: iPhone and iPad -- **Orientation support**: Portrait and landscape +- **Minimum iOS version**: iOS 18.0+ (Latest SwiftUI features and performance optimizations) +- **Target devices**: iPhone and iPad with full adaptive layout support +- **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 -- **SwiftUI**: Native iOS UI framework -- **AVFoundation**: Audio playback -- **UserNotifications**: Alarm notifications -- **Combine**: Timer publishers for real-time updates +- **SwiftUI**: Native iOS UI framework with latest features +- **AVFoundation**: Audio playback with modern async patterns +- **UserNotifications**: Alarm notifications with rich content support +- **Combine**: Timer publishers and reactive programming +- **Observation**: Modern state management with @Observable +- **Foundation**: Core system frameworks and utilities ### 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 - **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 +- **Preloaded audio**: Instant sound playback ## Future Enhancement Opportunities - **Additional sound types**: More white noise variants -- **Volume control**: Adjustable playback volume - **Sleep timer**: Auto-stop noise after specified time - **Widget support**: Home screen clock widget - **Apple Watch companion**: Watch app for quick time check - **In-app purchases**: Premium sound packs - **Custom sounds**: User-imported audio files -- **Snooze functionality**: Enhanced alarm features - **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 + +### Project Information - **Created**: September 7, 2025 -- **Framework**: SwiftUI with iOS 15+ target -- **Architecture**: MVVM pattern with SwiftUI -- **Testing**: Includes unit and UI test targets -- **Version control**: Git repository with staged changes +- **Framework**: SwiftUI with iOS 18.0+ target (latest stable features) +- **Architecture**: Modern SwiftUI with @Observable pattern and MVVM +- **Testing**: Comprehensive unit and UI test targets with Swift Testing +- **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 diff --git a/TheNoiseClock/AddAlarmView.swift b/TheNoiseClock/AddAlarmView.swift deleted file mode 100644 index 0cd85d2..0000000 --- a/TheNoiseClock/AddAlarmView.swift +++ /dev/null @@ -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 - ) -} diff --git a/TheNoiseClock/AlarmView.swift b/TheNoiseClock/AlarmView.swift deleted file mode 100644 index 9e54b46..0000000 --- a/TheNoiseClock/AlarmView.swift +++ /dev/null @@ -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 { - 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() -} diff --git a/TheNoiseClock/ContentView.swift b/TheNoiseClock/App/ContentView.swift similarity index 75% rename from TheNoiseClock/ContentView.swift rename to TheNoiseClock/App/ContentView.swift index a2a7fb1..1267fcc 100644 --- a/TheNoiseClock/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -1,6 +1,16 @@ +// +// ContentView.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/7/25. +// + import SwiftUI +/// Main tab navigation coordinator struct ContentView: View { + + // MARK: - Body var body: some View { TabView { NavigationStack { @@ -24,11 +34,12 @@ struct ContentView: View { Label("Noise", systemImage: "waveform") } } - .accentColor(.blue) + .accentColor(UIConstants.Colors.accentColor) .preferredColorScheme(.dark) } } +// MARK: - Preview #Preview { ContentView() } diff --git a/TheNoiseClock/TheNoiseClockApp.swift b/TheNoiseClock/App/TheNoiseClockApp.swift similarity index 79% rename from TheNoiseClock/TheNoiseClockApp.swift rename to TheNoiseClock/App/TheNoiseClockApp.swift index 13b8e3a..3713687 100644 --- a/TheNoiseClock/TheNoiseClockApp.swift +++ b/TheNoiseClock/App/TheNoiseClockApp.swift @@ -7,8 +7,11 @@ import SwiftUI +/// App entry point and configuration @main struct TheNoiseClockApp: App { + + // MARK: - Body var body: some Scene { WindowGroup { ContentView() diff --git a/TheNoiseClock/ClockSettingsView.swift b/TheNoiseClock/ClockSettingsView.swift deleted file mode 100644 index d55166b..0000000 --- a/TheNoiseClock/ClockSettingsView.swift +++ /dev/null @@ -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()) -} diff --git a/TheNoiseClock/ClockView.swift b/TheNoiseClock/ClockView.swift deleted file mode 100644 index 7f40c02..0000000 --- a/TheNoiseClock/ClockView.swift +++ /dev/null @@ -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 - } -} diff --git a/TheNoiseClock/Core/Constants/AppConstants.swift b/TheNoiseClock/Core/Constants/AppConstants.swift new file mode 100644 index 0000000..30a52fd --- /dev/null +++ b/TheNoiseClock/Core/Constants/AppConstants.swift @@ -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"] + } +} diff --git a/TheNoiseClock/Core/Constants/AudioConstants.swift b/TheNoiseClock/Core/Constants/AudioConstants.swift new file mode 100644 index 0000000..a87cbda --- /dev/null +++ b/TheNoiseClock/Core/Constants/AudioConstants.swift @@ -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 + } +} diff --git a/TheNoiseClock/Core/Constants/UIConstants.swift b/TheNoiseClock/Core/Constants/UIConstants.swift new file mode 100644 index 0000000..4e7096f --- /dev/null +++ b/TheNoiseClock/Core/Constants/UIConstants.swift @@ -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) + } +} diff --git a/TheNoiseClock/Core/Extensions/Color+Extensions.swift b/TheNoiseClock/Core/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..db927df --- /dev/null +++ b/TheNoiseClock/Core/Extensions/Color+Extensions.swift @@ -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 + } +} diff --git a/TheNoiseClock/Core/Extensions/Date+Extensions.swift b/TheNoiseClock/Core/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..9a3943a --- /dev/null +++ b/TheNoiseClock/Core/Extensions/Date+Extensions.swift @@ -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 + } + } +} diff --git a/TheNoiseClock/Core/Extensions/View+Extensions.swift b/TheNoiseClock/Core/Extensions/View+Extensions.swift new file mode 100644 index 0000000..698b018 --- /dev/null +++ b/TheNoiseClock/Core/Extensions/View+Extensions.swift @@ -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 diff --git a/TheNoiseClock/Core/Utilities/ColorUtils.swift b/TheNoiseClock/Core/Utilities/ColorUtils.swift new file mode 100644 index 0000000..7c29929 --- /dev/null +++ b/TheNoiseClock/Core/Utilities/ColorUtils.swift @@ -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 + } +} diff --git a/TheNoiseClock/Core/Utilities/NotificationUtils.swift b/TheNoiseClock/Core/Utilities/NotificationUtils.swift new file mode 100644 index 0000000..98ca0dc --- /dev/null +++ b/TheNoiseClock/Core/Utilities/NotificationUtils.swift @@ -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() + } +} diff --git a/TheNoiseClock/Models/Alarm.swift b/TheNoiseClock/Models/Alarm.swift new file mode 100644 index 0000000..b7a5984 --- /dev/null +++ b/TheNoiseClock/Models/Alarm.swift @@ -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) + } +} diff --git a/TheNoiseClock/ClockStyle.swift b/TheNoiseClock/Models/ClockStyle.swift similarity index 69% rename from TheNoiseClock/ClockStyle.swift rename to TheNoiseClock/Models/ClockStyle.swift index 9c01ad3..16513fc 100644 --- a/TheNoiseClock/ClockStyle.swift +++ b/TheNoiseClock/Models/ClockStyle.swift @@ -1,38 +1,41 @@ +// +// ClockStyle.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/7/25. +// + import SwiftUI import Observation +/// Clock customization settings and data model @Observable class ClockStyle: Codable, Equatable { + + // MARK: - Time Format Settings var use24Hour: Bool = true var showSeconds: Bool = false var showAmPmBadge: Bool = false - // Default to white digits - var digitColorHex: String = "#FFFFFF" + // MARK: - Visual Settings + var digitColorHex: String = AppConstants.Defaults.digitColorHex var randomizeColor: Bool = false - - var glowIntensity: Double = 0.6 // 0...1 - // 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 glowIntensity: Double = AppConstants.Defaults.glowIntensity + var digitScale: Double = AppConstants.Defaults.digitScale var stretched: Bool = true + var backgroundHex: String = AppConstants.Defaults.backgroundColorHex - var backgroundHex: String = "#000000" + // MARK: - Overlay Settings var showBattery: 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) - 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 + // MARK: - Cached Colors private var _cachedDigitColor: Color? private var _cachedBackgroundColor: Color? - // Codable keys (persist only these) + // MARK: - Codable Keys private enum CodingKeys: String, CodingKey { case use24Hour case showSeconds @@ -49,11 +52,15 @@ class ClockStyle: Codable, Equatable { case overlayOpacity } - // MARK: - Codable + // MARK: - Initialization + init() { + // Defaults already set in property declarations + } + + // MARK: - Codable Implementation 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) + self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds 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.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity - // Ensure cached colors reflect decoded hex clearColorCache() } - init() { - // Defaults already set in property declarations - } - func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(use24Hour, forKey: .use24Hour) @@ -93,7 +95,7 @@ class ClockStyle: Codable, Equatable { try container.encode(overlayOpacity, forKey: .overlayOpacity) } - // Codable <-> Color helpers with caching + // MARK: - Computed Properties var digitColor: Color { if let cached = _cachedDigitColor { return cached @@ -112,7 +114,7 @@ class ClockStyle: Codable, Equatable { return color } - // Clear cache when colors change + // MARK: - Helper Methods func clearColorCache() { _cachedDigitColor = nil _cachedBackgroundColor = nil @@ -136,34 +138,7 @@ class ClockStyle: Codable, Equatable { } } +// MARK: - Storage Key extension ClockStyle { - static let appStorageKey = "ClockStyle_JSON" -} - -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) - } + static let appStorageKey = AppConstants.StorageKeys.clockStyle } diff --git a/TheNoiseClock/Models/Sound.swift b/TheNoiseClock/Models/Sound.swift new file mode 100644 index 0000000..9d2f6a0 --- /dev/null +++ b/TheNoiseClock/Models/Sound.swift @@ -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) + } +} diff --git a/TheNoiseClock/NoiseView.swift b/TheNoiseClock/NoiseView.swift deleted file mode 100644 index df88370..0000000 --- a/TheNoiseClock/NoiseView.swift +++ /dev/null @@ -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() -} diff --git a/TheNoiseClock/Services/AlarmService.swift b/TheNoiseClock/Services/AlarmService.swift new file mode 100644 index 0000000..81962ee --- /dev/null +++ b/TheNoiseClock/Services/AlarmService.swift @@ -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) + } + } + } +} diff --git a/TheNoiseClock/NoisePlayer.swift b/TheNoiseClock/Services/NoisePlayer.swift similarity index 67% rename from TheNoiseClock/NoisePlayer.swift rename to TheNoiseClock/Services/NoisePlayer.swift index 67af6f0..82b2c48 100644 --- a/TheNoiseClock/NoisePlayer.swift +++ b/TheNoiseClock/Services/NoisePlayer.swift @@ -1,11 +1,22 @@ +// +// NoisePlayer.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/7/25. +// + import AVFoundation import Observation +/// Audio playback service for white noise and ambient sounds @Observable class NoisePlayer { + + // MARK: - Properties private var players: [String: AVAudioPlayer] = [:] private var currentPlayer: AVAudioPlayer? + // MARK: - Initialization init() { setupAudioSession() preloadSounds() @@ -15,40 +26,14 @@ class NoisePlayer { stopAllSounds() } - private func setupAudioSession() { - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers]) - 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)") - } - } + // MARK: - Public Interface + var isPlaying: Bool { + return currentPlayer?.isPlaying ?? false } func playSound(_ sound: Sound) { - // Stop current sound if playing stopSound() - // Get or create player for this sound guard let player = players[sound.fileName] else { print("Sound not preloaded: \(sound.fileName)") return @@ -63,6 +48,40 @@ class NoisePlayer { 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() { for player in players.values { player.stop() @@ -70,8 +89,4 @@ class NoisePlayer { players.removeAll() currentPlayer = nil } - - var isPlaying: Bool { - return currentPlayer?.isPlaying ?? false - } } diff --git a/TheNoiseClock/Services/NotificationService.swift b/TheNoiseClock/Services/NotificationService.swift new file mode 100644 index 0000000..547cf73 --- /dev/null +++ b/TheNoiseClock/Services/NotificationService.swift @@ -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() + } +} diff --git a/TheNoiseClock/Sound.swift b/TheNoiseClock/Sound.swift deleted file mode 100644 index 98f0b51..0000000 --- a/TheNoiseClock/Sound.swift +++ /dev/null @@ -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" -} diff --git a/TheNoiseClock/ViewModels/AlarmViewModel.swift b/TheNoiseClock/ViewModels/AlarmViewModel.swift new file mode 100644 index 0000000..d20d01b --- /dev/null +++ b/TheNoiseClock/ViewModels/AlarmViewModel.swift @@ -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() + } +} diff --git a/TheNoiseClock/ViewModels/ClockViewModel.swift b/TheNoiseClock/ViewModels/ClockViewModel.swift new file mode 100644 index 0000000..34c0ef8 --- /dev/null +++ b/TheNoiseClock/ViewModels/ClockViewModel.swift @@ -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 + } + } +} diff --git a/TheNoiseClock/ViewModels/NoiseViewModel.swift b/TheNoiseClock/ViewModels/NoiseViewModel.swift new file mode 100644 index 0000000..0c244fc --- /dev/null +++ b/TheNoiseClock/ViewModels/NoiseViewModel.swift @@ -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) + } + } +} diff --git a/TheNoiseClock/Views/Alarms/AddAlarmView.swift b/TheNoiseClock/Views/Alarms/AddAlarmView.swift new file mode 100644 index 0000000..379801f --- /dev/null +++ b/TheNoiseClock/Views/Alarms/AddAlarmView.swift @@ -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) + ) +} diff --git a/TheNoiseClock/Views/Alarms/AlarmView.swift b/TheNoiseClock/Views/Alarms/AlarmView.swift new file mode 100644 index 0000000..0a031ea --- /dev/null +++ b/TheNoiseClock/Views/Alarms/AlarmView.swift @@ -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() + } +} diff --git a/TheNoiseClock/Views/Alarms/Components/AlarmRowView.swift b/TheNoiseClock/Views/Alarms/Components/AlarmRowView.swift new file mode 100644 index 0000000..3fd65cc --- /dev/null +++ b/TheNoiseClock/Views/Alarms/Components/AlarmRowView.swift @@ -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: {} + ) + } +} diff --git a/TheNoiseClock/Views/Clock/ClockSettingsView.swift b/TheNoiseClock/Views/Clock/ClockSettingsView.swift new file mode 100644 index 0000000..71d4cc1 --- /dev/null +++ b/TheNoiseClock/Views/Clock/ClockSettingsView.swift @@ -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 } + ) +} diff --git a/TheNoiseClock/Views/Clock/ClockView.swift b/TheNoiseClock/Views/Clock/ClockView.swift new file mode 100644 index 0000000..15fad8e --- /dev/null +++ b/TheNoiseClock/Views/Clock/ClockView.swift @@ -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() + } +} diff --git a/TheNoiseClock/Views/Clock/Components/BatteryOverlayView.swift b/TheNoiseClock/Views/Clock/Components/BatteryOverlayView.swift new file mode 100644 index 0000000..1c85b42 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/BatteryOverlayView.swift @@ -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 + } +} diff --git a/TheNoiseClock/Views/Clock/Components/DateOverlayView.swift b/TheNoiseClock/Views/Clock/Components/DateOverlayView.swift new file mode 100644 index 0000000..dff78d2 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/DateOverlayView.swift @@ -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() + } +} diff --git a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift new file mode 100644 index 0000000..d3d813e --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift @@ -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) + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/TopOverlayView.swift b/TheNoiseClock/Views/Clock/Components/TopOverlayView.swift new file mode 100644 index 0000000..d13542e --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/TopOverlayView.swift @@ -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) + } +} diff --git a/TheNoiseClock/Views/Noise/Components/SoundControlView.swift b/TheNoiseClock/Views/Noise/Components/SoundControlView.swift new file mode 100644 index 0000000..7b987f6 --- /dev/null +++ b/TheNoiseClock/Views/Noise/Components/SoundControlView.swift @@ -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: {} + ) +} diff --git a/TheNoiseClock/Views/Noise/Components/SoundPickerView.swift b/TheNoiseClock/Views/Noise/Components/SoundPickerView.swift new file mode 100644 index 0000000..8a809ba --- /dev/null +++ b/TheNoiseClock/Views/Noise/Components/SoundPickerView.swift @@ -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) + ) +} diff --git a/TheNoiseClock/Views/Noise/NoiseView.swift b/TheNoiseClock/Views/Noise/NoiseView.swift new file mode 100644 index 0000000..dbf70f2 --- /dev/null +++ b/TheNoiseClock/Views/Noise/NoiseView.swift @@ -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() +}