Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
a4eaa187e5
commit
e4202d5853
@ -1,6 +1,7 @@
|
||||
Use /ios-18-role
|
||||
read the PRD.md
|
||||
read the README.md
|
||||
read the Bedrock README.md as well
|
||||
|
||||
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
|
||||
|
||||
|
||||
@ -41,6 +41,14 @@ public class SoundPlayer {
|
||||
}
|
||||
|
||||
public func playSound(_ sound: Sound) {
|
||||
playSound(sound, volumeOverride: nil)
|
||||
}
|
||||
|
||||
public func playSound(_ sound: Sound, volume: Float) {
|
||||
playSound(sound, volumeOverride: volume)
|
||||
}
|
||||
|
||||
private func playSound(_ sound: Sound, volumeOverride: Float?) {
|
||||
print("🎵 Attempting to play: \(sound.name)")
|
||||
|
||||
// Stop current sound if playing
|
||||
@ -63,7 +71,7 @@ public class SoundPlayer {
|
||||
do {
|
||||
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
|
||||
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||
newPlayer.volume = AudioConstants.Volume.default
|
||||
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
||||
newPlayer.prepareToPlay()
|
||||
players[sound.fileName] = newPlayer
|
||||
currentPlayer = newPlayer
|
||||
@ -77,6 +85,9 @@ public class SoundPlayer {
|
||||
}
|
||||
|
||||
currentPlayer = player
|
||||
if let volumeOverride {
|
||||
player.volume = volumeOverride
|
||||
}
|
||||
let success = player.play()
|
||||
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
||||
print("🔊 Player isPlaying: \(player.isPlaying)")
|
||||
|
||||
19
PRD.md
19
PRD.md
@ -105,11 +105,18 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
||||
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
|
||||
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
||||
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
|
||||
- **Background limitations**: Full alarm sound and screen require the app to be foregrounded; background alarms use notification sound
|
||||
- **Keep Awake prompt**: In-app popup enables Keep Awake without digging into settings
|
||||
- **Keep Awake guidance**: Banner messaging explains why Keep Awake improves alarm reliability
|
||||
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
||||
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
||||
- **Next trigger preview**: Shows when the next alarm will fire
|
||||
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
||||
- **AlarmSoundService integration**: Dedicated service for alarm-specific sound management
|
||||
- **In-app alarm screen**: Full-screen alarm UI with Snooze/Stop when the app is active
|
||||
- **Foreground alarm sound**: In-app playback of the selected alarm sound and volume
|
||||
- **Notification tap routing**: Tapping an alarm notification opens the alarm screen
|
||||
- **Foreground handling**: Alarm notifications surface as in-app UI instead of banners
|
||||
|
||||
## Advanced Clock Display Features
|
||||
|
||||
@ -351,6 +358,7 @@ These principles are fundamental to the project's long-term success and must be
|
||||
- **Real-time updates**: Changes apply immediately with live preview
|
||||
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
|
||||
- **Enum-based architecture**: Type-safe picker selections eliminate string-based errors
|
||||
- **Always-visible settings**: Advanced sections are always shown for clarity
|
||||
|
||||
## File Structure and Organization
|
||||
|
||||
@ -401,7 +409,8 @@ TheNoiseClock/
|
||||
│ │ │ └── SoundCategory.swift # Shared sound category definitions
|
||||
│ │ └── Utilities/
|
||||
│ │ ├── ColorUtils.swift # Color manipulation utilities
|
||||
│ │ └── NotificationUtils.swift # Notification helper functions
|
||||
│ │ ├── NotificationUtils.swift # Notification helper functions
|
||||
│ │ └── AlarmNotifications.swift # Alarm notification constants and events
|
||||
│ ├── Features/
|
||||
│ │ ├── Clock/
|
||||
│ │ │ ├── Models/
|
||||
@ -453,6 +462,7 @@ TheNoiseClock/
|
||||
│ │ │ ├── AlarmView.swift
|
||||
│ │ │ ├── AddAlarmView.swift
|
||||
│ │ │ ├── EditAlarmView.swift
|
||||
│ │ │ ├── AlarmScreen.swift
|
||||
│ │ │ └── Components/
|
||||
│ │ │ ├── AlarmRowView.swift
|
||||
│ │ │ ├── EmptyAlarmsView.swift
|
||||
@ -562,9 +572,10 @@ The following changes **automatically require** PRD updates:
|
||||
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
||||
2. **Appearance**: Adjust colors, glow, size, opacity
|
||||
3. **Display**: Control keep awake functionality for display mode
|
||||
4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
||||
5. **Overlays**: Control battery and date display
|
||||
6. **Background**: Set background color and use presets
|
||||
4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode)
|
||||
5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
||||
6. **Overlays**: Control battery and date display
|
||||
7. **Background**: Set background color and use presets
|
||||
|
||||
### Alarms Tab
|
||||
1. **View alarms**: List of all created alarms with labels and repeat schedules
|
||||
|
||||
@ -41,6 +41,11 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Alarm sound library with preview
|
||||
- Vibration and volume controls per alarm
|
||||
- Focus-mode aware scheduling
|
||||
- Full-screen in-app alarm screen with Snooze/Stop when active
|
||||
- In-app alarm sound playback using the selected alarm sound
|
||||
- Tapping alarm notifications opens the alarm screen
|
||||
- Background limitations: full alarm sound/screen requires the app to be open in the foreground
|
||||
- Keep Awake prompt enables staying on-screen for alarms
|
||||
|
||||
**Display Mode**
|
||||
- Long-press to enter immersive display mode
|
||||
@ -61,6 +66,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Full-screen display mode and Dynamic Island awareness
|
||||
- White noise playback with categories and previews
|
||||
- Rich alarm editor with scheduling and snooze controls
|
||||
- Full-screen in-app alarm screen with Snooze/Stop controls
|
||||
- Bedrock-based theming and branded launch
|
||||
- iPhone and iPad support with adaptive layouts
|
||||
- First-launch onboarding with feature highlights and permission setup
|
||||
|
||||
@ -13,16 +13,27 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private enum Tab: Hashable {
|
||||
private enum Tab: Hashable, CustomStringConvertible {
|
||||
case clock
|
||||
case alarms
|
||||
case noise
|
||||
case settings
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .clock: return "clock"
|
||||
case .alarms: return "alarms"
|
||||
case .noise: return "noise"
|
||||
case .settings: return "settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var selectedTab: Tab = .clock
|
||||
@State private var clockViewModel = ClockViewModel()
|
||||
@State private var alarmViewModel = AlarmViewModel()
|
||||
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
|
||||
@State private var keepAwakePromptState = KeepAwakePromptState()
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
@ -39,7 +50,11 @@ struct ContentView: View {
|
||||
.tag(Tab.clock)
|
||||
|
||||
NavigationStack {
|
||||
AlarmView()
|
||||
AlarmView(viewModel: alarmViewModel)
|
||||
.toolbar(.visible, for: .tabBar)
|
||||
.onAppear {
|
||||
Design.debugLog("[AlarmView] onAppear - forcing tabBar visible")
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Alarms", systemImage: "alarm")
|
||||
@ -48,6 +63,10 @@ struct ContentView: View {
|
||||
|
||||
NavigationStack {
|
||||
NoiseView()
|
||||
.toolbar(.visible, for: .tabBar)
|
||||
.onAppear {
|
||||
Design.debugLog("[NoiseView] onAppear - forcing tabBar visible")
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Noise", systemImage: "waveform")
|
||||
@ -64,6 +83,10 @@ struct ContentView: View {
|
||||
onboardingState.reset()
|
||||
}
|
||||
)
|
||||
.toolbar(.visible, for: .tabBar)
|
||||
.onAppear {
|
||||
Design.debugLog("[ClockSettingsView] onAppear - forcing tabBar visible")
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
@ -71,12 +94,26 @@ struct ContentView: View {
|
||||
.tag(Tab.settings)
|
||||
}
|
||||
.onChange(of: selectedTab) { oldValue, newValue in
|
||||
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
||||
if oldValue == .clock && newValue != .clock {
|
||||
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
|
||||
clockViewModel.setDisplayMode(false)
|
||||
}
|
||||
}
|
||||
.accentColor(AppAccent.primary)
|
||||
.background(Color.Branding.primary.ignoresSafeArea())
|
||||
.fullScreenCover(item: activeAlarmBinding) { alarm in
|
||||
AlarmScreen(
|
||||
alarm: alarm,
|
||||
onSnooze: {
|
||||
alarmViewModel.snoozeActiveAlarm()
|
||||
},
|
||||
onStop: {
|
||||
alarmViewModel.stopActiveAlarm()
|
||||
}
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
}
|
||||
|
||||
// Onboarding overlay for first-time users
|
||||
if !onboardingState.hasCompletedWelcome {
|
||||
@ -86,8 +123,38 @@ struct ContentView: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $keepAwakePromptState.isPresented) {
|
||||
KeepAwakePrompt(
|
||||
onEnable: {
|
||||
clockViewModel.setKeepAwakeEnabled(true)
|
||||
keepAwakePromptState.dismiss()
|
||||
},
|
||||
onDismiss: {
|
||||
keepAwakePromptState.dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .alarmDidFire)) { notification in
|
||||
alarmViewModel.handleAlarmNotification(userInfo: notification.userInfo)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .alarmDidStop)) { _ in
|
||||
alarmViewModel.stopActiveAlarm()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .alarmDidSnooze)) { _ in
|
||||
alarmViewModel.stopActiveAlarm()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .keepAwakePromptRequested)) { _ in
|
||||
keepAwakePromptState.showIfNeeded(isKeepAwakeEnabled: clockViewModel.style.keepAwake)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
|
||||
}
|
||||
|
||||
private var activeAlarmBinding: Binding<Alarm?> {
|
||||
Binding(
|
||||
get: { alarmViewModel.activeAlarm },
|
||||
set: { alarmViewModel.activeAlarm = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
@ -90,7 +90,10 @@ class AlarmService {
|
||||
date: alarm.time,
|
||||
soundName: alarm.soundName,
|
||||
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
|
||||
respectFocusModes: respectFocusModes
|
||||
respectFocusModes: respectFocusModes,
|
||||
snoozeDuration: alarm.snoozeDuration,
|
||||
isVibrationEnabled: alarm.isVibrationEnabled,
|
||||
volume: alarm.volume
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,4 +114,9 @@ class AlarmSoundService {
|
||||
}
|
||||
return fileName.replacingOccurrences(of: ".caf", with: "").capitalized
|
||||
}
|
||||
|
||||
/// Get alarm sound by filename
|
||||
func getAlarmSound(fileName: String) -> Sound? {
|
||||
return getAlarmSounds().first { $0.fileName == fileName }
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,15 +65,15 @@ class FocusModeService {
|
||||
private func configureNotificationSettings() async {
|
||||
// Create notification categories that work with Focus modes
|
||||
let alarmCategory = UNNotificationCategory(
|
||||
identifier: "ALARM_CATEGORY",
|
||||
identifier: AlarmNotificationConstants.categoryIdentifier,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: "SNOOZE_ACTION",
|
||||
identifier: AlarmNotificationConstants.snoozeActionIdentifier,
|
||||
title: "Snooze",
|
||||
options: []
|
||||
),
|
||||
UNNotificationAction(
|
||||
identifier: "STOP_ACTION",
|
||||
identifier: AlarmNotificationConstants.stopActionIdentifier,
|
||||
title: "Stop",
|
||||
options: [.destructive]
|
||||
)
|
||||
@ -96,7 +96,10 @@ class FocusModeService {
|
||||
date: Date,
|
||||
soundName: String,
|
||||
repeats: Bool = false,
|
||||
respectFocusModes: Bool = true
|
||||
respectFocusModes: Bool = true,
|
||||
snoozeDuration: Int? = nil,
|
||||
isVibrationEnabled: Bool? = nil,
|
||||
volume: Float? = nil
|
||||
) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
@ -110,16 +113,28 @@ class FocusModeService {
|
||||
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
||||
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
||||
}
|
||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||
|
||||
if !respectFocusModes, timeSensitiveSetting == .enabled {
|
||||
content.interruptionLevel = .timeSensitive
|
||||
}
|
||||
content.userInfo = [
|
||||
"alarmId": identifier,
|
||||
"soundName": soundName,
|
||||
"repeats": repeats
|
||||
var userInfo: [AnyHashable: Any] = [
|
||||
AlarmNotificationKeys.alarmId: identifier,
|
||||
AlarmNotificationKeys.soundName: soundName,
|
||||
AlarmNotificationKeys.repeats: repeats,
|
||||
AlarmNotificationKeys.label: title,
|
||||
AlarmNotificationKeys.notificationMessage: body
|
||||
]
|
||||
if let snoozeDuration {
|
||||
userInfo[AlarmNotificationKeys.snoozeDuration] = snoozeDuration
|
||||
}
|
||||
if let isVibrationEnabled {
|
||||
userInfo[AlarmNotificationKeys.isVibrationEnabled] = isVibrationEnabled
|
||||
}
|
||||
if let volume {
|
||||
userInfo[AlarmNotificationKeys.volume] = volume
|
||||
}
|
||||
content.userInfo = userInfo
|
||||
|
||||
// Create trigger
|
||||
let trigger: UNNotificationTrigger
|
||||
|
||||
@ -50,13 +50,16 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
|
||||
|
||||
switch actionIdentifier {
|
||||
case "SNOOZE_ACTION":
|
||||
case AlarmNotificationConstants.snoozeActionIdentifier:
|
||||
handleSnoozeAction(userInfo: userInfo)
|
||||
case "STOP_ACTION":
|
||||
postAlarmAction(name: .alarmDidSnooze, notification: notification)
|
||||
case AlarmNotificationConstants.stopActionIdentifier:
|
||||
handleStopAction(userInfo: userInfo)
|
||||
postAlarmAction(name: .alarmDidStop, notification: notification)
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
// User tapped the notification itself
|
||||
handleNotificationTap(userInfo: userInfo)
|
||||
postAlarmDidFire(notification: notification)
|
||||
default:
|
||||
Design.debugLog("[settings] Unknown action: \(actionIdentifier)")
|
||||
}
|
||||
@ -70,14 +73,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
// Show notification even when app is in foreground
|
||||
if notification.request.content.categoryIdentifier == AlarmNotificationConstants.categoryIdentifier {
|
||||
postAlarmDidFire(notification: notification)
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
|
||||
// Show non-alarm notifications even when app is in foreground
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
|
||||
// MARK: - Action Handlers
|
||||
|
||||
private func handleSnoozeAction(userInfo: [AnyHashable: Any]) {
|
||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
||||
guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString),
|
||||
let alarmService = self.alarmService,
|
||||
let alarm = alarmService.getAlarm(id: alarmId) else {
|
||||
@ -113,7 +122,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
|
||||
private func handleStopAction(userInfo: [AnyHashable: Any]) {
|
||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
||||
guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString) else {
|
||||
Design.debugLog("[general] Could not find alarm ID for stop action")
|
||||
return
|
||||
@ -129,7 +138,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
|
||||
private func handleNotificationTap(userInfo: [AnyHashable: Any]) {
|
||||
guard let alarmIdString = userInfo["alarmId"] as? String,
|
||||
guard let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString) else {
|
||||
Design.debugLog("[general] Could not find alarm ID for notification tap")
|
||||
return
|
||||
@ -147,13 +156,22 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = snoozeAlarm.label
|
||||
content.body = snoozeAlarm.notificationMessage
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
||||
if snoozeAlarm.soundName == "default" {
|
||||
content.sound = .default
|
||||
} else {
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
||||
}
|
||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||
content.userInfo = [
|
||||
"alarmId": snoozeAlarm.id.uuidString,
|
||||
"soundName": snoozeAlarm.soundName,
|
||||
"isSnooze": true,
|
||||
"originalAlarmId": userInfo["alarmId"] as? String ?? ""
|
||||
AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString,
|
||||
AlarmNotificationKeys.soundName: snoozeAlarm.soundName,
|
||||
AlarmNotificationKeys.isSnooze: true,
|
||||
AlarmNotificationKeys.originalAlarmId: userInfo[AlarmNotificationKeys.alarmId] as? String ?? "",
|
||||
AlarmNotificationKeys.label: snoozeAlarm.label,
|
||||
AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage,
|
||||
AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration,
|
||||
AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled,
|
||||
AlarmNotificationKeys.volume: snoozeAlarm.volume
|
||||
]
|
||||
|
||||
// Create trigger for snooze time
|
||||
@ -177,4 +195,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
Design.debugLog("[general] Error scheduling snooze notification: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Center Bridge
|
||||
|
||||
private func postAlarmDidFire(notification: UNNotification) {
|
||||
var userInfo = notification.request.content.userInfo
|
||||
userInfo[AlarmNotificationKeys.title] = notification.request.content.title
|
||||
userInfo[AlarmNotificationKeys.body] = notification.request.content.body
|
||||
NotificationCenter.default.post(name: .alarmDidFire, object: nil, userInfo: userInfo)
|
||||
}
|
||||
|
||||
private func postAlarmAction(name: Notification.Name, notification: UNNotification) {
|
||||
var userInfo = notification.request.content.userInfo
|
||||
userInfo[AlarmNotificationKeys.title] = notification.request.content.title
|
||||
userInfo[AlarmNotificationKeys.body] = notification.request.content.body
|
||||
NotificationCenter.default.post(name: name, object: nil, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,6 +64,12 @@ class NotificationService {
|
||||
body: body,
|
||||
soundName: soundName
|
||||
)
|
||||
content.userInfo = [
|
||||
AlarmNotificationKeys.alarmId: id,
|
||||
AlarmNotificationKeys.soundName: soundName,
|
||||
AlarmNotificationKeys.label: title,
|
||||
AlarmNotificationKeys.notificationMessage: body
|
||||
]
|
||||
let trigger = NotificationUtils.createCalendarTrigger(for: date)
|
||||
|
||||
return await NotificationUtils.scheduleNotification(
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import UserNotifications
|
||||
import AudioPlaybackKit
|
||||
|
||||
/// ViewModel for alarm management
|
||||
@Observable
|
||||
@ -15,6 +17,10 @@ class AlarmViewModel {
|
||||
// MARK: - Properties
|
||||
private let alarmService: AlarmService
|
||||
private let notificationService: NotificationService
|
||||
private let alarmSoundService = AlarmSoundService.shared
|
||||
private let soundPlayer = SoundPlayer.shared
|
||||
|
||||
var activeAlarm: Alarm?
|
||||
|
||||
var alarms: [Alarm] {
|
||||
alarmService.alarms
|
||||
@ -47,6 +53,7 @@ class AlarmViewModel {
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +69,7 @@ class AlarmViewModel {
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
} else {
|
||||
notificationService.cancelNotification(id: alarm.id.uuidString)
|
||||
}
|
||||
@ -90,6 +98,7 @@ class AlarmViewModel {
|
||||
soundName: alarm.soundName,
|
||||
date: alarm.time
|
||||
)
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
} else {
|
||||
notificationService.cancelNotification(id: id.uuidString)
|
||||
}
|
||||
@ -126,4 +135,157 @@ class AlarmViewModel {
|
||||
func requestNotificationPermissions() async -> Bool {
|
||||
return await notificationService.requestPermissions()
|
||||
}
|
||||
|
||||
func requestKeepAwakePromptIfNeeded() {
|
||||
guard !isKeepAwakeEnabled() else { return }
|
||||
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Active Alarm Handling
|
||||
|
||||
func handleAlarmNotification(userInfo: [AnyHashable: Any]?) {
|
||||
guard let userInfo else { return }
|
||||
if let alarm = resolveAlarm(from: userInfo) {
|
||||
startActiveAlarm(alarm)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func stopActiveAlarm() {
|
||||
soundPlayer.stopSound()
|
||||
activeAlarm = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snoozeActiveAlarm() {
|
||||
guard let alarm = activeAlarm else { return }
|
||||
scheduleSnoozeNotification(for: alarm)
|
||||
stopActiveAlarm()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func startActiveAlarm(_ alarm: Alarm) {
|
||||
if activeAlarm?.id == alarm.id {
|
||||
return
|
||||
}
|
||||
activeAlarm = alarm
|
||||
playAlarmSound(for: alarm)
|
||||
}
|
||||
|
||||
private func playAlarmSound(for alarm: Alarm) {
|
||||
let resolvedSound = alarmSoundService.getAlarmSound(fileName: alarm.soundName)
|
||||
?? alarmSoundService.getDefaultAlarmSound()
|
||||
guard let sound = resolvedSound else { return }
|
||||
soundPlayer.playSound(sound, volume: alarm.volume)
|
||||
}
|
||||
|
||||
private func isKeepAwakeEnabled() -> Bool {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return style.keepAwake
|
||||
}
|
||||
|
||||
private func scheduleSnoozeNotification(for alarm: Alarm) {
|
||||
let snoozeTime = Date().addingTimeInterval(TimeInterval(alarm.snoozeDuration * 60))
|
||||
let snoozeAlarm = Alarm(
|
||||
id: UUID(),
|
||||
time: snoozeTime,
|
||||
isEnabled: true,
|
||||
soundName: alarm.soundName,
|
||||
label: "\(alarm.label) (Snoozed)",
|
||||
notificationMessage: "Snoozed: \(alarm.notificationMessage)",
|
||||
snoozeDuration: alarm.snoozeDuration,
|
||||
isVibrationEnabled: alarm.isVibrationEnabled,
|
||||
isLightFlashEnabled: alarm.isLightFlashEnabled,
|
||||
volume: alarm.volume
|
||||
)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = snoozeAlarm.label
|
||||
content.body = snoozeAlarm.notificationMessage
|
||||
if snoozeAlarm.soundName == "default" {
|
||||
content.sound = .default
|
||||
} else {
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
||||
}
|
||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||
content.userInfo = [
|
||||
AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString,
|
||||
AlarmNotificationKeys.soundName: snoozeAlarm.soundName,
|
||||
AlarmNotificationKeys.isSnooze: true,
|
||||
AlarmNotificationKeys.originalAlarmId: alarm.id.uuidString,
|
||||
AlarmNotificationKeys.label: snoozeAlarm.label,
|
||||
AlarmNotificationKeys.notificationMessage: snoozeAlarm.notificationMessage,
|
||||
AlarmNotificationKeys.snoozeDuration: snoozeAlarm.snoozeDuration,
|
||||
AlarmNotificationKeys.isVibrationEnabled: snoozeAlarm.isVibrationEnabled,
|
||||
AlarmNotificationKeys.volume: snoozeAlarm.volume
|
||||
]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(
|
||||
timeInterval: snoozeAlarm.time.timeIntervalSinceNow,
|
||||
repeats: false
|
||||
)
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: snoozeAlarm.id.uuidString,
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
} catch {
|
||||
// Intentionally silent; notification system logs errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveAlarm(from userInfo: [AnyHashable: Any]) -> Alarm? {
|
||||
if let alarmIdString = userInfo[AlarmNotificationKeys.alarmId] as? String,
|
||||
let alarmId = UUID(uuidString: alarmIdString),
|
||||
let alarm = alarmService.getAlarm(id: alarmId) {
|
||||
return alarm
|
||||
}
|
||||
|
||||
let title = (userInfo[AlarmNotificationKeys.label] as? String) ?? (userInfo[AlarmNotificationKeys.title] as? String)
|
||||
let body = (userInfo[AlarmNotificationKeys.notificationMessage] as? String) ?? (userInfo[AlarmNotificationKeys.body] as? String)
|
||||
let soundName = (userInfo[AlarmNotificationKeys.soundName] as? String) ?? AppConstants.SystemSounds.defaultSound
|
||||
let snoozeDuration = intValue(from: userInfo[AlarmNotificationKeys.snoozeDuration]) ?? 9
|
||||
let isVibrationEnabled = boolValue(from: userInfo[AlarmNotificationKeys.isVibrationEnabled]) ?? true
|
||||
let volume = floatValue(from: userInfo[AlarmNotificationKeys.volume]) ?? 1.0
|
||||
|
||||
return Alarm(
|
||||
time: Date(),
|
||||
isEnabled: true,
|
||||
soundName: soundName,
|
||||
label: title ?? "Alarm",
|
||||
notificationMessage: body ?? "Your alarm is ringing",
|
||||
snoozeDuration: snoozeDuration,
|
||||
isVibrationEnabled: isVibrationEnabled,
|
||||
isLightFlashEnabled: false,
|
||||
volume: volume
|
||||
)
|
||||
}
|
||||
|
||||
private func intValue(from value: Any?) -> Int? {
|
||||
if let intValue = value as? Int { return intValue }
|
||||
if let number = value as? NSNumber { return number.intValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func floatValue(from value: Any?) -> Float? {
|
||||
if let floatValue = value as? Float { return floatValue }
|
||||
if let doubleValue = value as? Double { return Float(doubleValue) }
|
||||
if let number = value as? NSNumber { return number.floatValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func boolValue(from value: Any?) -> Bool? {
|
||||
if let boolValue = value as? Bool { return boolValue }
|
||||
if let number = value as? NSNumber { return number.boolValue }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import AudioPlaybackKit
|
||||
import Foundation
|
||||
|
||||
/// View for creating new alarms with iOS-native style interface
|
||||
struct AddAlarmView: View {
|
||||
@ -14,6 +15,7 @@ struct AddAlarmView: View {
|
||||
// MARK: - Properties
|
||||
let viewModel: AlarmViewModel
|
||||
@Binding var isPresented: Bool
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
@State private var newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
@State private var selectedSoundName = "digital-alarm.caf"
|
||||
@ -33,6 +35,15 @@ struct AddAlarmView: View {
|
||||
|
||||
// List for settings below
|
||||
List {
|
||||
if !isKeepAwakeEnabled {
|
||||
Section {
|
||||
AlarmLimitationsBanner()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Label Section
|
||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||
HStack {
|
||||
@ -127,4 +138,11 @@ struct AddAlarmView: View {
|
||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
64
TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift
Normal file
64
TheNoiseClock/Features/Alarms/Views/AlarmScreen.swift
Normal file
@ -0,0 +1,64 @@
|
||||
//
|
||||
// AlarmScreen.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Full-screen alarm UI for active alarms
|
||||
struct AlarmScreen: View {
|
||||
|
||||
let alarm: Alarm
|
||||
let onSnooze: () -> Void
|
||||
let onStop: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.Branding.primary
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Spacer()
|
||||
|
||||
Text(alarm.formattedTime())
|
||||
.font(.system(size: 72, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text(alarm.label)
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
Button(action: onSnooze) {
|
||||
Text("Snooze")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(color: AppAccent.primary)
|
||||
|
||||
Button(action: onStop) {
|
||||
Text("Stop")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(color: .red)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.bottom, Design.Spacing.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AlarmScreen(
|
||||
alarm: Alarm(time: Date(), soundName: "digital-alarm.caf", label: "Wake Up", notificationMessage: "Alarm", snoozeDuration: 9, isVibrationEnabled: true, isLightFlashEnabled: false, volume: 1.0),
|
||||
onSnooze: {},
|
||||
onStop: {}
|
||||
)
|
||||
}
|
||||
@ -7,31 +7,48 @@
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Foundation
|
||||
|
||||
/// Main alarm management view
|
||||
struct AlarmView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
@State private var viewModel = AlarmViewModel()
|
||||
@Bindable var viewModel: AlarmViewModel
|
||||
@State private var showAddAlarm = false
|
||||
@State private var selectedAlarmForEdit: Alarm?
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
Group {
|
||||
if viewModel.alarms.isEmpty {
|
||||
EmptyAlarmsView {
|
||||
showAddAlarm = true
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showAddAlarm = true
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
if !isKeepAwakeEnabled {
|
||||
AlarmLimitationsBanner()
|
||||
}
|
||||
|
||||
EmptyAlarmsView {
|
||||
showAddAlarm = true
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showAddAlarm = true
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
List {
|
||||
if !isKeepAwakeEnabled {
|
||||
Section {
|
||||
AlarmLimitationsBanner()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.alarms) { alarm in
|
||||
AlarmRowView(
|
||||
alarm: alarm,
|
||||
@ -47,6 +64,7 @@ struct AlarmView: View {
|
||||
}
|
||||
.onDelete(perform: deleteAlarm)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
@ -67,6 +85,7 @@ struct AlarmView: View {
|
||||
Task {
|
||||
await viewModel.requestNotificationPermissions()
|
||||
}
|
||||
viewModel.requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
.sheet(isPresented: $showAddAlarm) {
|
||||
AddAlarmView(
|
||||
@ -91,11 +110,18 @@ struct AlarmView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AlarmView()
|
||||
AlarmView(viewModel: AlarmViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
//
|
||||
// AlarmLimitationsBanner.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Foundation
|
||||
|
||||
/// Banner explaining background alarm limitations and mitigation.
|
||||
struct AlarmLimitationsBanner: View {
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
var body: some View {
|
||||
if isKeepAwakeEnabled {
|
||||
EmptyView()
|
||||
} else {
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
Text("Alarm reliability")
|
||||
.typography(.body)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
}
|
||||
|
||||
Text("iOS only allows notification sounds when the app is backgrounded. For a full alarm sound and screen, keep TheNoiseClock open in the foreground.")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Text("Tip: Use the Keep Awake prompt to keep the app on-screen while alarms are active.")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AlarmLimitationsBanner()
|
||||
.padding()
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import AudioPlaybackKit
|
||||
import Foundation
|
||||
|
||||
/// View for editing existing alarms
|
||||
struct EditAlarmView: View {
|
||||
@ -15,6 +16,7 @@ struct EditAlarmView: View {
|
||||
let viewModel: AlarmViewModel
|
||||
let alarm: Alarm
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||
|
||||
@State private var alarmTime: Date
|
||||
@State private var selectedSoundName: String
|
||||
@ -51,6 +53,15 @@ struct EditAlarmView: View {
|
||||
|
||||
// List for settings below
|
||||
List {
|
||||
if !isKeepAwakeEnabled {
|
||||
Section {
|
||||
AlarmLimitationsBanner()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Label Section
|
||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||
HStack {
|
||||
@ -147,6 +158,13 @@ struct EditAlarmView: View {
|
||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
||||
}
|
||||
|
||||
private var isKeepAwakeEnabled: Bool {
|
||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||
return ClockStyle().keepAwake
|
||||
}
|
||||
return decoded.keepAwake
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
@ -61,20 +61,32 @@ class ClockViewModel {
|
||||
|
||||
// MARK: - Public Interface
|
||||
func toggleDisplayMode() {
|
||||
let oldValue = isDisplayMode
|
||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||
isDisplayMode.toggle()
|
||||
}
|
||||
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
|
||||
|
||||
// Manage wake lock based on display mode and keep awake setting
|
||||
updateWakeLockState()
|
||||
if isDisplayMode {
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func setDisplayMode(_ enabled: Bool) {
|
||||
guard isDisplayMode != enabled else { return }
|
||||
guard isDisplayMode != enabled else {
|
||||
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)")
|
||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||
isDisplayMode = enabled
|
||||
}
|
||||
updateWakeLockState()
|
||||
if enabled {
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func updateStyle(_ newStyle: ClockStyle) {
|
||||
@ -116,6 +128,12 @@ class ClockViewModel {
|
||||
updateBrightness() // Update brightness when style changes
|
||||
}
|
||||
|
||||
func setKeepAwakeEnabled(_ enabled: Bool) {
|
||||
style.keepAwake = enabled
|
||||
saveStyle()
|
||||
updateWakeLockState()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
private func loadStyle() {
|
||||
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
|
||||
@ -193,6 +211,11 @@ class ClockViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func requestKeepAwakePromptIfNeeded() {
|
||||
guard !style.keepAwake else { return }
|
||||
NotificationCenter.default.post(name: .keepAwakePromptRequested, object: nil)
|
||||
}
|
||||
|
||||
/// Update wake lock state based on current settings
|
||||
private func updateWakeLockState() {
|
||||
// Enable wake lock if in display mode and keep awake is enabled
|
||||
|
||||
@ -18,7 +18,6 @@ struct ClockSettingsView: View {
|
||||
|
||||
@State private var digitColor: Color = .white
|
||||
@State private var backgroundColor: Color = .black
|
||||
@State private var showAdvancedSettings = false
|
||||
|
||||
// MARK: - Init
|
||||
init(
|
||||
@ -42,34 +41,17 @@ struct ClockSettingsView: View {
|
||||
backgroundColor: $backgroundColor
|
||||
)
|
||||
|
||||
FontSection(style: $style)
|
||||
|
||||
AdvancedAppearanceSection(style: $style)
|
||||
|
||||
BasicDisplaySection(style: $style)
|
||||
|
||||
if showAdvancedSettings {
|
||||
AdvancedAppearanceSection(style: $style)
|
||||
AdvancedDisplaySection(style: $style)
|
||||
|
||||
FontSection(style: $style)
|
||||
NightModeSection(style: $style)
|
||||
|
||||
NightModeSection(style: $style)
|
||||
|
||||
OverlaySection(style: $style)
|
||||
|
||||
AdvancedDisplaySection(style: $style)
|
||||
}
|
||||
|
||||
SettingsSectionHeader(
|
||||
title: "Advanced",
|
||||
systemImage: "gearshape",
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
SettingsToggle(
|
||||
title: "Show Advanced Settings",
|
||||
subtitle: "Reveal additional customization options",
|
||||
isOn: $showAdvancedSettings,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
OverlaySection(style: $style)
|
||||
|
||||
#if DEBUG
|
||||
SettingsSectionHeader(
|
||||
|
||||
@ -18,6 +18,7 @@ struct ClockView: View {
|
||||
@Bindable var viewModel: ClockViewModel
|
||||
@State private var idleTimer: Timer?
|
||||
@State private var didHandleTouch = false
|
||||
@State private var isViewActive = false
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
@ -65,15 +66,15 @@ struct ClockView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
||||
}
|
||||
.onChange(of: geometry.size) { _, newSize in
|
||||
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
||||
}
|
||||
.onChange(of: safeInsets) { _, newInsets in
|
||||
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
||||
}
|
||||
// .onAppear {
|
||||
// logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
||||
// }
|
||||
// .onChange(of: geometry.size) { _, newSize in
|
||||
// logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
||||
// }
|
||||
// .onChange(of: safeInsets) { _, newInsets in
|
||||
// logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
||||
// }
|
||||
}
|
||||
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
@ -94,9 +95,13 @@ struct ClockView: View {
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
Design.debugLog("[ClockView] onAppear - setting isViewActive = true")
|
||||
isViewActive = true
|
||||
resetIdleTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer")
|
||||
isViewActive = false
|
||||
idleTimer?.invalidate()
|
||||
idleTimer = nil
|
||||
}
|
||||
@ -121,7 +126,16 @@ struct ClockView: View {
|
||||
}
|
||||
|
||||
private func enterDisplayModeFromIdle() {
|
||||
guard !viewModel.isDisplayMode else { return }
|
||||
// Guard against entering display mode if we're no longer on the clock tab
|
||||
guard isViewActive else {
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: view is not active (user switched tabs)")
|
||||
return
|
||||
}
|
||||
guard !viewModel.isDisplayMode else {
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode")
|
||||
viewModel.toggleDisplayMode()
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Component that manages tab bar visibility for display mode
|
||||
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
|
||||
@ -18,6 +19,12 @@ struct ClockTabBarManager: View {
|
||||
var body: some View {
|
||||
EmptyView()
|
||||
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar)
|
||||
.onAppear {
|
||||
Design.debugLog("[ClockTabBarManager] onAppear - isDisplayMode: \(isDisplayMode), tabBar: \(isDisplayMode ? "hidden" : "automatic")")
|
||||
}
|
||||
.onChange(of: isDisplayMode) { oldValue, newValue in
|
||||
Design.debugLog("[ClockTabBarManager] isDisplayMode changed: \(oldValue) -> \(newValue), tabBar: \(newValue ? "hidden" : "automatic")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ struct AdvancedDisplaySection: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("Advanced display and system integration settings.")
|
||||
Text("Advanced display and system integration settings. Keep Awake helps alarms stay active while the app remains open.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
|
||||
|
||||
@ -263,9 +263,9 @@ struct TimeDisplayView: View {
|
||||
height: max(1, availableHeight / digitRows)
|
||||
)
|
||||
|
||||
Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
|
||||
Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
|
||||
Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||||
//Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
|
||||
//Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
|
||||
//Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||||
|
||||
return FontUtils.calculateOptimalFontSize(
|
||||
digit: "8",
|
||||
@ -308,17 +308,17 @@ struct TimeDisplayView: View {
|
||||
if totalWidth > containerSize.width {
|
||||
let scaleFactor = containerSize.width / totalWidth
|
||||
estimated *= scaleFactor * 0.98 // Add 2% margin
|
||||
Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
|
||||
//Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
|
||||
}
|
||||
}
|
||||
|
||||
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
|
||||
//Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
|
||||
|
||||
if abs(estimated - fontSize) > 1 {
|
||||
fontSize = estimated
|
||||
Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
|
||||
//Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
|
||||
} else {
|
||||
Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
|
||||
// Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
|
||||
}
|
||||
lastCalculatedContainerSize = containerSize
|
||||
}
|
||||
|
||||
36
TheNoiseClock/Shared/Utilities/AlarmNotifications.swift
Normal file
36
TheNoiseClock/Shared/Utilities/AlarmNotifications.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// AlarmNotifications.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AlarmNotificationConstants {
|
||||
static let categoryIdentifier = "ALARM_CATEGORY"
|
||||
static let snoozeActionIdentifier = "SNOOZE_ACTION"
|
||||
static let stopActionIdentifier = "STOP_ACTION"
|
||||
}
|
||||
|
||||
enum AlarmNotificationKeys {
|
||||
static let alarmId = "alarmId"
|
||||
static let soundName = "soundName"
|
||||
static let repeats = "repeats"
|
||||
static let isSnooze = "isSnooze"
|
||||
static let originalAlarmId = "originalAlarmId"
|
||||
static let label = "label"
|
||||
static let notificationMessage = "notificationMessage"
|
||||
static let snoozeDuration = "snoozeDuration"
|
||||
static let isVibrationEnabled = "isVibrationEnabled"
|
||||
static let volume = "volume"
|
||||
static let title = "title"
|
||||
static let body = "body"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let alarmDidFire = Notification.Name("alarmDidFire")
|
||||
static let alarmDidStop = Notification.Name("alarmDidStop")
|
||||
static let alarmDidSnooze = Notification.Name("alarmDidSnooze")
|
||||
static let keepAwakePromptRequested = Notification.Name("keepAwakePromptRequested")
|
||||
}
|
||||
56
TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift
Normal file
56
TheNoiseClock/Shared/Utilities/KeepAwakePrompt.swift
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// KeepAwakePrompt.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct KeepAwakePrompt: View {
|
||||
let onEnable: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 36, weight: .semibold))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Keep Awake for Alarms")
|
||||
.typography(.title2)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Enable Keep Awake so your alarm can play loudly and show the full screen while TheNoiseClock stays open.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Button(action: onEnable) {
|
||||
Text("Enable Keep Awake")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(color: AppAccent.primary)
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Text("Not Now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.xLarge)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppSurface.primary)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
KeepAwakePrompt(onEnable: {}, onDismiss: {})
|
||||
}
|
||||
30
TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift
Normal file
30
TheNoiseClock/Shared/Utilities/KeepAwakePromptState.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// KeepAwakePromptState.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 2/2/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
class KeepAwakePromptState {
|
||||
|
||||
var isPresented = false
|
||||
private var hasShownThisSession = false
|
||||
|
||||
func showIfNeeded(isKeepAwakeEnabled: Bool) {
|
||||
guard !isKeepAwakeEnabled, !hasShownThisSession else { return }
|
||||
isPresented = true
|
||||
hasShownThisSession = true
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
isPresented = false
|
||||
}
|
||||
|
||||
func resetSessionFlag() {
|
||||
hasShownThisSession = false
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ enum NotificationUtils {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||
|
||||
if soundName == "default" {
|
||||
content.sound = UNNotificationSound.default
|
||||
|
||||
Loading…
Reference in New Issue
Block a user