diff --git a/PRD.md b/PRD.md index 7572a37..700e56e 100644 --- a/PRD.md +++ b/PRD.md @@ -50,10 +50,14 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Battery optimization**: Wake lock automatically disabled when exiting display mode ### 4. Information Overlays -- **Battery level display**: Real-time battery percentage with icon +- **Battery level display**: Real-time battery percentage with dynamic icon +- **Battery state awareness**: Shows charging state with lightning bolt icon +- **Battery color coding**: Green (50-100%), Yellow (20-49%), Orange (10-19%), Red (<10%) +- **Charging indicator**: Green lightning bolt icon when device is charging - **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 +- **Battery service integration**: Dedicated BatteryService for monitoring and state management ### 5. White Noise Player - **Multiple sound options**: @@ -61,8 +65,10 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - Heavy Rain White Noise (`heavy-rain-white-noise.mp3`) - Fan White Noise (`fan-white-noise-heater-303207.mp3`) - **Continuous playback**: Sounds loop indefinitely +- **Advanced sound selection**: Category-based grid with search and preview - **Simple controls**: Play/Stop button with visual feedback -- **Sound selection**: Dropdown picker for sound selection +- **Auto-stop on selection**: Automatically stops current sound when selecting new one +- **Sound preview**: 3-second preview on long-press - **JSON-based configuration**: Sound definitions loaded from external configuration - **Bundle organization**: Sounds organized in category-based bundles - **Shared audio player**: Singleton pattern prevents audio conflicts @@ -70,6 +76,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Audio interruption handling**: Automatically resumes after phone calls or route changes - **Wake lock integration**: Prevents device sleep while audio is playing - **Bluetooth audio support**: Works with AirPods and other Bluetooth audio devices +- **Responsive layout**: Optimized for both portrait and landscape orientations ### 6. Advanced Alarm System - **Multiple alarms**: Create and manage unlimited alarms @@ -215,6 +222,16 @@ These principles are fundamental to the project's long-term success and must be - **Focus mode awareness**: Monitors and respects Focus mode settings - **Notification compatibility**: Ensures alarms work with Focus modes enabled +### Battery System +- **@Observable BatteryService**: Modern state management for battery monitoring +- **Real-time monitoring**: Continuous battery level and charging state tracking +- **UIDevice integration**: Native iOS battery monitoring with proper lifecycle management +- **Charging state detection**: Automatic detection of charging/not charging states +- **Color-coded display**: Dynamic color coding based on battery level (green/yellow/orange/red) +- **Icon management**: Dynamic battery icons with charging state indicators +- **Lifecycle management**: Automatic start/stop monitoring based on view visibility +- **Pure view architecture**: BatteryOverlayView is purely presentational with no business logic + ### Notification System - **UserNotifications**: iOS notification framework - **Permission handling**: Automatic permission requests @@ -317,14 +334,15 @@ TheNoiseClock/ │ └── Noise/ │ ├── NoiseView.swift # Main white noise player interface │ └── Components/ -│ ├── SoundPickerView.swift # Sound selection component -│ └── SoundControlView.swift # Playback controls component +│ ├── SoundCategoryView.swift # Advanced grid-based sound selection +│ └── SoundControlView.swift # Playback controls component ├── Services/ │ ├── NoisePlayer.swift # Audio playback service with background support │ ├── AlarmService.swift # Alarm management service with Focus mode integration │ ├── NotificationService.swift # Notification handling service │ ├── WakeLockService.swift # Screen wake lock management service -│ └── FocusModeService.swift # Focus mode integration and notification management +│ ├── FocusModeService.swift # Focus mode integration and notification management +│ └── BatteryService.swift # Battery monitoring and state management service └── Resources/ ├── sounds.json # Sound configuration and definitions ├── Ambient.bundle/ # Ambient sound category @@ -420,9 +438,13 @@ The following changes **automatically require** PRD updates: - Snooze duration settings ### Noise Tab -1. **Select sound**: Choose from dropdown menu -2. **Play/Stop**: Single button to control playback -3. **Continuous playback**: Sounds loop until stopped +1. **Sound Selection**: Browse sounds by category with search functionality +2. **Sound Preview**: Long-press for 3-second preview +3. **Visual Feedback**: Grid layout with clear selection states +4. **Auto-stop**: Automatically stops current sound when selecting new one +5. **Play/Stop Controls**: Simple button with visual feedback +6. **Continuous playback**: Sounds loop until stopped +7. **Responsive layout**: Optimized for portrait and landscape orientations ## Technical Requirements diff --git a/TheNoiseClock/Core/Extensions/View+Extensions.swift b/TheNoiseClock/Core/Extensions/View+Extensions.swift index 007c36e..e7d751a 100644 --- a/TheNoiseClock/Core/Extensions/View+Extensions.swift +++ b/TheNoiseClock/Core/Extensions/View+Extensions.swift @@ -83,6 +83,46 @@ extension View { func onOrientationChange() -> some View { self.modifier(OrientationChangeModifier()) } + + /// Apply standard section title styling + /// - Returns: View with section title styling applied + func sectionTitleStyle() -> some View { + self + .font(.title2.weight(.bold)) + .foregroundColor(UIConstants.Colors.primaryText) + } + + /// Center content horizontally with spacers + /// - Returns: View wrapped in centered HStack + func centered() -> some View { + HStack { + Spacer() + self + Spacer() + } + } + + /// Apply standard section header styling with title + /// - Parameter title: The title text + /// - Returns: View with section header styling + func sectionHeader(title: String) -> some View { + VStack(alignment: .leading, spacing: UIConstants.Spacing.medium) { + Text(title) + .sectionTitleStyle() + self + } + } + + /// Apply standard content padding + /// - Parameters: + /// - horizontal: Horizontal padding amount + /// - vertical: Vertical padding amount + /// - Returns: View with standard padding applied + func contentPadding(horizontal: CGFloat = UIConstants.Spacing.large, vertical: CGFloat? = nil) -> some View { + self + .padding(.horizontal, horizontal) + .padding(.vertical, vertical ?? horizontal) + } } // MARK: - View Modifiers diff --git a/TheNoiseClock/Models/Sound.swift b/TheNoiseClock/Models/Sound.swift index b2ecb27..4a8888f 100644 --- a/TheNoiseClock/Models/Sound.swift +++ b/TheNoiseClock/Models/Sound.swift @@ -12,13 +12,17 @@ struct Sound: Identifiable, Hashable { let id: String let name: String let fileName: String + let category: String + let description: String let bundleName: String? // Optional bundle name for organization // MARK: - Initialization - init(name: String, fileName: String, bundleName: String? = nil) { + init(name: String, fileName: String, category: String, description: String, bundleName: String? = nil) { self.id = fileName // Use fileName as stable identifier self.name = name self.fileName = fileName + self.category = category + self.description = description self.bundleName = bundleName } diff --git a/TheNoiseClock/Models/SoundConfiguration.swift b/TheNoiseClock/Models/SoundConfiguration.swift index 080db8a..03b88ff 100644 --- a/TheNoiseClock/Models/SoundConfiguration.swift +++ b/TheNoiseClock/Models/SoundConfiguration.swift @@ -25,7 +25,7 @@ struct SoundConfig: Codable, Identifiable { /// Convert to Sound model for compatibility func toSound() -> Sound { - return Sound(name: name, fileName: fileName, bundleName: bundleName) + return Sound(name: name, fileName: fileName, category: category, description: description, bundleName: bundleName) } } @@ -134,17 +134,17 @@ class SoundConfigurationService { private func getFallbackSounds() -> [Sound] { return [ // White noise sounds - Sound(name: "White Noise", fileName: "white-noise.mp3", bundleName: "Ambient"), - Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3", bundleName: "Nature"), - Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater.mp3", bundleName: "Mechanical"), + Sound(name: "White Noise", fileName: "white-noise.mp3", category: "ambient", description: "Classic white noise for focus and relaxation", bundleName: "Ambient"), + Sound(name: "Heavy Rain White Noise", fileName: "heavy-rain-white-noise.mp3", category: "nature", description: "Heavy rainfall sounds for peaceful sleep", bundleName: "Nature"), + Sound(name: "Fan White Noise", fileName: "fan-white-noise-heater.mp3", category: "mechanical", description: "Fan and heater sounds for consistent background noise", bundleName: "Mechanical"), // Alarm sounds - Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", bundleName: "AlarmSounds"), - Sound(name: "iPhone Alarm", fileName: "iphone-alarm.mp3", bundleName: "AlarmSounds"), - Sound(name: "Classic Alarm", fileName: "classic-alarm.mp3", bundleName: "AlarmSounds"), - Sound(name: "Beep Alarm", fileName: "beep-alarm.mp3", bundleName: "AlarmSounds"), - Sound(name: "Siren Alarm", fileName: "siren-alarm.mp3", bundleName: "AlarmSounds"), - Sound(name: "Voice Wake Up", fileName: "voice-wakeup.mp3", bundleName: "AlarmSounds") + Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Classic digital alarm sound", bundleName: "AlarmSounds"), + Sound(name: "iPhone Alarm", fileName: "iphone-alarm.mp3", category: "alarm", description: "iPhone-style alarm sound", bundleName: "AlarmSounds"), + Sound(name: "Classic Alarm", fileName: "classic-alarm.mp3", category: "alarm", description: "Traditional alarm sound", bundleName: "AlarmSounds"), + Sound(name: "Beep Alarm", fileName: "beep-alarm.mp3", category: "alarm", description: "Short beep alarm sound", bundleName: "AlarmSounds"), + Sound(name: "Siren Alarm", fileName: "siren-alarm.mp3", category: "alarm", description: "Emergency siren alarm for heavy sleepers", bundleName: "AlarmSounds"), + Sound(name: "Voice Wake Up", fileName: "voice-wakeup.mp3", category: "alarm", description: "Voice-based wake up sound", bundleName: "AlarmSounds") ] } } \ No newline at end of file diff --git a/TheNoiseClock/Services/BatteryService.swift b/TheNoiseClock/Services/BatteryService.swift index 08c45fe..ee529b3 100644 --- a/TheNoiseClock/Services/BatteryService.swift +++ b/TheNoiseClock/Services/BatteryService.swift @@ -19,7 +19,7 @@ class BatteryService { var batteryLevel: Int = 100 var isCharging: Bool = false - private var cancellables = Set() + @ObservationIgnored private var cancellables = Set() // MARK: - Initialization private init() { diff --git a/TheNoiseClock/ViewModels/NoiseViewModel.swift b/TheNoiseClock/ViewModels/NoiseViewModel.swift index 85553f8..ceea79c 100644 --- a/TheNoiseClock/ViewModels/NoiseViewModel.swift +++ b/TheNoiseClock/ViewModels/NoiseViewModel.swift @@ -14,6 +14,8 @@ class NoiseViewModel { // MARK: - Properties private let noisePlayer: NoisePlayer + var isPreviewing: Bool = false + var previewSound: Sound? var isPlaying: Bool { noisePlayer.isPlaying @@ -37,11 +39,38 @@ class NoiseViewModel { noisePlayer.stopSound() } - func togglePlayback(for sound: Sound) { + func selectSound(_ sound: Sound) { + // Stop any current playback when selecting a new sound if isPlaying { stopSound() - } else { - playSound(sound) + } + // Stop any preview + stopPreview() + } + + // MARK: - Preview Functionality + func previewSound(_ sound: Sound) { + // Stop any current preview + stopPreview() + + // Set preview state + previewSound = sound + isPreviewing = true + + // Play preview (3 seconds) + noisePlayer.playSound(sound) + + // Auto-stop preview after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + self.stopPreview() + } + } + + func stopPreview() { + if isPreviewing { + noisePlayer.stopSound() + isPreviewing = false + previewSound = nil } } } diff --git a/TheNoiseClock/Views/Clock/Components/BatteryOverlayView.swift b/TheNoiseClock/Views/Clock/Components/BatteryOverlayView.swift index 7dd49fa..b6bdc7e 100644 --- a/TheNoiseClock/Views/Clock/Components/BatteryOverlayView.swift +++ b/TheNoiseClock/Views/Clock/Components/BatteryOverlayView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import Combine /// Component for displaying battery level overlay struct BatteryOverlayView: View { diff --git a/TheNoiseClock/Views/Noise/Components/SoundCategoryView.swift b/TheNoiseClock/Views/Noise/Components/SoundCategoryView.swift new file mode 100644 index 0000000..2047591 --- /dev/null +++ b/TheNoiseClock/Views/Noise/Components/SoundCategoryView.swift @@ -0,0 +1,265 @@ +// +// SoundCategoryView.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/7/25. +// + +import SwiftUI + +/// Category-based sound selection view with grid layout +struct SoundCategoryView: View { + + // MARK: - Properties + let sounds: [Sound] + @Binding var selectedSound: Sound? + @State private var selectedCategory: String = "all" + @State private var searchText: String = "" + @State private var viewModel = NoiseViewModel() + + // MARK: - Computed Properties + private var filteredSounds: [Sound] { + let nonAlarmSounds = sounds.filter { $0.category != "alarm" } + + let categoryFiltered = selectedCategory == "all" + ? nonAlarmSounds + : nonAlarmSounds.filter { $0.category == selectedCategory } + + if searchText.isEmpty { + return categoryFiltered + } else { + return categoryFiltered.filter { sound in + sound.name.localizedCaseInsensitiveContains(searchText) || + sound.description.localizedCaseInsensitiveContains(searchText) + } + } + } + + private var categories: [String] { + let nonAlarmSounds = sounds.filter { $0.category != "alarm" } + let uniqueCategories = Set(nonAlarmSounds.map { $0.category }) + return ["all"] + Array(uniqueCategories).sorted() + } + + private var categoryDisplayName: (String) -> String { + return { category in + switch category { + case "all": return "All" + case "ambient": return "Ambient" + case "nature": return "Nature" + case "mechanical": return "Mechanical" + default: return category.capitalized + } + } + } + + // MARK: - Body + var body: some View { + VStack(spacing: UIConstants.Spacing.medium) { + // Search Bar + searchBar + + // Category Tabs + categoryTabs + + // Scrollable Sound Grid + ScrollView { + soundGrid + } + } + .padding(.top, UIConstants.Spacing.medium) + } + + // MARK: - Subviews + private var searchBar: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search sounds...", text: $searchText) + .textFieldStyle(.plain) + } + .padding(.horizontal, UIConstants.Spacing.medium) + .padding(.vertical, UIConstants.Spacing.small) + .background(Color(.systemGray6)) + .cornerRadius(10) + } + + private var categoryTabs: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: UIConstants.Spacing.small) { + ForEach(categories, id: \.self) { category in + CategoryTab( + title: categoryDisplayName(category), + isSelected: selectedCategory == category, + count: category == "all" ? filteredSounds.count : sounds.filter { $0.category == category && $0.category != "alarm" }.count + ) { + selectedCategory = category + } + } + } + .padding(.horizontal, UIConstants.Spacing.medium) + } + } + + private var soundGrid: some View { + LazyVStack(spacing: UIConstants.Spacing.small) { + ForEach(filteredSounds) { sound in + SoundCard( + sound: sound, + isSelected: selectedSound?.id == sound.id, + isPlaying: viewModel.isPlaying && selectedSound?.id == sound.id, + isPreviewing: viewModel.isPreviewing && viewModel.previewSound?.id == sound.id, + onSelect: { + selectedSound = sound + }, + onPreview: { + viewModel.previewSound(sound) + } + ) + } + } + .padding(.horizontal, UIConstants.Spacing.medium) + } +} + +// MARK: - Supporting Views +struct CategoryTab: View { + let title: String + let isSelected: Bool + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + + if count > 0 { + Text("(\(count))") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, UIConstants.Spacing.medium) + .padding(.vertical, UIConstants.Spacing.small) + .background(isSelected ? UIConstants.Colors.accentColor : Color(.systemGray6)) + .foregroundColor(isSelected ? .white : UIConstants.Colors.primaryText) + .cornerRadius(20) + } + .buttonStyle(.plain) + } +} + +struct SoundCard: View { + let sound: Sound + let isSelected: Bool + let isPlaying: Bool + let isPreviewing: Bool + let onSelect: () -> Void + let onPreview: () -> Void + + var body: some View { + HStack(spacing: UIConstants.Spacing.medium) { + // Sound Icon (Left) + ZStack { + Circle() + .fill(isSelected ? UIConstants.Colors.accentColor : Color(.systemGray5)) + .frame(width: 50, height: 50) + + Image(systemName: soundIcon) + .font(.title3) + .foregroundColor(isSelected ? .white : UIConstants.Colors.primaryText) + + if isPlaying { + Circle() + .stroke(UIConstants.Colors.accentColor, lineWidth: 2) + .frame(width: 58, height: 58) + .scaleEffect(1.05) + .animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: isPlaying) + } + + if isPreviewing { + Circle() + .stroke(Color.orange, lineWidth: 2) + .frame(width: 58, height: 58) + .scaleEffect(1.02) + .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing) + } + } + + // Sound Info (Right) + VStack(alignment: .leading, spacing: 4) { + // Sound Name + Text(sound.name) + .font(.subheadline.weight(.medium)) + .foregroundColor(UIConstants.Colors.primaryText) + .lineLimit(1) + + // Description + Text(sound.description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + + // Category Badge + HStack { + Text(sound.category.capitalized) + .font(.caption2.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(UIConstants.Colors.accentColor, in: Capsule()) + + Spacer() + } + } + + Spacer() + } + .padding(.horizontal, UIConstants.Spacing.medium) + .padding(.vertical, UIConstants.Spacing.small) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? UIConstants.Colors.accentColor.opacity(0.1) : Color(.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? UIConstants.Colors.accentColor : Color.clear, lineWidth: 2) + ) + ) + .onTapGesture { + onSelect() + } + .onLongPressGesture { + onPreview() + } + } + + private var soundIcon: String { + switch sound.category { + case "ambient": + return "waveform" + case "nature": + return "cloud.rain" + case "mechanical": + return "fan" + default: + return "speaker.wave.2" + } + } +} + +// MARK: - Preview +#Preview { + SoundCategoryView( + sounds: [ + Sound(name: "White Noise", fileName: "white-noise.mp3", category: "ambient", description: "Classic white noise"), + Sound(name: "Heavy Rain", fileName: "heavy-rain.mp3", category: "nature", description: "Heavy rainfall sounds"), + Sound(name: "Fan Noise", fileName: "fan-noise.mp3", category: "mechanical", description: "Fan sounds"), + Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Alarm sound") + ], + selectedSound: .constant(nil) + ) + .padding() +} diff --git a/TheNoiseClock/Views/Noise/Components/SoundControlView.swift b/TheNoiseClock/Views/Noise/Components/SoundControlView.swift index 7b987f6..e37aeb5 100644 --- a/TheNoiseClock/Views/Noise/Components/SoundControlView.swift +++ b/TheNoiseClock/Views/Noise/Components/SoundControlView.swift @@ -18,7 +18,36 @@ struct SoundControlView: View { // MARK: - Body var body: some View { - HStack { + VStack(spacing: UIConstants.Spacing.medium) { + // Sound info header + if let sound = selectedSound { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(sound.name) + .font(.headline.weight(.semibold)) + .foregroundColor(UIConstants.Colors.primaryText) + + Text(sound.description) + .font(.caption) + .foregroundColor(UIConstants.Colors.secondaryText) + .lineLimit(2) + } + + Spacer() + + // Category badge + Text(sound.category.capitalized) + .font(.caption2.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(UIConstants.Colors.accentColor, in: Capsule()) + } + .contentPadding(horizontal: UIConstants.Spacing.medium, vertical: UIConstants.Spacing.small) + .background(UIConstants.Colors.overlayBackground, in: RoundedRectangle(cornerRadius: UIConstants.CornerRadius.medium)) + } + + // Main control button Button(action: { if isPlaying { onStop() @@ -26,24 +55,69 @@ struct SoundControlView: View { onPlay(sound) } }) { - Text(isPlaying ? "Stop" : "Play") - .font(.headline) - .foregroundColor(.white) + HStack(spacing: UIConstants.Spacing.small) { + Image(systemName: isPlaying ? "stop.fill" : "play.fill") + .font(.title2.weight(.semibold)) + .foregroundColor(.white) + + Text(isPlaying ? "Stop Sound" : "Play Sound") + .font(.headline.weight(.semibold)) + .foregroundColor(.white) + } + .contentPadding(horizontal: UIConstants.Spacing.large, vertical: UIConstants.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: UIConstants.CornerRadius.large) + .fill(isPlaying ? Color.red : UIConstants.Colors.accentColor) + .shadow(color: (isPlaying ? Color.red : UIConstants.Colors.accentColor).opacity(0.3), radius: 8, x: 0, y: 4) + ) } - .buttonStyle( - isEnabled: selectedSound != nil, - color: isPlaying ? UIConstants.Colors.accentColor : .green - ) + .disabled(selectedSound == nil) + .scaleEffect(selectedSound == nil ? 0.95 : 1.0) + .opacity(selectedSound == nil ? 0.6 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isPlaying) + .animation(.easeInOut(duration: 0.2), value: selectedSound) } + .frame(maxWidth: 400) // Reasonable max width for iPad + .padding(UIConstants.Spacing.medium) + .background(UIConstants.Colors.overlayBackground, in: RoundedRectangle(cornerRadius: UIConstants.CornerRadius.large)) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.CornerRadius.large) + .stroke(UIConstants.Colors.overlayBorder, lineWidth: UIConstants.BorderWidth.normal) + ) } } // MARK: - Preview -#Preview { +#Preview("Not Playing") { SoundControlView( isPlaying: false, - selectedSound: Sound(name: "White Noise", fileName: "white-noise.mp3"), + selectedSound: Sound( + name: "White Noise", + fileName: "white-noise.mp3", + category: "ambient", + description: "Classic white noise for focus and relaxation", + bundleName: "Ambient" + ), onPlay: { _ in }, onStop: {} ) + .padding() + .background(Color.black) +} + +#Preview("Playing") { + SoundControlView( + isPlaying: true, + selectedSound: Sound( + name: "Heavy Rain", + fileName: "heavy-rain-white-noise.mp3", + category: "nature", + description: "Soothing rain sounds for deep relaxation and sleep", + bundleName: "Nature" + ), + onPlay: { _ in }, + onStop: {} + ) + .padding() + .background(Color.black) } diff --git a/TheNoiseClock/Views/Noise/Components/SoundPickerView.swift b/TheNoiseClock/Views/Noise/Components/SoundPickerView.swift deleted file mode 100644 index d1bc3a5..0000000 --- a/TheNoiseClock/Views/Noise/Components/SoundPickerView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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: Binding( - get: { selectedSound?.fileName }, - set: { fileName in - selectedSound = sounds.first { $0.fileName == fileName } - } - )) { - Text("Choose a sound").tag(nil as String?) - ForEach(sounds) { sound in - Text(sound.name).tag(sound.fileName as String?) - } - } - .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 index dbf70f2..5cb2159 100644 --- a/TheNoiseClock/Views/Noise/NoiseView.swift +++ b/TheNoiseClock/Views/Noise/NoiseView.swift @@ -12,34 +12,117 @@ struct NoiseView: View { // MARK: - Properties @State private var viewModel = NoiseViewModel() - @State private var selectedSound: Sound? + @State private var selectedSound: Sound? { + didSet { + // Stop current playback when selecting a new sound + if let newSound = selectedSound, newSound != oldValue { + viewModel.selectSound(newSound) + } + } + } // MARK: - Body var body: some View { - VStack(spacing: UIConstants.Spacing.large) { - Text("White/Pink Noise") - .font(.headline) - .foregroundColor(UIConstants.Colors.primaryText) + GeometryReader { geometry in + let isLandscape = geometry.size.width > geometry.size.height - 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 + if isLandscape { + // Landscape layout: Player on left, sounds on right + landscapeLayout + } else { + // Portrait layout: Stacked vertically + portraitLayout + } } - .padding(UIConstants.Spacing.large) + .animation(.easeInOut(duration: 0.3), value: selectedSound) + } + + // MARK: - Computed Properties + private var soundControlView: some View { + SoundControlView( + isPlaying: viewModel.isPlaying, + selectedSound: selectedSound, + onPlay: { sound in + viewModel.playSound(sound) + }, + onStop: { + viewModel.stopSound() + } + ) + .transition(.opacity.combined(with: .scale)) + } + + // MARK: - Layouts + private var portraitLayout: some View { + VStack(spacing: 0) { + // Fixed header + VStack(alignment: .leading, spacing: UIConstants.Spacing.medium) { + Text("Ambient Sounds") + .sectionTitleStyle() + + // Playback controls - always visible when sound is selected + if selectedSound != nil { + soundControlView + .centered() + } + } + .contentPadding(horizontal: UIConstants.Spacing.large) + .padding(.top, UIConstants.Spacing.large) + .background(Color(.systemBackground)) + + // Scrollable sound selection + ScrollView { + SoundCategoryView( + sounds: viewModel.availableSounds, + selectedSound: $selectedSound + ) + .contentPadding(horizontal: UIConstants.Spacing.large, vertical: UIConstants.Spacing.large) + } + } + } + + private var landscapeLayout: some View { + HStack(spacing: UIConstants.Spacing.large) { + // Left side: Player controls + VStack(alignment: .leading, spacing: UIConstants.Spacing.medium) { + Text("Ambient Sounds") + .sectionTitleStyle() + + if selectedSound != nil { + soundControlView + } else { + // Placeholder when no sound selected + VStack(spacing: UIConstants.Spacing.small) { + Image(systemName: "music.note") + .font(.largeTitle) + .foregroundColor(.secondary) + + Text("Select a sound to begin") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, UIConstants.Spacing.large) + } + + Spacer() + } + .frame(maxWidth: 400) // Reasonable width for player section + .contentPadding(horizontal: UIConstants.Spacing.large) + .padding(.top, UIConstants.Spacing.large) + + // Right side: Sound selection + VStack(spacing: 0) { + ScrollView { + SoundCategoryView( + sounds: viewModel.availableSounds, + selectedSound: $selectedSound + ) + .contentPadding(horizontal: UIConstants.Spacing.large, vertical: UIConstants.Spacing.large) + } + } + } + .contentPadding(horizontal: UIConstants.Spacing.medium) } }