Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
2804d45e86
commit
5b21d81fe3
38
PRD.md
38
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
|
- **Battery optimization**: Wake lock automatically disabled when exiting display mode
|
||||||
|
|
||||||
### 4. Information Overlays
|
### 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")
|
- **Date display**: Current date in "d MMMM EEE" format (e.g., "7 September Mon")
|
||||||
- **Overlay opacity control**: Independent opacity for battery/date overlays
|
- **Overlay opacity control**: Independent opacity for battery/date overlays
|
||||||
- **Automatic updates**: Battery and date update in real-time
|
- **Automatic updates**: Battery and date update in real-time
|
||||||
|
- **Battery service integration**: Dedicated BatteryService for monitoring and state management
|
||||||
|
|
||||||
### 5. White Noise Player
|
### 5. White Noise Player
|
||||||
- **Multiple sound options**:
|
- **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`)
|
- Heavy Rain White Noise (`heavy-rain-white-noise.mp3`)
|
||||||
- Fan White Noise (`fan-white-noise-heater-303207.mp3`)
|
- Fan White Noise (`fan-white-noise-heater-303207.mp3`)
|
||||||
- **Continuous playback**: Sounds loop indefinitely
|
- **Continuous playback**: Sounds loop indefinitely
|
||||||
|
- **Advanced sound selection**: Category-based grid with search and preview
|
||||||
- **Simple controls**: Play/Stop button with visual feedback
|
- **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
|
- **JSON-based configuration**: Sound definitions loaded from external configuration
|
||||||
- **Bundle organization**: Sounds organized in category-based bundles
|
- **Bundle organization**: Sounds organized in category-based bundles
|
||||||
- **Shared audio player**: Singleton pattern prevents audio conflicts
|
- **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
|
- **Audio interruption handling**: Automatically resumes after phone calls or route changes
|
||||||
- **Wake lock integration**: Prevents device sleep while audio is playing
|
- **Wake lock integration**: Prevents device sleep while audio is playing
|
||||||
- **Bluetooth audio support**: Works with AirPods and other Bluetooth audio devices
|
- **Bluetooth audio support**: Works with AirPods and other Bluetooth audio devices
|
||||||
|
- **Responsive layout**: Optimized for both portrait and landscape orientations
|
||||||
|
|
||||||
### 6. Advanced Alarm System
|
### 6. Advanced Alarm System
|
||||||
- **Multiple alarms**: Create and manage unlimited alarms
|
- **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
|
- **Focus mode awareness**: Monitors and respects Focus mode settings
|
||||||
- **Notification compatibility**: Ensures alarms work with Focus modes enabled
|
- **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
|
### Notification System
|
||||||
- **UserNotifications**: iOS notification framework
|
- **UserNotifications**: iOS notification framework
|
||||||
- **Permission handling**: Automatic permission requests
|
- **Permission handling**: Automatic permission requests
|
||||||
@ -317,14 +334,15 @@ TheNoiseClock/
|
|||||||
│ └── Noise/
|
│ └── Noise/
|
||||||
│ ├── NoiseView.swift # Main white noise player interface
|
│ ├── NoiseView.swift # Main white noise player interface
|
||||||
│ └── Components/
|
│ └── Components/
|
||||||
│ ├── SoundPickerView.swift # Sound selection component
|
│ ├── SoundCategoryView.swift # Advanced grid-based sound selection
|
||||||
│ └── SoundControlView.swift # Playback controls component
|
│ └── SoundControlView.swift # Playback controls component
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── NoisePlayer.swift # Audio playback service with background support
|
│ ├── NoisePlayer.swift # Audio playback service with background support
|
||||||
│ ├── AlarmService.swift # Alarm management service with Focus mode integration
|
│ ├── AlarmService.swift # Alarm management service with Focus mode integration
|
||||||
│ ├── NotificationService.swift # Notification handling service
|
│ ├── NotificationService.swift # Notification handling service
|
||||||
│ ├── WakeLockService.swift # Screen wake lock management 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/
|
└── Resources/
|
||||||
├── sounds.json # Sound configuration and definitions
|
├── sounds.json # Sound configuration and definitions
|
||||||
├── Ambient.bundle/ # Ambient sound category
|
├── Ambient.bundle/ # Ambient sound category
|
||||||
@ -420,9 +438,13 @@ The following changes **automatically require** PRD updates:
|
|||||||
- Snooze duration settings
|
- Snooze duration settings
|
||||||
|
|
||||||
### Noise Tab
|
### Noise Tab
|
||||||
1. **Select sound**: Choose from dropdown menu
|
1. **Sound Selection**: Browse sounds by category with search functionality
|
||||||
2. **Play/Stop**: Single button to control playback
|
2. **Sound Preview**: Long-press for 3-second preview
|
||||||
3. **Continuous playback**: Sounds loop until stopped
|
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
|
## Technical Requirements
|
||||||
|
|
||||||
|
|||||||
@ -83,6 +83,46 @@ extension View {
|
|||||||
func onOrientationChange() -> some View {
|
func onOrientationChange() -> some View {
|
||||||
self.modifier(OrientationChangeModifier())
|
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
|
// MARK: - View Modifiers
|
||||||
|
|||||||
@ -12,13 +12,17 @@ struct Sound: Identifiable, Hashable {
|
|||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let fileName: String
|
let fileName: String
|
||||||
|
let category: String
|
||||||
|
let description: String
|
||||||
let bundleName: String? // Optional bundle name for organization
|
let bundleName: String? // Optional bundle name for organization
|
||||||
|
|
||||||
// MARK: - Initialization
|
// 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.id = fileName // Use fileName as stable identifier
|
||||||
self.name = name
|
self.name = name
|
||||||
self.fileName = fileName
|
self.fileName = fileName
|
||||||
|
self.category = category
|
||||||
|
self.description = description
|
||||||
self.bundleName = bundleName
|
self.bundleName = bundleName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ struct SoundConfig: Codable, Identifiable {
|
|||||||
|
|
||||||
/// Convert to Sound model for compatibility
|
/// Convert to Sound model for compatibility
|
||||||
func toSound() -> Sound {
|
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] {
|
private func getFallbackSounds() -> [Sound] {
|
||||||
return [
|
return [
|
||||||
// White noise sounds
|
// White noise sounds
|
||||||
Sound(name: "White Noise", fileName: "white-noise.mp3", bundleName: "Ambient"),
|
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", bundleName: "Nature"),
|
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", bundleName: "Mechanical"),
|
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
|
// Alarm sounds
|
||||||
Sound(name: "Digital Alarm", fileName: "digital-alarm.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", 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", 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", 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", 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", bundleName: "AlarmSounds")
|
Sound(name: "Voice Wake Up", fileName: "voice-wakeup.mp3", category: "alarm", description: "Voice-based wake up sound", bundleName: "AlarmSounds")
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -19,7 +19,7 @@ class BatteryService {
|
|||||||
var batteryLevel: Int = 100
|
var batteryLevel: Int = 100
|
||||||
var isCharging: Bool = false
|
var isCharging: Bool = false
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
@ObservationIgnored private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
|
|||||||
@ -14,6 +14,8 @@ class NoiseViewModel {
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private let noisePlayer: NoisePlayer
|
private let noisePlayer: NoisePlayer
|
||||||
|
var isPreviewing: Bool = false
|
||||||
|
var previewSound: Sound?
|
||||||
|
|
||||||
var isPlaying: Bool {
|
var isPlaying: Bool {
|
||||||
noisePlayer.isPlaying
|
noisePlayer.isPlaying
|
||||||
@ -37,11 +39,38 @@ class NoiseViewModel {
|
|||||||
noisePlayer.stopSound()
|
noisePlayer.stopSound()
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePlayback(for sound: Sound) {
|
func selectSound(_ sound: Sound) {
|
||||||
|
// Stop any current playback when selecting a new sound
|
||||||
if isPlaying {
|
if isPlaying {
|
||||||
stopSound()
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Component for displaying battery level overlay
|
/// Component for displaying battery level overlay
|
||||||
struct BatteryOverlayView: View {
|
struct BatteryOverlayView: View {
|
||||||
|
|||||||
265
TheNoiseClock/Views/Noise/Components/SoundCategoryView.swift
Normal file
265
TheNoiseClock/Views/Noise/Components/SoundCategoryView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
@ -18,7 +18,36 @@ struct SoundControlView: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
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: {
|
Button(action: {
|
||||||
if isPlaying {
|
if isPlaying {
|
||||||
onStop()
|
onStop()
|
||||||
@ -26,24 +55,69 @@ struct SoundControlView: View {
|
|||||||
onPlay(sound)
|
onPlay(sound)
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Text(isPlaying ? "Stop" : "Play")
|
HStack(spacing: UIConstants.Spacing.small) {
|
||||||
.font(.headline)
|
Image(systemName: isPlaying ? "stop.fill" : "play.fill")
|
||||||
.foregroundColor(.white)
|
.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(
|
.disabled(selectedSound == nil)
|
||||||
isEnabled: selectedSound != nil,
|
.scaleEffect(selectedSound == nil ? 0.95 : 1.0)
|
||||||
color: isPlaying ? UIConstants.Colors.accentColor : .green
|
.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
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview("Not Playing") {
|
||||||
SoundControlView(
|
SoundControlView(
|
||||||
isPlaying: false,
|
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 },
|
onPlay: { _ in },
|
||||||
onStop: {}
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -12,34 +12,117 @@ struct NoiseView: View {
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
@State private var viewModel = NoiseViewModel()
|
@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
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: UIConstants.Spacing.large) {
|
GeometryReader { geometry in
|
||||||
Text("White/Pink Noise")
|
let isLandscape = geometry.size.width > geometry.size.height
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(UIConstants.Colors.primaryText)
|
|
||||||
|
|
||||||
SoundPickerView(
|
if isLandscape {
|
||||||
sounds: viewModel.availableSounds,
|
// Landscape layout: Player on left, sounds on right
|
||||||
selectedSound: $selectedSound
|
landscapeLayout
|
||||||
)
|
} else {
|
||||||
|
// Portrait layout: Stacked vertically
|
||||||
SoundControlView(
|
portraitLayout
|
||||||
isPlaying: viewModel.isPlaying,
|
}
|
||||||
selectedSound: selectedSound,
|
|
||||||
onPlay: { sound in
|
|
||||||
viewModel.playSound(sound)
|
|
||||||
},
|
|
||||||
onStop: {
|
|
||||||
viewModel.stopSound()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Future: Add premium unlock button here
|
|
||||||
}
|
}
|
||||||
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user