Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
cfa51e04b7
commit
9716185622
191
PRD.md
Normal file
191
PRD.md
Normal file
@ -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
|
||||||
49
TheNoiseClock/AddAlarmView.swift
Normal file
49
TheNoiseClock/AddAlarmView.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
163
TheNoiseClock/AlarmView.swift
Normal file
163
TheNoiseClock/AlarmView.swift
Normal file
@ -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<Bool> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
102
TheNoiseClock/ClockSettingsView.swift
Normal file
102
TheNoiseClock/ClockSettingsView.swift
Normal file
@ -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()))
|
||||||
|
}
|
||||||
68
TheNoiseClock/ClockStyle.swift
Normal file
68
TheNoiseClock/ClockStyle.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
512
TheNoiseClock/ClockView.swift
Normal file
512
TheNoiseClock/ClockView.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
23
TheNoiseClock/NoisePlayer.swift
Normal file
23
TheNoiseClock/NoisePlayer.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
52
TheNoiseClock/NoiseView.swift
Normal file
52
TheNoiseClock/NoiseView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
BIN
TheNoiseClock/Resources/fan-white-noise-heater-303207.mp3
Normal file
BIN
TheNoiseClock/Resources/fan-white-noise-heater-303207.mp3
Normal file
Binary file not shown.
BIN
TheNoiseClock/Resources/heavy-rain-white-noise.mp3
Normal file
BIN
TheNoiseClock/Resources/heavy-rain-white-noise.mp3
Normal file
Binary file not shown.
BIN
TheNoiseClock/Resources/white-noise.mp3
Normal file
BIN
TheNoiseClock/Resources/white-noise.mp3
Normal file
Binary file not shown.
7
TheNoiseClock/Sound.swift
Normal file
7
TheNoiseClock/Sound.swift
Normal file
@ -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"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user