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
|
Use /ios-18-role
|
||||||
read the PRD.md
|
read the PRD.md
|
||||||
read the README.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.
|
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) {
|
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)")
|
print("🎵 Attempting to play: \(sound.name)")
|
||||||
|
|
||||||
// Stop current sound if playing
|
// Stop current sound if playing
|
||||||
@ -63,7 +71,7 @@ public class SoundPlayer {
|
|||||||
do {
|
do {
|
||||||
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
|
let newPlayer = try AVAudioPlayer(contentsOf: fileUrl)
|
||||||
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
newPlayer.numberOfLoops = AudioConstants.Playback.numberOfLoops
|
||||||
newPlayer.volume = AudioConstants.Volume.default
|
newPlayer.volume = volumeOverride ?? AudioConstants.Volume.default
|
||||||
newPlayer.prepareToPlay()
|
newPlayer.prepareToPlay()
|
||||||
players[sound.fileName] = newPlayer
|
players[sound.fileName] = newPlayer
|
||||||
currentPlayer = newPlayer
|
currentPlayer = newPlayer
|
||||||
@ -77,6 +85,9 @@ public class SoundPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPlayer = player
|
currentPlayer = player
|
||||||
|
if let volumeOverride {
|
||||||
|
player.volume = volumeOverride
|
||||||
|
}
|
||||||
let success = player.play()
|
let success = player.play()
|
||||||
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
print("🎵 Play result: \(success ? "SUCCESS" : "FAILED")")
|
||||||
print("🔊 Player isPlaying: \(player.isPlaying)")
|
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
|
- **Smart notifications**: Automatic scheduling for one-time and repeating alarms
|
||||||
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
- **Enable/disable toggles**: Individual alarm control with instant feedback
|
||||||
- **Notification integration**: Uses iOS UserNotifications framework with proper scheduling
|
- **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
|
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
||||||
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
||||||
- **Next trigger preview**: Shows when the next alarm will fire
|
- **Next trigger preview**: Shows when the next alarm will fire
|
||||||
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
||||||
- **AlarmSoundService integration**: Dedicated service for alarm-specific sound management
|
- **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
|
## 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
|
- **Real-time updates**: Changes apply immediately with live preview
|
||||||
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
|
- **Sheet presentation**: Full-screen settings sheet for uninterrupted editing
|
||||||
- **Enum-based architecture**: Type-safe picker selections eliminate string-based errors
|
- **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
|
## File Structure and Organization
|
||||||
|
|
||||||
@ -401,7 +409,8 @@ TheNoiseClock/
|
|||||||
│ │ │ └── SoundCategory.swift # Shared sound category definitions
|
│ │ │ └── SoundCategory.swift # Shared sound category definitions
|
||||||
│ │ └── Utilities/
|
│ │ └── Utilities/
|
||||||
│ │ ├── ColorUtils.swift # Color manipulation utilities
|
│ │ ├── ColorUtils.swift # Color manipulation utilities
|
||||||
│ │ └── NotificationUtils.swift # Notification helper functions
|
│ │ ├── NotificationUtils.swift # Notification helper functions
|
||||||
|
│ │ └── AlarmNotifications.swift # Alarm notification constants and events
|
||||||
│ ├── Features/
|
│ ├── Features/
|
||||||
│ │ ├── Clock/
|
│ │ ├── Clock/
|
||||||
│ │ │ ├── Models/
|
│ │ │ ├── Models/
|
||||||
@ -453,6 +462,7 @@ TheNoiseClock/
|
|||||||
│ │ │ ├── AlarmView.swift
|
│ │ │ ├── AlarmView.swift
|
||||||
│ │ │ ├── AddAlarmView.swift
|
│ │ │ ├── AddAlarmView.swift
|
||||||
│ │ │ ├── EditAlarmView.swift
|
│ │ │ ├── EditAlarmView.swift
|
||||||
|
│ │ │ ├── AlarmScreen.swift
|
||||||
│ │ │ └── Components/
|
│ │ │ └── Components/
|
||||||
│ │ │ ├── AlarmRowView.swift
|
│ │ │ ├── AlarmRowView.swift
|
||||||
│ │ │ ├── EmptyAlarmsView.swift
|
│ │ │ ├── EmptyAlarmsView.swift
|
||||||
@ -562,9 +572,10 @@ The following changes **automatically require** PRD updates:
|
|||||||
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
||||||
2. **Appearance**: Adjust colors, glow, size, opacity
|
2. **Appearance**: Adjust colors, glow, size, opacity
|
||||||
3. **Display**: Control keep awake functionality for display mode
|
3. **Display**: Control keep awake functionality for display mode
|
||||||
4. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
4. **Keep Awake prompt**: Auto-prompt when needed (alarms tab, enabling alarms, display mode)
|
||||||
5. **Overlays**: Control battery and date display
|
5. **Focus Modes**: Control how app behaves with Focus modes (Do Not Disturb)
|
||||||
6. **Background**: Set background color and use presets
|
6. **Overlays**: Control battery and date display
|
||||||
|
7. **Background**: Set background color and use presets
|
||||||
|
|
||||||
### Alarms Tab
|
### Alarms Tab
|
||||||
1. **View alarms**: List of all created alarms with labels and repeat schedules
|
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
|
- Alarm sound library with preview
|
||||||
- Vibration and volume controls per alarm
|
- Vibration and volume controls per alarm
|
||||||
- Focus-mode aware scheduling
|
- 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**
|
**Display Mode**
|
||||||
- Long-press to enter immersive 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
|
- Full-screen display mode and Dynamic Island awareness
|
||||||
- White noise playback with categories and previews
|
- White noise playback with categories and previews
|
||||||
- Rich alarm editor with scheduling and snooze controls
|
- Rich alarm editor with scheduling and snooze controls
|
||||||
|
- Full-screen in-app alarm screen with Snooze/Stop controls
|
||||||
- Bedrock-based theming and branded launch
|
- Bedrock-based theming and branded launch
|
||||||
- iPhone and iPad support with adaptive layouts
|
- iPhone and iPad support with adaptive layouts
|
||||||
- First-launch onboarding with feature highlights and permission setup
|
- First-launch onboarding with feature highlights and permission setup
|
||||||
|
|||||||
@ -13,16 +13,27 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
private enum Tab: Hashable {
|
private enum Tab: Hashable, CustomStringConvertible {
|
||||||
case clock
|
case clock
|
||||||
case alarms
|
case alarms
|
||||||
case noise
|
case noise
|
||||||
case settings
|
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 selectedTab: Tab = .clock
|
||||||
@State private var clockViewModel = ClockViewModel()
|
@State private var clockViewModel = ClockViewModel()
|
||||||
|
@State private var alarmViewModel = AlarmViewModel()
|
||||||
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
|
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
|
||||||
|
@State private var keepAwakePromptState = KeepAwakePromptState()
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
@ -39,7 +50,11 @@ struct ContentView: View {
|
|||||||
.tag(Tab.clock)
|
.tag(Tab.clock)
|
||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
AlarmView()
|
AlarmView(viewModel: alarmViewModel)
|
||||||
|
.toolbar(.visible, for: .tabBar)
|
||||||
|
.onAppear {
|
||||||
|
Design.debugLog("[AlarmView] onAppear - forcing tabBar visible")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Alarms", systemImage: "alarm")
|
Label("Alarms", systemImage: "alarm")
|
||||||
@ -48,6 +63,10 @@ struct ContentView: View {
|
|||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
NoiseView()
|
NoiseView()
|
||||||
|
.toolbar(.visible, for: .tabBar)
|
||||||
|
.onAppear {
|
||||||
|
Design.debugLog("[NoiseView] onAppear - forcing tabBar visible")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Noise", systemImage: "waveform")
|
Label("Noise", systemImage: "waveform")
|
||||||
@ -64,6 +83,10 @@ struct ContentView: View {
|
|||||||
onboardingState.reset()
|
onboardingState.reset()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.toolbar(.visible, for: .tabBar)
|
||||||
|
.onAppear {
|
||||||
|
Design.debugLog("[ClockSettingsView] onAppear - forcing tabBar visible")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Settings", systemImage: "gearshape")
|
Label("Settings", systemImage: "gearshape")
|
||||||
@ -71,12 +94,26 @@ struct ContentView: View {
|
|||||||
.tag(Tab.settings)
|
.tag(Tab.settings)
|
||||||
}
|
}
|
||||||
.onChange(of: selectedTab) { oldValue, newValue in
|
.onChange(of: selectedTab) { oldValue, newValue in
|
||||||
|
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
||||||
if oldValue == .clock && newValue != .clock {
|
if oldValue == .clock && newValue != .clock {
|
||||||
|
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false")
|
||||||
clockViewModel.setDisplayMode(false)
|
clockViewModel.setDisplayMode(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accentColor(AppAccent.primary)
|
.accentColor(AppAccent.primary)
|
||||||
.background(Color.Branding.primary.ignoresSafeArea())
|
.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
|
// Onboarding overlay for first-time users
|
||||||
if !onboardingState.hasCompletedWelcome {
|
if !onboardingState.hasCompletedWelcome {
|
||||||
@ -86,8 +123,38 @@ struct ContentView: View {
|
|||||||
.transition(.opacity)
|
.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)
|
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var activeAlarmBinding: Binding<Alarm?> {
|
||||||
|
Binding(
|
||||||
|
get: { alarmViewModel.activeAlarm },
|
||||||
|
set: { alarmViewModel.activeAlarm = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|||||||
@ -90,7 +90,10 @@ class AlarmService {
|
|||||||
date: alarm.time,
|
date: alarm.time,
|
||||||
soundName: alarm.soundName,
|
soundName: alarm.soundName,
|
||||||
repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
|
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
|
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 {
|
private func configureNotificationSettings() async {
|
||||||
// Create notification categories that work with Focus modes
|
// Create notification categories that work with Focus modes
|
||||||
let alarmCategory = UNNotificationCategory(
|
let alarmCategory = UNNotificationCategory(
|
||||||
identifier: "ALARM_CATEGORY",
|
identifier: AlarmNotificationConstants.categoryIdentifier,
|
||||||
actions: [
|
actions: [
|
||||||
UNNotificationAction(
|
UNNotificationAction(
|
||||||
identifier: "SNOOZE_ACTION",
|
identifier: AlarmNotificationConstants.snoozeActionIdentifier,
|
||||||
title: "Snooze",
|
title: "Snooze",
|
||||||
options: []
|
options: []
|
||||||
),
|
),
|
||||||
UNNotificationAction(
|
UNNotificationAction(
|
||||||
identifier: "STOP_ACTION",
|
identifier: AlarmNotificationConstants.stopActionIdentifier,
|
||||||
title: "Stop",
|
title: "Stop",
|
||||||
options: [.destructive]
|
options: [.destructive]
|
||||||
)
|
)
|
||||||
@ -96,7 +96,10 @@ class FocusModeService {
|
|||||||
date: Date,
|
date: Date,
|
||||||
soundName: String,
|
soundName: String,
|
||||||
repeats: Bool = false,
|
repeats: Bool = false,
|
||||||
respectFocusModes: Bool = true
|
respectFocusModes: Bool = true,
|
||||||
|
snoozeDuration: Int? = nil,
|
||||||
|
isVibrationEnabled: Bool? = nil,
|
||||||
|
volume: Float? = nil
|
||||||
) {
|
) {
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title
|
content.title = title
|
||||||
@ -110,16 +113,28 @@ class FocusModeService {
|
|||||||
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
Design.debugLog("[settings] Using custom alarm sound: \(soundName)")
|
||||||
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
Design.debugLog("[settings] Sound file should be in main bundle: \(soundName)")
|
||||||
}
|
}
|
||||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||||
|
|
||||||
if !respectFocusModes, timeSensitiveSetting == .enabled {
|
if !respectFocusModes, timeSensitiveSetting == .enabled {
|
||||||
content.interruptionLevel = .timeSensitive
|
content.interruptionLevel = .timeSensitive
|
||||||
}
|
}
|
||||||
content.userInfo = [
|
var userInfo: [AnyHashable: Any] = [
|
||||||
"alarmId": identifier,
|
AlarmNotificationKeys.alarmId: identifier,
|
||||||
"soundName": soundName,
|
AlarmNotificationKeys.soundName: soundName,
|
||||||
"repeats": repeats
|
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
|
// Create trigger
|
||||||
let trigger: UNNotificationTrigger
|
let trigger: UNNotificationTrigger
|
||||||
|
|||||||
@ -50,13 +50,16 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
|
Design.debugLog("[settings] Notification action received: \(actionIdentifier)")
|
||||||
|
|
||||||
switch actionIdentifier {
|
switch actionIdentifier {
|
||||||
case "SNOOZE_ACTION":
|
case AlarmNotificationConstants.snoozeActionIdentifier:
|
||||||
handleSnoozeAction(userInfo: userInfo)
|
handleSnoozeAction(userInfo: userInfo)
|
||||||
case "STOP_ACTION":
|
postAlarmAction(name: .alarmDidSnooze, notification: notification)
|
||||||
|
case AlarmNotificationConstants.stopActionIdentifier:
|
||||||
handleStopAction(userInfo: userInfo)
|
handleStopAction(userInfo: userInfo)
|
||||||
|
postAlarmAction(name: .alarmDidStop, notification: notification)
|
||||||
case UNNotificationDefaultActionIdentifier:
|
case UNNotificationDefaultActionIdentifier:
|
||||||
// User tapped the notification itself
|
// User tapped the notification itself
|
||||||
handleNotificationTap(userInfo: userInfo)
|
handleNotificationTap(userInfo: userInfo)
|
||||||
|
postAlarmDidFire(notification: notification)
|
||||||
default:
|
default:
|
||||||
Design.debugLog("[settings] Unknown action: \(actionIdentifier)")
|
Design.debugLog("[settings] Unknown action: \(actionIdentifier)")
|
||||||
}
|
}
|
||||||
@ -70,14 +73,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
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])
|
completionHandler([.banner, .sound, .badge])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Action Handlers
|
// MARK: - Action Handlers
|
||||||
|
|
||||||
private func handleSnoozeAction(userInfo: [AnyHashable: Any]) {
|
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 alarmId = UUID(uuidString: alarmIdString),
|
||||||
let alarmService = self.alarmService,
|
let alarmService = self.alarmService,
|
||||||
let alarm = alarmService.getAlarm(id: alarmId) else {
|
let alarm = alarmService.getAlarm(id: alarmId) else {
|
||||||
@ -113,7 +122,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleStopAction(userInfo: [AnyHashable: Any]) {
|
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 {
|
let alarmId = UUID(uuidString: alarmIdString) else {
|
||||||
Design.debugLog("[general] Could not find alarm ID for stop action")
|
Design.debugLog("[general] Could not find alarm ID for stop action")
|
||||||
return
|
return
|
||||||
@ -129,7 +138,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleNotificationTap(userInfo: [AnyHashable: Any]) {
|
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 {
|
let alarmId = UUID(uuidString: alarmIdString) else {
|
||||||
Design.debugLog("[general] Could not find alarm ID for notification tap")
|
Design.debugLog("[general] Could not find alarm ID for notification tap")
|
||||||
return
|
return
|
||||||
@ -147,13 +156,22 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = snoozeAlarm.label
|
content.title = snoozeAlarm.label
|
||||||
content.body = snoozeAlarm.notificationMessage
|
content.body = snoozeAlarm.notificationMessage
|
||||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
if snoozeAlarm.soundName == "default" {
|
||||||
content.categoryIdentifier = "ALARM_CATEGORY"
|
content.sound = .default
|
||||||
|
} else {
|
||||||
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: snoozeAlarm.soundName))
|
||||||
|
}
|
||||||
|
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||||
content.userInfo = [
|
content.userInfo = [
|
||||||
"alarmId": snoozeAlarm.id.uuidString,
|
AlarmNotificationKeys.alarmId: snoozeAlarm.id.uuidString,
|
||||||
"soundName": snoozeAlarm.soundName,
|
AlarmNotificationKeys.soundName: snoozeAlarm.soundName,
|
||||||
"isSnooze": true,
|
AlarmNotificationKeys.isSnooze: true,
|
||||||
"originalAlarmId": userInfo["alarmId"] as? String ?? ""
|
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
|
// Create trigger for snooze time
|
||||||
@ -177,4 +195,20 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
Design.debugLog("[general] Error scheduling snooze notification: \(error)")
|
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,
|
body: body,
|
||||||
soundName: soundName
|
soundName: soundName
|
||||||
)
|
)
|
||||||
|
content.userInfo = [
|
||||||
|
AlarmNotificationKeys.alarmId: id,
|
||||||
|
AlarmNotificationKeys.soundName: soundName,
|
||||||
|
AlarmNotificationKeys.label: title,
|
||||||
|
AlarmNotificationKeys.notificationMessage: body
|
||||||
|
]
|
||||||
let trigger = NotificationUtils.createCalendarTrigger(for: date)
|
let trigger = NotificationUtils.createCalendarTrigger(for: date)
|
||||||
|
|
||||||
return await NotificationUtils.scheduleNotification(
|
return await NotificationUtils.scheduleNotification(
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import UserNotifications
|
||||||
|
import AudioPlaybackKit
|
||||||
|
|
||||||
/// ViewModel for alarm management
|
/// ViewModel for alarm management
|
||||||
@Observable
|
@Observable
|
||||||
@ -15,6 +17,10 @@ class AlarmViewModel {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private let alarmService: AlarmService
|
private let alarmService: AlarmService
|
||||||
private let notificationService: NotificationService
|
private let notificationService: NotificationService
|
||||||
|
private let alarmSoundService = AlarmSoundService.shared
|
||||||
|
private let soundPlayer = SoundPlayer.shared
|
||||||
|
|
||||||
|
var activeAlarm: Alarm?
|
||||||
|
|
||||||
var alarms: [Alarm] {
|
var alarms: [Alarm] {
|
||||||
alarmService.alarms
|
alarmService.alarms
|
||||||
@ -47,6 +53,7 @@ class AlarmViewModel {
|
|||||||
soundName: alarm.soundName,
|
soundName: alarm.soundName,
|
||||||
date: alarm.time
|
date: alarm.time
|
||||||
)
|
)
|
||||||
|
requestKeepAwakePromptIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +69,7 @@ class AlarmViewModel {
|
|||||||
soundName: alarm.soundName,
|
soundName: alarm.soundName,
|
||||||
date: alarm.time
|
date: alarm.time
|
||||||
)
|
)
|
||||||
|
requestKeepAwakePromptIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
notificationService.cancelNotification(id: alarm.id.uuidString)
|
notificationService.cancelNotification(id: alarm.id.uuidString)
|
||||||
}
|
}
|
||||||
@ -90,6 +98,7 @@ class AlarmViewModel {
|
|||||||
soundName: alarm.soundName,
|
soundName: alarm.soundName,
|
||||||
date: alarm.time
|
date: alarm.time
|
||||||
)
|
)
|
||||||
|
requestKeepAwakePromptIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
notificationService.cancelNotification(id: id.uuidString)
|
notificationService.cancelNotification(id: id.uuidString)
|
||||||
}
|
}
|
||||||
@ -126,4 +135,157 @@ class AlarmViewModel {
|
|||||||
func requestNotificationPermissions() async -> Bool {
|
func requestNotificationPermissions() async -> Bool {
|
||||||
return await notificationService.requestPermissions()
|
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 SwiftUI
|
||||||
import AudioPlaybackKit
|
import AudioPlaybackKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// View for creating new alarms with iOS-native style interface
|
/// View for creating new alarms with iOS-native style interface
|
||||||
struct AddAlarmView: View {
|
struct AddAlarmView: View {
|
||||||
@ -14,6 +15,7 @@ struct AddAlarmView: View {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
let viewModel: AlarmViewModel
|
let viewModel: AlarmViewModel
|
||||||
@Binding var isPresented: Bool
|
@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 newAlarmTime = Calendar.current.date(bySettingHour: 6, minute: 0, second: 0, of: Date()) ?? Date()
|
||||||
@State private var selectedSoundName = "digital-alarm.caf"
|
@State private var selectedSoundName = "digital-alarm.caf"
|
||||||
@ -33,6 +35,15 @@ struct AddAlarmView: View {
|
|||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
List {
|
||||||
|
if !isKeepAwakeEnabled {
|
||||||
|
Section {
|
||||||
|
AlarmLimitationsBanner()
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Label Section
|
// Label Section
|
||||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -127,4 +138,11 @@ struct AddAlarmView: View {
|
|||||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
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 SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// Main alarm management view
|
/// Main alarm management view
|
||||||
struct AlarmView: View {
|
struct AlarmView: View {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
@State private var viewModel = AlarmViewModel()
|
@Bindable var viewModel: AlarmViewModel
|
||||||
@State private var showAddAlarm = false
|
@State private var showAddAlarm = false
|
||||||
@State private var selectedAlarmForEdit: Alarm?
|
@State private var selectedAlarmForEdit: Alarm?
|
||||||
|
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
Group {
|
Group {
|
||||||
if viewModel.alarms.isEmpty {
|
if viewModel.alarms.isEmpty {
|
||||||
EmptyAlarmsView {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
showAddAlarm = true
|
if !isKeepAwakeEnabled {
|
||||||
}
|
AlarmLimitationsBanner()
|
||||||
.contentShape(Rectangle())
|
}
|
||||||
.onTapGesture {
|
|
||||||
showAddAlarm = true
|
EmptyAlarmsView {
|
||||||
|
showAddAlarm = true
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
showAddAlarm = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
|
if !isKeepAwakeEnabled {
|
||||||
|
Section {
|
||||||
|
AlarmLimitationsBanner()
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(viewModel.alarms) { alarm in
|
ForEach(viewModel.alarms) { alarm in
|
||||||
AlarmRowView(
|
AlarmRowView(
|
||||||
alarm: alarm,
|
alarm: alarm,
|
||||||
@ -47,6 +64,7 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
.onDelete(perform: deleteAlarm)
|
.onDelete(perform: deleteAlarm)
|
||||||
}
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
@ -67,6 +85,7 @@ struct AlarmView: View {
|
|||||||
Task {
|
Task {
|
||||||
await viewModel.requestNotificationPermissions()
|
await viewModel.requestNotificationPermissions()
|
||||||
}
|
}
|
||||||
|
viewModel.requestKeepAwakePromptIfNeeded()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddAlarm) {
|
.sheet(isPresented: $showAddAlarm) {
|
||||||
AddAlarmView(
|
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
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
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 SwiftUI
|
||||||
import AudioPlaybackKit
|
import AudioPlaybackKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// View for editing existing alarms
|
/// View for editing existing alarms
|
||||||
struct EditAlarmView: View {
|
struct EditAlarmView: View {
|
||||||
@ -15,6 +16,7 @@ struct EditAlarmView: View {
|
|||||||
let viewModel: AlarmViewModel
|
let viewModel: AlarmViewModel
|
||||||
let alarm: Alarm
|
let alarm: Alarm
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@AppStorage(ClockStyle.appStorageKey) private var clockStyleData: Data = Data()
|
||||||
|
|
||||||
@State private var alarmTime: Date
|
@State private var alarmTime: Date
|
||||||
@State private var selectedSoundName: String
|
@State private var selectedSoundName: String
|
||||||
@ -51,6 +53,15 @@ struct EditAlarmView: View {
|
|||||||
|
|
||||||
// List for settings below
|
// List for settings below
|
||||||
List {
|
List {
|
||||||
|
if !isKeepAwakeEnabled {
|
||||||
|
Section {
|
||||||
|
AlarmLimitationsBanner()
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Label Section
|
// Label Section
|
||||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -147,6 +158,13 @@ struct EditAlarmView: View {
|
|||||||
private func getSoundDisplayName(_ fileName: String) -> String {
|
private func getSoundDisplayName(_ fileName: String) -> String {
|
||||||
return AlarmSoundService.shared.getSoundDisplayName(fileName)
|
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
|
// MARK: - Preview
|
||||||
|
|||||||
@ -61,20 +61,32 @@ class ClockViewModel {
|
|||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
func toggleDisplayMode() {
|
func toggleDisplayMode() {
|
||||||
|
let oldValue = isDisplayMode
|
||||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||||
isDisplayMode.toggle()
|
isDisplayMode.toggle()
|
||||||
}
|
}
|
||||||
|
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
|
||||||
|
|
||||||
// Manage wake lock based on display mode and keep awake setting
|
// Manage wake lock based on display mode and keep awake setting
|
||||||
updateWakeLockState()
|
updateWakeLockState()
|
||||||
|
if isDisplayMode {
|
||||||
|
requestKeepAwakePromptIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDisplayMode(_ enabled: Bool) {
|
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)) {
|
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||||
isDisplayMode = enabled
|
isDisplayMode = enabled
|
||||||
}
|
}
|
||||||
updateWakeLockState()
|
updateWakeLockState()
|
||||||
|
if enabled {
|
||||||
|
requestKeepAwakePromptIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStyle(_ newStyle: ClockStyle) {
|
func updateStyle(_ newStyle: ClockStyle) {
|
||||||
@ -116,6 +128,12 @@ class ClockViewModel {
|
|||||||
updateBrightness() // Update brightness when style changes
|
updateBrightness() // Update brightness when style changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setKeepAwakeEnabled(_ enabled: Bool) {
|
||||||
|
style.keepAwake = enabled
|
||||||
|
saveStyle()
|
||||||
|
updateWakeLockState()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
private func loadStyle() {
|
private func loadStyle() {
|
||||||
if let decoded = try? JSONDecoder().decode(ClockStyle.self, from: styleJSON) {
|
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
|
/// Update wake lock state based on current settings
|
||||||
private func updateWakeLockState() {
|
private func updateWakeLockState() {
|
||||||
// Enable wake lock if in display mode and keep awake is enabled
|
// 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 digitColor: Color = .white
|
||||||
@State private var backgroundColor: Color = .black
|
@State private var backgroundColor: Color = .black
|
||||||
@State private var showAdvancedSettings = false
|
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
init(
|
init(
|
||||||
@ -42,34 +41,17 @@ struct ClockSettingsView: View {
|
|||||||
backgroundColor: $backgroundColor
|
backgroundColor: $backgroundColor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FontSection(style: $style)
|
||||||
|
|
||||||
|
AdvancedAppearanceSection(style: $style)
|
||||||
|
|
||||||
BasicDisplaySection(style: $style)
|
BasicDisplaySection(style: $style)
|
||||||
|
|
||||||
if showAdvancedSettings {
|
AdvancedDisplaySection(style: $style)
|
||||||
AdvancedAppearanceSection(style: $style)
|
|
||||||
|
|
||||||
FontSection(style: $style)
|
NightModeSection(style: $style)
|
||||||
|
|
||||||
NightModeSection(style: $style)
|
OverlaySection(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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
SettingsSectionHeader(
|
SettingsSectionHeader(
|
||||||
|
|||||||
@ -18,6 +18,7 @@ struct ClockView: View {
|
|||||||
@Bindable var viewModel: ClockViewModel
|
@Bindable var viewModel: ClockViewModel
|
||||||
@State private var idleTimer: Timer?
|
@State private var idleTimer: Timer?
|
||||||
@State private var didHandleTouch = false
|
@State private var didHandleTouch = false
|
||||||
|
@State private var isViewActive = false
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -65,15 +66,15 @@ struct ClockView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
// .onAppear {
|
||||||
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
// logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
||||||
}
|
// }
|
||||||
.onChange(of: geometry.size) { _, newSize in
|
// .onChange(of: geometry.size) { _, newSize in
|
||||||
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
// logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
||||||
}
|
// }
|
||||||
.onChange(of: safeInsets) { _, newInsets in
|
// .onChange(of: safeInsets) { _, newInsets in
|
||||||
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
// logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
@ -94,9 +95,13 @@ struct ClockView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
Design.debugLog("[ClockView] onAppear - setting isViewActive = true")
|
||||||
|
isViewActive = true
|
||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer")
|
||||||
|
isViewActive = false
|
||||||
idleTimer?.invalidate()
|
idleTimer?.invalidate()
|
||||||
idleTimer = nil
|
idleTimer = nil
|
||||||
}
|
}
|
||||||
@ -121,7 +126,16 @@ struct ClockView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enterDisplayModeFromIdle() {
|
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()
|
viewModel.toggleDisplayMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Component that manages tab bar visibility for display mode
|
/// Component that manages tab bar visibility for display mode
|
||||||
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
|
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
|
||||||
@ -18,6 +19,12 @@ struct ClockTabBarManager: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
|
|
||||||
|
|||||||
@ -263,9 +263,9 @@ struct TimeDisplayView: View {
|
|||||||
height: max(1, availableHeight / digitRows)
|
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 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 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 digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||||||
|
|
||||||
return FontUtils.calculateOptimalFontSize(
|
return FontUtils.calculateOptimalFontSize(
|
||||||
digit: "8",
|
digit: "8",
|
||||||
@ -308,17 +308,17 @@ struct TimeDisplayView: View {
|
|||||||
if totalWidth > containerSize.width {
|
if totalWidth > containerSize.width {
|
||||||
let scaleFactor = containerSize.width / totalWidth
|
let scaleFactor = containerSize.width / totalWidth
|
||||||
estimated *= scaleFactor * 0.98 // Add 2% margin
|
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 {
|
if abs(estimated - fontSize) > 1 {
|
||||||
fontSize = estimated
|
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 {
|
} 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
|
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()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title
|
content.title = title
|
||||||
content.body = body
|
content.body = body
|
||||||
|
content.categoryIdentifier = AlarmNotificationConstants.categoryIdentifier
|
||||||
|
|
||||||
if soundName == "default" {
|
if soundName == "default" {
|
||||||
content.sound = UNNotificationSound.default
|
content.sound = UNNotificationSound.default
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user