diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..a2f078b --- /dev/null +++ b/PRD.md @@ -0,0 +1,191 @@ +# TheNoiseClock - Product Requirements Document + +## Overview +TheNoiseClock is a SwiftUI-based iOS application that combines a customizable digital clock display with white noise playback and alarm functionality. The app is designed with a dark theme and focuses on providing a clean, distraction-free interface for time display and ambient sound management. + +## Core Features + +### 1. Digital Clock Display +- **Real-time clock** with automatic updates every second +- **Customizable time format**: 12-hour or 24-hour display +- **Optional seconds display** with toggle control +- **AM/PM badge** for 12-hour format (optional) +- **Segmented time display** with colon separators that adapt to orientation +- **Dynamic scaling** that fits available screen space +- **Portrait and landscape orientation support** + +### 2. Clock Customization +- **Color customization**: User-selectable digit colors with color picker +- **Background color**: Customizable background with color picker +- **Glow effects**: Adjustable glow intensity (0-100%) +- **Size control**: Manual scaling (0-100%) or auto-fit mode +- **Opacity controls**: Separate opacity for clock digits and overlays +- **Random color mode**: Automatically changes digit color every minute +- **Preset themes**: Quick "Night" (black/white) and "Day" (white/black) themes + +### 3. Display Modes +- **Normal mode**: Standard interface with navigation and settings +- **Display mode**: Full-screen clock activated by long-press (0.6 seconds) +- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode +- **Smooth transitions**: Animated transitions between modes + +### 4. Information Overlays +- **Battery level display**: Real-time battery percentage with icon +- **Date display**: Current date in "d MMMM EEE" format (e.g., "7 September Mon") +- **Overlay opacity control**: Independent opacity for battery/date overlays +- **Automatic updates**: Battery and date update in real-time + +### 5. White Noise Player +- **Multiple sound options**: + - White Noise (`white-noise.mp3`) + - Heavy Rain White Noise (`heavy-rain-white-noise.mp3`) + - Fan White Noise (`fan-white-noise-heater-303207.mp3`) +- **Continuous playback**: Sounds loop indefinitely +- **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 + +## Technical Architecture + +### 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 +- **Dark theme**: Preferred color scheme set to dark + +### Data Models +- **ClockStyle**: Codable struct 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 + - UUID identifier + - Time and enabled state + - Sound name +- **Sound**: Simple struct for noise file management + - Display name and file name + +### Data Persistence +- **AppStorage**: ClockStyle settings persisted as JSON +- **UserDefaults**: Alarm data persisted as JSON +- **Bundle resources**: Audio files stored in app bundle + +### Audio System +- **AVFoundation**: AVAudioPlayer for noise playback +- **Looping playback**: Infinite loop for ambient sounds +- **Error handling**: Graceful handling of missing audio files + +### Notification System +- **UserNotifications**: iOS notification framework +- **Permission handling**: Automatic permission requests +- **Calendar triggers**: Daily alarm scheduling +- **Sound customization**: System sound selection + +## User Interface Design + +### Navigation +- **TabView**: Three-tab interface (Clock, Alarms, Noise) +- **NavigationStack**: Modern navigation with back button support +- **Toolbar integration**: Settings and add buttons in navigation bars + +### Visual Design +- **Rounded corners**: Modern iOS design language +- **Smooth animations**: 0.28-second easeInOut transitions +- **Color consistency**: Blue accent color throughout +- **Accessibility**: Proper labels and hidden decorative elements + +### Settings Interface +- **Form-based layout**: Organized sections for different setting categories +- **Interactive controls**: Toggles, sliders, color pickers +- **Real-time updates**: Changes apply immediately +- **Sheet presentation**: Modal settings with detents + +## File Structure +``` +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 +``` + +## Key User Interactions + +### Clock Tab +1. **View time**: Real-time clock display +2. **Access settings**: Tap gear icon in navigation bar +3. **Enter display mode**: Long-press anywhere on clock (0.6 seconds) +4. **Exit display mode**: Long-press again to return to normal mode + +### Settings +1. **Time format**: Toggle 24-hour, seconds, AM/PM display +2. **Appearance**: Adjust colors, glow, size, opacity +3. **Overlays**: Control battery and date display +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 + +### Noise Tab +1. **Select sound**: Choose from dropdown menu +2. **Play/Stop**: Single button to control playback +3. **Continuous playback**: Sounds loop until stopped + +## Technical Requirements + +### iOS Compatibility +- **Minimum iOS version**: iOS 15.0+ (SwiftUI features) +- **Target devices**: iPhone and iPad +- **Orientation support**: Portrait and landscape + +### Dependencies +- **SwiftUI**: Native iOS UI framework +- **AVFoundation**: Audio playback +- **UserNotifications**: Alarm notifications +- **Combine**: Timer publishers for real-time updates + +### Performance Considerations +- **Efficient timers**: Separate timers for seconds and minutes +- **Memory management**: Proper cleanup of audio players +- **Battery optimization**: Efficient update mechanisms +- **Smooth animations**: Hardware-accelerated transitions + +## 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 + +## Development Notes +- **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 diff --git a/TheNoiseClock/AddAlarmView.swift b/TheNoiseClock/AddAlarmView.swift new file mode 100644 index 0000000..71e9caa --- /dev/null +++ b/TheNoiseClock/AddAlarmView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +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 { + AddAlarmView(alarms: .constant([]), systemSounds: ["default", "bell", "chimes"], newAlarmTime: .constant(Date()), selectedSoundName: .constant("default"), showAddAlarm: .constant(true)) +} + diff --git a/TheNoiseClock/AlarmView.swift b/TheNoiseClock/AlarmView.swift new file mode 100644 index 0000000..961cf9e --- /dev/null +++ b/TheNoiseClock/AlarmView.swift @@ -0,0 +1,163 @@ +import SwiftUI +import UserNotifications + +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 showAddAlarm = false + @State private var newAlarmTime = Date() + @State private var selectedSoundName = "default" + + 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: { + print("Tapped + button, showing Add Alarm sheet") + showAddAlarm = true + newAlarmTime = Date() + selectedSoundName = "default" + }) { + Image(systemName: "plus") + .font(.title2) + } + } + } + .onAppear(perform: loadAlarms) + .onChange(of: alarms) { _ in + 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 = alarms.firstIndex(where: { $0.id == alarm.id }) else { + print("Binding error: Alarm \(alarm.id) not found") + return .constant(false) + } + print("Binding created for alarm \(alarm.id) at index \(index), isEnabled: \(alarms[index].isEnabled)") + return Binding( + get: { alarms[index].isEnabled }, + set: { newValue in + print("Setting isEnabled to \(newValue) for alarm \(alarm.id)") + var updatedAlarm = alarms[index] + updatedAlarm.isEnabled = newValue + alarms[index] = updatedAlarm // Update array to trigger UI refresh + updateAlarmNotification(alarm: updatedAlarm) + saveAlarms() + } + ) + } + + private func deleteAlarm(at offsets: IndexSet) { + print("Delete triggered for offsets: \(offsets)") + let indices = offsets.map { $0 } + guard !indices.isEmpty, indices.max()! < alarms.count else { + print("Invalid delete offset") + return + } + alarms.remove(atOffsets: offsets) + updateAllNotifications() + saveAlarms() + print("Alarm(s) deleted") + } + + 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)") + } else { + print("Notification scheduled for \(alarm.id)") + } + } + } else { + print("Notification disabled for \(alarm.id)") + } + } + + private func updateAllNotifications() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + for alarm in alarms where alarm.isEnabled { + updateAlarmNotification(alarm: alarm) + } + } + + private func saveAlarms() { + if let encoded = try? JSONEncoder().encode(alarms) { + UserDefaults.standard.set(encoded, forKey: "SavedAlarms") + print("Alarms saved: \(alarms.count)") + } else { + print("Failed to encode alarms") + } + } + + private func loadAlarms() { + if let savedAlarms = UserDefaults.standard.data(forKey: "SavedAlarms"), + let decodedAlarms = try? JSONDecoder().decode([Alarm].self, from: savedAlarms) { + alarms = decodedAlarms + updateAllNotifications() + print("Alarms loaded: \(alarms.count)") + } else { + print("No saved alarms or decode failed") + } + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in + if let error = error { + print("Authorization error: \(error)") + } else { + print("Authorization granted: \(success)") + } + } + } +} + +#Preview { + AlarmView() +} diff --git a/TheNoiseClock/ClockSettingsView.swift b/TheNoiseClock/ClockSettingsView.swift new file mode 100644 index 0000000..5675610 --- /dev/null +++ b/TheNoiseClock/ClockSettingsView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct ClockSettingsView: View { + @Binding 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" + onCommit() + } + .onChange(of: backgroundColor) { newValue in + style.backgroundHex = newValue.toHex() ?? "#000000" + onCommit() + } + } + } +} + +#Preview { + ClockSettingsView(style: .constant(ClockStyle())) +} diff --git a/TheNoiseClock/ClockStyle.swift b/TheNoiseClock/ClockStyle.swift new file mode 100644 index 0000000..d4ec16c --- /dev/null +++ b/TheNoiseClock/ClockStyle.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct ClockStyle: Codable, Equatable { + var use24Hour: Bool = true + var showSeconds: Bool = false + var showAmPmBadge: Bool = false + + // Default to white digits + var digitColorHex: String = "#FFFFFF" + 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 stretched: Bool = true + + var backgroundHex: String = "#000000" + var showBattery: Bool = true + var showDate: Bool = true + + // 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 + + // Codable <-> Color helpers + var digitColor: Color { + Color(hex: digitColorHex) ?? .white + } + var backgroundColor: Color { + Color(hex: backgroundHex) ?? .black + } +} + +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) + } +} diff --git a/TheNoiseClock/ClockView.swift b/TheNoiseClock/ClockView.swift new file mode 100644 index 0000000..6e4a10c --- /dev/null +++ b/TheNoiseClock/ClockView.swift @@ -0,0 +1,512 @@ +import SwiftUI +import Combine + +struct ClockView: View { + @State private var currentTime = Date() + private let secondTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private let minuteTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + // 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 = ClockStyle() + @State private var showSettings = false + + // Display mode (full-screen clock) + @State private var isDisplayMode = false + + 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(.easeInOut(duration: 0.28), 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() + // Ensure correct tab bar visibility if we reappear while in display mode + setTabBarHidden(isDisplayMode, animated: false) + } + .onDisappear { + // Restore tab bar when leaving this screen + setTabBarHidden(false, animated: false) + } + .onReceive(secondTimer) { now in + currentTime = now + } + .onReceive(minuteTimer) { _ in + guard style.randomizeColor else { return } + style.digitColorHex = Self.randomBrightColorHex() + saveStyle() + } + .sheet(isPresented: $showSettings) { + ClockSettingsView(style: $style, onCommit: saveStyle) + .presentationDetents([.medium, .large]) + } + .onChange(of: style) { _ in + saveStyle() + } + // Long-press anywhere to toggle display mode + .contentShape(Rectangle()) + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.6) + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.28)) { + 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() + } + } + + private func saveStyle() { + if let data = try? JSONEncoder().encode(style) { + styleJSON = data + } + } + + 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 + let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold) + let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold) + let hourSize = measure(text: hour, font: digitUIFont) + let minuteSize = measure(text: minute, font: digitUIFont) + let secondsSize = showSeconds ? measure(text: secondsText, font: digitUIFont) : .zero + let ampmSize = showAMPM ? measure(text: 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) + + // 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(.easeInOut(duration: 0.28), 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) + } +} + +// 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/ContentView.swift b/TheNoiseClock/ContentView.swift deleted file mode 100644 index b433e1a..0000000 --- a/TheNoiseClock/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// TheNoiseClock -// -// Created by Matt Bruce on 9/7/25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/TheNoiseClock/NoisePlayer.swift b/TheNoiseClock/NoisePlayer.swift new file mode 100644 index 0000000..82e017f --- /dev/null +++ b/TheNoiseClock/NoisePlayer.swift @@ -0,0 +1,23 @@ +import AVFoundation + +class NoisePlayer { + var player: AVAudioPlayer? + + func playSound(_ sound: Sound) { + guard let url = Bundle.main.url(forResource: sound.fileName, withExtension: nil) else { + print("Sound file not found: \(sound.fileName)") + return + } + do { + player = try AVAudioPlayer(contentsOf: url) + player?.numberOfLoops = -1 // Loop indefinitely + player?.play() + } catch { + print("Error playing sound: \(error)") + } + } + + func stopSound() { + player?.stop() + } +} diff --git a/TheNoiseClock/NoiseView.swift b/TheNoiseClock/NoiseView.swift new file mode 100644 index 0000000..60f1d96 --- /dev/null +++ b/TheNoiseClock/NoiseView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct NoiseView: View { + let 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.mp3") + // Add more sounds here, matching your bundled MP3s + ] + + @State private var selectedSound: Sound? + @State private var isPlaying = false + + 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(isPlaying ? "Stop" : "Play") { + if isPlaying { + player.stopSound() + } else if let sound = selectedSound { + player.playSound(sound) + } + isPlaying.toggle() + } + .padding() + .background(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/Resources/fan-white-noise-heater-303207.mp3 b/TheNoiseClock/Resources/fan-white-noise-heater-303207.mp3 new file mode 100644 index 0000000..9fea7d1 Binary files /dev/null and b/TheNoiseClock/Resources/fan-white-noise-heater-303207.mp3 differ diff --git a/TheNoiseClock/Resources/heavy-rain-white-noise.mp3 b/TheNoiseClock/Resources/heavy-rain-white-noise.mp3 new file mode 100644 index 0000000..c7641df Binary files /dev/null and b/TheNoiseClock/Resources/heavy-rain-white-noise.mp3 differ diff --git a/TheNoiseClock/Resources/white-noise.mp3 b/TheNoiseClock/Resources/white-noise.mp3 new file mode 100644 index 0000000..2b24fd3 Binary files /dev/null and b/TheNoiseClock/Resources/white-noise.mp3 differ diff --git a/TheNoiseClock/Sound.swift b/TheNoiseClock/Sound.swift new file mode 100644 index 0000000..98f0b51 --- /dev/null +++ b/TheNoiseClock/Sound.swift @@ -0,0 +1,7 @@ +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" +}