Compare commits
2 Commits
3844e19b39
...
6a154f00bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a154f00bc | |||
| e959060af5 |
@ -19,7 +19,7 @@ public class SoundPlayer {
|
||||
// MARK: - Properties
|
||||
private var players: [String: AVAudioPlayer] = [:]
|
||||
private var currentPlayer: AVAudioPlayer?
|
||||
private var currentSound: Sound?
|
||||
public private(set) var currentSound: Sound?
|
||||
private var shouldResumeAfterInterruption = false
|
||||
private let wakeLockService = WakeLockService.shared
|
||||
private let soundConfigurationService = SoundConfigurationService.shared
|
||||
@ -102,13 +102,17 @@ public class SoundPlayer {
|
||||
public func stopSound() {
|
||||
currentPlayer?.stop()
|
||||
currentPlayer = nil
|
||||
currentSound = nil
|
||||
shouldResumeAfterInterruption = false
|
||||
|
||||
// Disable wake lock when stopping audio
|
||||
wakeLockService.disableWakeLock()
|
||||
}
|
||||
|
||||
public func clearCurrentSound() {
|
||||
stopSound()
|
||||
currentSound = nil
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Helper method to get URL for sound file, handling bundles and direct paths
|
||||
|
||||
@ -19,6 +19,8 @@ public class SoundViewModel {
|
||||
public var isPreviewing: Bool = false
|
||||
public var previewSound: Sound?
|
||||
|
||||
public var selectedSound: Sound?
|
||||
|
||||
public var isPlaying: Bool {
|
||||
soundPlayer.isPlaying
|
||||
}
|
||||
@ -49,6 +51,8 @@ public class SoundViewModel {
|
||||
}
|
||||
// Stop any preview
|
||||
stopPreview()
|
||||
|
||||
selectedSound = sound
|
||||
}
|
||||
|
||||
// MARK: - Preview Functionality
|
||||
|
||||
15
PRD.md
15
PRD.md
@ -47,17 +47,18 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
||||
|
||||
### 3. Display Modes
|
||||
- **Normal mode**: Standard interface with navigation and settings
|
||||
- **Display mode**: Full-screen clock activated by long-press (0.6 seconds)
|
||||
- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode
|
||||
- **Automatic UI hiding**: Tab bar and navigation elements automatically hide after 5 seconds of inactivity on the Clock tab
|
||||
- **Interaction**: Any tap on the screen restores the UI and resets the idle timer
|
||||
- **iPad compatibility**: Uses SwiftUI's native `.toolbar(.hidden, for: .tabBar)` for proper iPad sidebar-style tab bar hiding
|
||||
- **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
|
||||
- **Smooth transitions**: Animated transitions between modes
|
||||
- **Status bar control**: Status bar hidden on the Clock tab (including full-screen mode)
|
||||
- **Status bar control**: Status bar hidden on the Clock tab
|
||||
- **Safe area expansion**: Clock expands into tab bar area when hidden
|
||||
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
|
||||
- **Orientation handling**: Full-screen mode works in both portrait and landscape
|
||||
- **Keep awake functionality**: Optional screen wake lock to prevent device sleep in display mode
|
||||
- **Battery optimization**: Wake lock automatically disabled when exiting display mode
|
||||
- **Keep awake functionality**: Optional screen wake lock to prevent device sleep when the app is active
|
||||
- **Battery optimization**: Wake lock automatically managed based on app state
|
||||
- **Clock Integrations**: Optional mini-controls for active alarms and white noise playback directly on the clock face
|
||||
|
||||
### 4. Information Overlays
|
||||
- **Battery level display**: Real-time battery percentage with dynamic icon
|
||||
@ -567,8 +568,8 @@ The following changes **automatically require** PRD updates:
|
||||
### Clock Tab
|
||||
1. **View time**: Real-time clock display
|
||||
2. **Access settings**: Tap gear icon in navigation bar
|
||||
3. **Enter display mode**: Long-press anywhere on clock (0.6 seconds)
|
||||
4. **Exit display mode**: Long-press again to return to normal mode
|
||||
3. **Automatic Full-Screen**: UI automatically hides after 5 seconds of inactivity
|
||||
4. **Restore UI**: Tap anywhere to bring back the navigation and tab bar
|
||||
|
||||
### Settings
|
||||
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
||||
|
||||
@ -30,6 +30,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Clock tab hides the status bar for a distraction-free display
|
||||
- Selectable animation styles: None, Spring, Bounce, and Glitch
|
||||
- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons
|
||||
- Automatic full-screen: UI fades out after 5 seconds of inactivity, tap to restore
|
||||
- Optional mini-controls for alarms and white noise directly on the clock face
|
||||
|
||||
**White Noise**
|
||||
- Multiple ambient categories and curated sound packs
|
||||
@ -48,8 +50,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Snooze support via AlarmKit's countdown feature
|
||||
|
||||
**Display Mode**
|
||||
- Long-press to enter immersive display mode
|
||||
- Auto-hides navigation and status bar
|
||||
- Automatic full-screen display after 5 seconds of inactivity
|
||||
- Tap anywhere to restore navigation and status bar
|
||||
- Optional wake-lock to keep the screen on
|
||||
|
||||
### What's New
|
||||
|
||||
@ -94,10 +94,10 @@ struct ContentView: View {
|
||||
.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")
|
||||
// Safety net: also explicitly disable display mode when leaving clock tab
|
||||
Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false")
|
||||
// Safety net: also explicitly disable full-screen mode when leaving clock tab
|
||||
// The ClockView's toolbar modifier already responds to isOnClockTab changing
|
||||
clockViewModel.setDisplayMode(false)
|
||||
clockViewModel.setFullScreenMode(false)
|
||||
}
|
||||
}
|
||||
.accentColor(AppAccent.primary)
|
||||
|
||||
@ -18,6 +18,9 @@ import Bedrock
|
||||
@Observable
|
||||
class AlarmService {
|
||||
|
||||
// MARK: - Singleton
|
||||
static let shared = AlarmService()
|
||||
|
||||
// MARK: - Properties
|
||||
private(set) var alarms: [Alarm] = []
|
||||
private var alarmLookup: [UUID: Int] = [:]
|
||||
@ -37,6 +40,7 @@ class AlarmService {
|
||||
alarms.append(alarm)
|
||||
updateAlarmLookup()
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
||||
@ -49,6 +53,7 @@ class AlarmService {
|
||||
alarms[index] = alarm
|
||||
updateAlarmLookup()
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
||||
@ -57,6 +62,7 @@ class AlarmService {
|
||||
alarms.removeAll { $0.id == id }
|
||||
updateAlarmLookup()
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
||||
@ -65,6 +71,7 @@ class AlarmService {
|
||||
alarms[index].isEnabled.toggle()
|
||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||
saveAlarms()
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
}
|
||||
|
||||
func getAlarm(id: UUID) -> Alarm? {
|
||||
@ -129,7 +136,11 @@ class AlarmService {
|
||||
}
|
||||
|
||||
/// Get all enabled alarms (for rescheduling with AlarmKit)
|
||||
func getEnabledAlarms() -> [Alarm] {
|
||||
var enabledAlarms: [Alarm] {
|
||||
return alarms.filter { $0.isEnabled }
|
||||
}
|
||||
|
||||
func getEnabledAlarms() -> [Alarm] {
|
||||
return enabledAlarms
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ class AlarmViewModel {
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
init(alarmService: AlarmService = AlarmService()) {
|
||||
init(alarmService: AlarmService = AlarmService.shared) {
|
||||
self.alarmService = alarmService
|
||||
}
|
||||
|
||||
|
||||
@ -21,52 +21,53 @@ struct AlarmView: View {
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
Group {
|
||||
if viewModel.alarms.isEmpty {
|
||||
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 {
|
||||
ZStack {
|
||||
AppSurface.primary.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
if viewModel.alarms.isEmpty {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
if !isKeepAwakeEnabled {
|
||||
AlarmLimitationsBanner()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
EmptyAlarmsView {
|
||||
showAddAlarm = true
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showAddAlarm = true
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.alarms) { alarm in
|
||||
AlarmRowView(
|
||||
alarm: alarm,
|
||||
onToggle: {
|
||||
Task {
|
||||
await viewModel.toggleAlarm(id: alarm.id)
|
||||
}
|
||||
},
|
||||
onEdit: {
|
||||
selectedAlarmForEdit = alarm
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
if !isKeepAwakeEnabled {
|
||||
AlarmLimitationsBanner()
|
||||
}
|
||||
)
|
||||
|
||||
ForEach(viewModel.alarms) { alarm in
|
||||
AlarmRowView(
|
||||
alarm: alarm,
|
||||
onToggle: {
|
||||
Task {
|
||||
await viewModel.toggleAlarm(id: alarm.id)
|
||||
}
|
||||
},
|
||||
onEdit: {
|
||||
selectedAlarmForEdit = alarm
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.large)
|
||||
}
|
||||
.onDelete(perform: deleteAlarm)
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isPad ? "" : "Alarms")
|
||||
|
||||
@ -20,43 +20,50 @@ struct AlarmRowView: View {
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text(alarm.formattedTime())
|
||||
.font(.headline)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
|
||||
Text(alarm.label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
|
||||
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
|
||||
if alarm.isEnabled && !isKeepAwakeEnabled {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
Text("Foreground only for full alarm sound")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text(alarm.formattedTime())
|
||||
.font(.headline)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
|
||||
Text(alarm.label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
|
||||
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
|
||||
if alarm.isEnabled && !isKeepAwakeEnabled {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
Text("Foreground only for full alarm sound")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
SettingsToggle(
|
||||
title: "",
|
||||
subtitle: "",
|
||||
isOn: Binding(
|
||||
get: { alarm.isEnabled },
|
||||
set: { _ in onToggle() }
|
||||
),
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
.labelsHidden()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onEdit()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { alarm.isEnabled },
|
||||
set: { _ in onToggle() }
|
||||
))
|
||||
.labelsHidden()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onEdit()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,19 +16,53 @@ struct EmptyAlarmsView: View {
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
// Icon
|
||||
Image(systemName: "alarm")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Spacer()
|
||||
|
||||
// Instructional text
|
||||
Text("Create an alarm to begin")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
// Icon with subtle animation
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: "alarm.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.symbolEffect(.bounce, value: true)
|
||||
}
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("No Alarms Set")
|
||||
.typography(.title2Bold)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
|
||||
Text("Create an alarm to wake up gently on your own terms.")
|
||||
.typography(.body)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// Primary Action Button
|
||||
Button(action: onAddAlarm) {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
Text("Add Your First Alarm")
|
||||
}
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
.shadow(color: AppAccent.primary.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.clear)
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,15 +14,22 @@ struct LabelEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
TextField("Alarm Label", text: $label)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.contentPadding(horizontal: Design.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
Form {
|
||||
Section {
|
||||
TextField("Alarm Label", text: $label)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
} footer: {
|
||||
Text("Enter a name for your alarm.")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.listRowBackground(AppSurface.card)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppSurface.primary.ignoresSafeArea())
|
||||
.navigationTitle("Label")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.contentPadding(vertical: Design.Spacing.large)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,37 +14,24 @@ struct NotificationMessageEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
TextField("Notification message", text: $message)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.contentPadding(horizontal: Design.Spacing.large)
|
||||
|
||||
// Preview section
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text("Preview:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Alarm")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(message.isEmpty ? "Your alarm is ringing" : message)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
Form {
|
||||
Section {
|
||||
TextEditor(text: $message)
|
||||
.frame(minHeight: 100)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
} footer: {
|
||||
Text("This message will appear when the alarm rings.")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.contentPadding(horizontal: Design.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
.listRowBackground(AppSurface.card)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppSurface.primary.ignoresSafeArea())
|
||||
.navigationTitle("Message")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.contentPadding(vertical: Design.Spacing.large)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// View for selecting snooze duration
|
||||
struct SnoozeSelectionView: View {
|
||||
@ -20,6 +21,7 @@ struct SnoozeSelectionView: View {
|
||||
ForEach(snoozeOptions, id: \.self) { duration in
|
||||
HStack {
|
||||
Text("\(duration) minutes")
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
Spacer()
|
||||
if snoozeDuration == duration {
|
||||
Image(systemName: "checkmark")
|
||||
@ -27,12 +29,16 @@ struct SnoozeSelectionView: View {
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.listRowBackground(AppSurface.card)
|
||||
.onTapGesture {
|
||||
snoozeDuration = duration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppSurface.primary.ignoresSafeArea())
|
||||
.navigationTitle("Snooze")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import AudioPlaybackKit
|
||||
|
||||
/// View for selecting alarm sounds with preview functionality
|
||||
@ -27,6 +28,7 @@ struct SoundSelectionView: View {
|
||||
HStack {
|
||||
Text(sound.name)
|
||||
.font(.body)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
Spacer()
|
||||
if selectedSound == sound.fileName {
|
||||
Image(systemName: "checkmark")
|
||||
@ -34,6 +36,7 @@ struct SoundSelectionView: View {
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.listRowBackground(AppSurface.card)
|
||||
.onTapGesture {
|
||||
// Stop any currently playing sound when selecting a new one
|
||||
if isPlaying {
|
||||
@ -44,6 +47,9 @@ struct SoundSelectionView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppSurface.primary.ignoresSafeArea())
|
||||
.navigationTitle("Sound")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
@ -28,8 +28,9 @@ struct TimePickerSection: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 200)
|
||||
.onOrientationChange() // Force updates on orientation changes
|
||||
}
|
||||
|
||||
@ -25,8 +25,9 @@ struct TimeUntilAlarmSection: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
|
||||
private var timeUntilAlarm: String {
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import AudioPlaybackKit
|
||||
import Foundation
|
||||
|
||||
@ -69,11 +70,13 @@ struct EditAlarmView: View {
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Label")
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
Text(alarmLabel)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(AppSurface.card)
|
||||
|
||||
// Notification Message Section
|
||||
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
|
||||
@ -82,12 +85,14 @@ struct EditAlarmView: View {
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Message")
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
Text(notificationMessage)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.listRowBackground(AppSurface.card)
|
||||
|
||||
// Sound Section
|
||||
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
|
||||
@ -96,11 +101,13 @@ struct EditAlarmView: View {
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Sound")
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
Text(getSoundDisplayName(selectedSoundName))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(AppSurface.card)
|
||||
|
||||
// Snooze Section
|
||||
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
||||
@ -109,13 +116,17 @@ struct EditAlarmView: View {
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Snooze")
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
Text("for \(snoozeDuration) min")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(AppSurface.card)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppSurface.primary.ignoresSafeArea())
|
||||
}
|
||||
.navigationTitle("Edit Alarm")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@ -46,6 +46,8 @@ class ClockStyle: Codable, Equatable {
|
||||
// MARK: - Overlay Settings
|
||||
var showBattery: Bool = true
|
||||
var showDate: Bool = true
|
||||
var showNextAlarm: Bool = true
|
||||
var showNoiseControls: Bool = true
|
||||
var dateFormat: String = "d MMMM EEE" // Default: "7 September Mon"
|
||||
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
||||
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
||||
@ -83,6 +85,8 @@ class ClockStyle: Codable, Equatable {
|
||||
case digitAnimationStyle
|
||||
case showBattery
|
||||
case showDate
|
||||
case showNextAlarm
|
||||
case showNoiseControls
|
||||
case dateFormat
|
||||
case clockOpacity
|
||||
case overlayOpacity
|
||||
@ -135,6 +139,8 @@ class ClockStyle: Codable, Equatable {
|
||||
}
|
||||
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
|
||||
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
|
||||
self.showNextAlarm = try container.decodeIfPresent(Bool.self, forKey: .showNextAlarm) ?? self.showNextAlarm
|
||||
self.showNoiseControls = try container.decodeIfPresent(Bool.self, forKey: .showNoiseControls) ?? self.showNoiseControls
|
||||
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
|
||||
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
|
||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
||||
@ -169,6 +175,8 @@ class ClockStyle: Codable, Equatable {
|
||||
try container.encode(digitAnimationStyle.rawValue, forKey: .digitAnimationStyle)
|
||||
try container.encode(showBattery, forKey: .showBattery)
|
||||
try container.encode(showDate, forKey: .showDate)
|
||||
try container.encode(showNextAlarm, forKey: .showNextAlarm)
|
||||
try container.encode(showNoiseControls, forKey: .showNoiseControls)
|
||||
try container.encode(dateFormat, forKey: .dateFormat)
|
||||
try container.encode(clockOpacity, forKey: .clockOpacity)
|
||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||
@ -463,6 +471,8 @@ class ClockStyle: Codable, Equatable {
|
||||
lhs.digitAnimationStyle == rhs.digitAnimationStyle &&
|
||||
lhs.showBattery == rhs.showBattery &&
|
||||
lhs.showDate == rhs.showDate &&
|
||||
lhs.showNextAlarm == rhs.showNextAlarm &&
|
||||
lhs.showNoiseControls == rhs.showNoiseControls &&
|
||||
lhs.dateFormat == rhs.dateFormat &&
|
||||
lhs.clockOpacity == rhs.clockOpacity &&
|
||||
lhs.overlayOpacity == rhs.overlayOpacity &&
|
||||
|
||||
@ -19,7 +19,7 @@ class ClockViewModel {
|
||||
// MARK: - Properties
|
||||
private(set) var currentTime = Date()
|
||||
private(set) var style = ClockStyle()
|
||||
private(set) var isDisplayMode = false
|
||||
private(set) var isFullScreenMode = false
|
||||
|
||||
// Wake lock service
|
||||
private let wakeLockService = WakeLockService.shared
|
||||
@ -65,28 +65,28 @@ class ClockViewModel {
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
func toggleDisplayMode() {
|
||||
let oldValue = isDisplayMode
|
||||
func toggleFullScreenMode() {
|
||||
let oldValue = isFullScreenMode
|
||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||
isDisplayMode.toggle()
|
||||
isFullScreenMode.toggle()
|
||||
}
|
||||
Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)")
|
||||
Design.debugLog("[ClockViewModel] toggleFullScreenMode: \(oldValue) -> \(isFullScreenMode)")
|
||||
|
||||
// Manage wake lock based on display mode and keep awake setting
|
||||
// Manage wake lock based on full-screen mode and keep awake setting
|
||||
updateWakeLockState()
|
||||
if isDisplayMode {
|
||||
if isFullScreenMode {
|
||||
requestKeepAwakePromptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func setDisplayMode(_ enabled: Bool) {
|
||||
guard isDisplayMode != enabled else {
|
||||
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping")
|
||||
func setFullScreenMode(_ enabled: Bool) {
|
||||
guard isFullScreenMode != enabled else {
|
||||
Design.debugLog("[ClockViewModel] setFullScreenMode(\(enabled)) - already at this value, skipping")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)")
|
||||
Design.debugLog("[ClockViewModel] setFullScreenMode: \(isFullScreenMode) -> \(enabled)")
|
||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||
isDisplayMode = enabled
|
||||
isFullScreenMode = enabled
|
||||
}
|
||||
updateWakeLockState()
|
||||
if enabled {
|
||||
@ -110,6 +110,8 @@ class ClockViewModel {
|
||||
style.fontDesign = newStyle.fontDesign
|
||||
style.showBattery = newStyle.showBattery
|
||||
style.showDate = newStyle.showDate
|
||||
style.showNextAlarm = newStyle.showNextAlarm
|
||||
style.showNoiseControls = newStyle.showNoiseControls
|
||||
style.overlayOpacity = newStyle.overlayOpacity
|
||||
style.backgroundHex = newStyle.backgroundHex
|
||||
style.keepAwake = newStyle.keepAwake
|
||||
@ -237,8 +239,8 @@ class ClockViewModel {
|
||||
|
||||
/// Update wake lock state based on current settings
|
||||
private func updateWakeLockState() {
|
||||
// Enable wake lock if in display mode and keep awake is enabled
|
||||
if isDisplayMode && style.keepAwake {
|
||||
// Enable wake lock if in full-screen mode and keep awake is enabled
|
||||
if isFullScreenMode && style.keepAwake {
|
||||
wakeLockService.enableWakeLock()
|
||||
} else {
|
||||
wakeLockService.disableWakeLock()
|
||||
|
||||
@ -24,9 +24,9 @@ struct ClockView: View {
|
||||
|
||||
/// Tab bar should ONLY be hidden when BOTH conditions are true:
|
||||
/// 1. We're on the clock tab (prevents hiding when user switches away)
|
||||
/// 2. Display mode is active
|
||||
/// 2. Full-screen mode is active
|
||||
private var shouldHideTabBar: Bool {
|
||||
isOnClockTab && viewModel.isDisplayMode
|
||||
isOnClockTab && viewModel.isFullScreenMode
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
@ -54,7 +54,7 @@ struct ClockView: View {
|
||||
ClockDisplayContainer(
|
||||
currentTime: viewModel.currentTime,
|
||||
style: viewModel.style,
|
||||
isDisplayMode: viewModel.isDisplayMode
|
||||
isFullScreenMode: viewModel.isFullScreenMode
|
||||
)
|
||||
.padding(.leading, symmetricInset)
|
||||
.padding(.trailing, symmetricInset)
|
||||
@ -92,7 +92,7 @@ struct ClockView: View {
|
||||
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
|
||||
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
|
||||
.onChange(of: shouldHideTabBar) { oldValue, newValue in
|
||||
Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isDisplayMode=\(viewModel.isDisplayMode))")
|
||||
Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isFullScreenMode=\(viewModel.isFullScreenMode))")
|
||||
}
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
@ -116,8 +116,8 @@ struct ClockView: View {
|
||||
idleTimer?.invalidate()
|
||||
idleTimer = nil
|
||||
}
|
||||
.onChange(of: viewModel.isDisplayMode) { _, isDisplayMode in
|
||||
if isDisplayMode {
|
||||
.onChange(of: viewModel.isFullScreenMode) { _, isFullScreenMode in
|
||||
if isFullScreenMode {
|
||||
idleTimer?.invalidate()
|
||||
idleTimer = nil
|
||||
} else {
|
||||
@ -130,29 +130,29 @@ struct ClockView: View {
|
||||
private func resetIdleTimer() {
|
||||
idleTimer?.invalidate()
|
||||
idleTimer = nil
|
||||
guard !viewModel.isDisplayMode else { return }
|
||||
guard !viewModel.isFullScreenMode else { return }
|
||||
idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
||||
enterDisplayModeFromIdle()
|
||||
enterFullScreenFromIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private func enterDisplayModeFromIdle() {
|
||||
// Guard against entering display mode if we're no longer on the clock tab
|
||||
private func enterFullScreenFromIdle() {
|
||||
// Guard against entering full-screen if we're no longer on the clock tab
|
||||
guard isViewActive else {
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: view is not active (user switched tabs)")
|
||||
Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: view is not active (user switched tabs)")
|
||||
return
|
||||
}
|
||||
guard !viewModel.isDisplayMode else {
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode")
|
||||
guard !viewModel.isFullScreenMode else {
|
||||
Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: already in full-screen")
|
||||
return
|
||||
}
|
||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode")
|
||||
viewModel.toggleDisplayMode()
|
||||
Design.debugLog("[ClockView] enterFullScreenFromIdle - entering full-screen")
|
||||
viewModel.toggleFullScreenMode()
|
||||
}
|
||||
|
||||
private func handleUserInteraction() {
|
||||
if viewModel.isDisplayMode {
|
||||
viewModel.toggleDisplayMode()
|
||||
if viewModel.isFullScreenMode {
|
||||
viewModel.toggleFullScreenMode()
|
||||
}
|
||||
resetIdleTimer()
|
||||
}
|
||||
|
||||
@ -14,13 +14,13 @@ struct ClockDisplayContainer: View {
|
||||
// MARK: - Properties
|
||||
let currentTime: Date
|
||||
let style: ClockStyle
|
||||
let isDisplayMode: Bool
|
||||
let isFullScreenMode: Bool
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let isPortrait = geometry.size.height >= geometry.size.width
|
||||
let hasOverlay = style.showBattery || style.showDate
|
||||
let hasOverlay = style.showBattery || style.showDate || style.showNextAlarm || style.showNoiseControls
|
||||
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
|
||||
|
||||
// Time display - fills all available space
|
||||
@ -36,13 +36,13 @@ struct ClockDisplayContainer: View {
|
||||
fontWeight: style.fontWeight,
|
||||
fontDesign: style.fontDesign,
|
||||
forceHorizontalMode: style.forceHorizontalMode,
|
||||
isDisplayMode: isDisplayMode,
|
||||
isDisplayMode: isFullScreenMode,
|
||||
animationStyle: style.digitAnimationStyle
|
||||
)
|
||||
.padding(.top, topSpacing)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.transition(.opacity)
|
||||
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
|
||||
.animation(.smooth(duration: Design.Animation.standard), value: isFullScreenMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -52,7 +52,7 @@ struct ClockDisplayContainer: View {
|
||||
ClockDisplayContainer(
|
||||
currentTime: Date(),
|
||||
style: ClockStyle(),
|
||||
isDisplayMode: false
|
||||
isFullScreenMode: false
|
||||
)
|
||||
.frame(width: 400, height: 600)
|
||||
.background(Color.black)
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
//
|
||||
// ClockGestureHandler.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Component that handles gesture interactions for the clock view
|
||||
struct ClockGestureHandler: View {
|
||||
|
||||
// MARK: - Properties
|
||||
let onLongPress: () -> Void
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
EmptyView()
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
LongPressGesture(minimumDuration: AppConstants.DisplayMode.longPressDuration)
|
||||
.onEnded { _ in
|
||||
onLongPress()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
ClockGestureHandler(onLongPress: {})
|
||||
}
|
||||
@ -17,13 +17,15 @@ struct ClockOverlayContainer: View {
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
VStack {
|
||||
if style.showBattery || style.showDate {
|
||||
if style.showBattery || style.showDate || style.showNextAlarm || style.showNoiseControls {
|
||||
TopOverlayView(
|
||||
showBattery: style.showBattery,
|
||||
showDate: style.showDate,
|
||||
color: style.effectiveDigitColor,
|
||||
opacity: style.clockOpacity,
|
||||
dateFormat: style.dateFormat
|
||||
dateFormat: style.dateFormat,
|
||||
showNextAlarm: style.showNextAlarm,
|
||||
showNoiseControls: style.showNoiseControls
|
||||
)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
//
|
||||
// NextAlarmOverlay.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Component for displaying the next scheduled alarm on the clock face
|
||||
struct NextAlarmOverlay: View {
|
||||
|
||||
// MARK: - Properties
|
||||
let alarmTime: Date?
|
||||
let color: Color
|
||||
let opacity: Double
|
||||
|
||||
private var alarmString: String {
|
||||
guard let time = alarmTime else { return "" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: time)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
Group {
|
||||
if let _ = alarmTime {
|
||||
HStack(spacing: Design.Spacing.xxSmall) {
|
||||
Image(systemName: "alarm.fill")
|
||||
.font(.caption)
|
||||
|
||||
Text(alarmString)
|
||||
.typography(.calloutEmphasis)
|
||||
}
|
||||
.foregroundColor(color)
|
||||
.opacity(opacity)
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(AppSurface.overlay.opacity(0.3))
|
||||
.cornerRadius(Design.CornerRadius.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NextAlarmOverlay(
|
||||
alarmTime: Date().addingTimeInterval(3600),
|
||||
color: .white,
|
||||
opacity: 0.8
|
||||
)
|
||||
.background(Color.black)
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
//
|
||||
// NoiseMiniPlayer.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import AudioPlaybackKit
|
||||
|
||||
/// Compact mini-player for white noise on the clock face
|
||||
struct NoiseMiniPlayer: View {
|
||||
|
||||
// MARK: - Properties
|
||||
let isPlaying: Bool
|
||||
let soundName: String?
|
||||
let color: Color
|
||||
let opacity: Double
|
||||
let onToggle: () -> Void
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
Group {
|
||||
if let name = soundName {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Button(action: onToggle) {
|
||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(isPlaying ? AppAccent.primary.opacity(0.8) : AppAccent.primary.opacity(0.8))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(isPlaying ? "Playing" : "Paused")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(color.opacity(0.6))
|
||||
.textCase(.uppercase)
|
||||
|
||||
Text(name)
|
||||
.typography(.calloutEmphasis)
|
||||
.foregroundColor(color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.leading, Design.Spacing.xxSmall)
|
||||
.padding(.trailing, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(AppSurface.overlay.opacity(0.3))
|
||||
.cornerRadius(Design.CornerRadius.appLarge)
|
||||
.opacity(opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
NoiseMiniPlayer(
|
||||
isPlaying: true,
|
||||
soundName: "Heavy Rain",
|
||||
color: .white,
|
||||
opacity: 0.9,
|
||||
onToggle: {}
|
||||
)
|
||||
|
||||
NoiseMiniPlayer(
|
||||
isPlaying: false,
|
||||
soundName: "White Noise",
|
||||
color: .white,
|
||||
opacity: 0.9,
|
||||
onToggle: {}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
@ -20,47 +20,62 @@ struct AdvancedAppearanceSection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text("Digit Animation")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Picker("Digit Animation", selection: $style.digitAnimationStyle) {
|
||||
ForEach(DigitAnimationStyle.allCases, id: \.self) { animation in
|
||||
Text(animation.displayName).tag(animation)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
SettingsNavigationRow(
|
||||
title: "Digit Animation",
|
||||
subtitle: style.digitAnimationStyle.displayName,
|
||||
backgroundColor: .clear
|
||||
) {
|
||||
SettingsSelectionView(
|
||||
selection: $style.digitAnimationStyle,
|
||||
options: DigitAnimationStyle.allCases,
|
||||
title: "Digit Animation",
|
||||
toString: { $0.displayName }
|
||||
)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.tint(AppAccent.primary)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Randomize Color",
|
||||
subtitle: "Shift the color every minute",
|
||||
isOn: $style.randomizeColor,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsSlider(
|
||||
title: "Glow",
|
||||
subtitle: "Adjust the glow intensity",
|
||||
value: $style.glowIntensity,
|
||||
in: 0.0...1.0,
|
||||
step: 0.01,
|
||||
format: SliderFormat.percentage,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsSlider(
|
||||
title: "Clock Opacity",
|
||||
subtitle: "Set the clock transparency",
|
||||
value: $style.clockOpacity,
|
||||
in: 0.0...1.0,
|
||||
step: 0.01,
|
||||
format: SliderFormat.percentage,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Randomize Color",
|
||||
subtitle: "Shift the color every minute",
|
||||
isOn: $style.randomizeColor,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsSlider(
|
||||
title: "Glow",
|
||||
subtitle: "Adjust the glow intensity",
|
||||
value: $style.glowIntensity,
|
||||
in: 0.0...1.0,
|
||||
step: 0.01,
|
||||
format: SliderFormat.percentage,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsSlider(
|
||||
title: "Clock Opacity",
|
||||
subtitle: "Set the clock transparency",
|
||||
value: $style.clockOpacity,
|
||||
in: 0.0...1.0,
|
||||
step: 0.01,
|
||||
format: SliderFormat.percentage,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
|
||||
Text("Fine-tune the visual appearance of your clock.")
|
||||
|
||||
@ -20,29 +20,43 @@ struct AdvancedDisplaySection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
SettingsToggle(
|
||||
title: "Keep Awake",
|
||||
subtitle: "Prevent sleep in display mode",
|
||||
isOn: $style.keepAwake,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
VStack(spacing: 0) {
|
||||
SettingsToggle(
|
||||
title: "Keep Awake",
|
||||
subtitle: "Prevent sleep in display mode",
|
||||
isOn: $style.keepAwake,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Live Activities",
|
||||
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
|
||||
isOn: $style.liveActivitiesEnabled,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
if style.autoBrightness {
|
||||
HStack {
|
||||
Text("Current Brightness")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
Text("\(Int(style.effectiveBrightness * 100))%")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
SettingsToggle(
|
||||
title: "Live Activities",
|
||||
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
|
||||
isOn: $style.liveActivitiesEnabled,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if style.autoBrightness {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
HStack {
|
||||
Text("Current Brightness")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
Text("\(Int(style.effectiveBrightness * 100))%")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,38 +22,49 @@ struct BasicAppearanceSection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Color Theme")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Picker("Color Theme", selection: $style.selectedColorTheme) {
|
||||
ForEach(ClockStyle.availableColorThemes(), id: \.0) { theme in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(themeColor(for: theme.0))
|
||||
.frame(width: 20, height: 20)
|
||||
Text(theme.1)
|
||||
}
|
||||
.tag(theme.0)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.onChange(of: style.selectedColorTheme) { _, newTheme in
|
||||
if newTheme != "Custom" {
|
||||
style.applyColorTheme(newTheme)
|
||||
digitColor = Color(hex: style.digitColorHex) ?? .white
|
||||
backgroundColor = Color(hex: style.backgroundHex) ?? .black
|
||||
VStack(spacing: 0) {
|
||||
SettingsNavigationRow(
|
||||
title: "Color Theme",
|
||||
subtitle: style.selectedColorTheme,
|
||||
backgroundColor: .clear
|
||||
) {
|
||||
SettingsSelectionView(
|
||||
selection: $style.selectedColorTheme,
|
||||
options: ClockStyle.availableColorThemes().map { $0.0 },
|
||||
title: "Color Theme",
|
||||
toString: { theme in
|
||||
ClockStyle.availableColorThemes().first(where: { $0.0 == theme })?.1 ?? theme
|
||||
}
|
||||
)
|
||||
}
|
||||
.onChange(of: style.selectedColorTheme) { _, newTheme in
|
||||
if newTheme != "Custom" {
|
||||
style.applyColorTheme(newTheme)
|
||||
digitColor = Color(hex: style.digitColorHex) ?? .white
|
||||
backgroundColor = Color(hex: style.backgroundHex) ?? .black
|
||||
}
|
||||
}
|
||||
|
||||
if style.selectedColorTheme == "Custom" {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,43 +20,65 @@ struct BasicDisplaySection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
SettingsToggle(
|
||||
title: "24‑Hour Format",
|
||||
subtitle: "Use military time",
|
||||
isOn: $style.use24Hour,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Show Seconds",
|
||||
subtitle: "Display seconds in the clock",
|
||||
isOn: $style.showSeconds,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if !style.use24Hour {
|
||||
VStack(spacing: 0) {
|
||||
SettingsToggle(
|
||||
title: "Show AM/PM",
|
||||
subtitle: "Add an AM/PM indicator",
|
||||
isOn: $style.showAmPm,
|
||||
title: "24‑Hour Format",
|
||||
subtitle: "Use military time",
|
||||
isOn: $style.use24Hour,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
|
||||
SettingsToggle(
|
||||
title: "Auto Brightness",
|
||||
subtitle: "Adapt brightness to ambient light",
|
||||
isOn: $style.autoBrightness,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown {
|
||||
SettingsToggle(
|
||||
title: "Horizontal Mode",
|
||||
subtitle: "Force a wide layout in portrait",
|
||||
isOn: $style.forceHorizontalMode,
|
||||
title: "Show Seconds",
|
||||
subtitle: "Display seconds in the clock",
|
||||
isOn: $style.showSeconds,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if !style.use24Hour {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Show AM/PM",
|
||||
subtitle: "Add an AM/PM indicator",
|
||||
isOn: $style.showAmPm,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Auto Brightness",
|
||||
subtitle: "Adapt brightness to ambient light",
|
||||
isOn: $style.autoBrightness,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Horizontal Mode",
|
||||
subtitle: "Force a wide layout in portrait",
|
||||
isOn: $style.forceHorizontalMode,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -37,58 +37,76 @@ struct FontSection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Family")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Picker("Family", selection: $style.fontFamily) {
|
||||
ForEach(sortedFontFamilies, id: \.self) { family in
|
||||
Text(family.rawValue).tag(family)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Font Family
|
||||
SettingsNavigationRow(
|
||||
title: "Family",
|
||||
subtitle: style.fontFamily.rawValue,
|
||||
backgroundColor: .clear
|
||||
) {
|
||||
SettingsSelectionView(
|
||||
selection: $style.fontFamily,
|
||||
options: sortedFontFamilies,
|
||||
title: "Font Family",
|
||||
toString: { $0.rawValue }
|
||||
)
|
||||
}
|
||||
.onChange(of: style.fontFamily) { _, newFamily in
|
||||
if newFamily != .system {
|
||||
style.fontDesign = .default
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.onChange(of: style.fontFamily) { _, newFamily in
|
||||
if newFamily != .system {
|
||||
style.fontDesign = .default
|
||||
}
|
||||
|
||||
let weights = newFamily == .system ? Font.Weight.allCases : newFamily.fontWeights
|
||||
if !weights.contains(style.fontWeight) {
|
||||
style.fontWeight = weights.first ?? .regular
|
||||
}
|
||||
let weights = newFamily == .system ? Font.Weight.allCases : newFamily.fontWeights
|
||||
if !weights.contains(style.fontWeight) {
|
||||
style.fontWeight = weights.first ?? .regular
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Weight")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
Picker("Weight", selection: $style.fontWeight) {
|
||||
ForEach(availableWeights, id: \.self) { weight in
|
||||
Text(weight.rawValue).tag(weight)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
// Font Weight
|
||||
SettingsNavigationRow(
|
||||
title: "Weight",
|
||||
subtitle: style.fontWeight.rawValue,
|
||||
backgroundColor: .clear
|
||||
) {
|
||||
SettingsSelectionView(
|
||||
selection: $style.fontWeight,
|
||||
options: availableWeights,
|
||||
title: "Font Weight",
|
||||
toString: { $0.rawValue }
|
||||
)
|
||||
}
|
||||
|
||||
if style.fontFamily == .system {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Design")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
Picker("Design", selection: $style.fontDesign) {
|
||||
ForEach(Font.Design.allCases, id: \.self) { design in
|
||||
Text(design.rawValue).tag(design)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
// Font Design
|
||||
SettingsNavigationRow(
|
||||
title: "Design",
|
||||
subtitle: style.fontDesign.rawValue,
|
||||
backgroundColor: .clear
|
||||
) {
|
||||
SettingsSelectionView(
|
||||
selection: $style.fontDesign,
|
||||
options: Font.Design.allCases,
|
||||
title: "Font Design",
|
||||
toString: { $0.rawValue }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
HStack {
|
||||
Text("Preview")
|
||||
.font(.subheadline.weight(.medium))
|
||||
@ -98,6 +116,7 @@ struct FontSection: View {
|
||||
.font(FontUtils.createFont(name: style.fontFamily, weight: style.fontWeight, design: style.fontDesign, size: 24))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,65 +20,103 @@ struct NightModeSection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
SettingsToggle(
|
||||
title: "Enable Night Mode",
|
||||
subtitle: "Use a red clock for low light",
|
||||
isOn: $style.nightModeEnabled,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Auto Night Mode",
|
||||
subtitle: "Trigger based on ambient light",
|
||||
isOn: $style.autoNightMode,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if style.autoNightMode {
|
||||
SettingsSlider(
|
||||
title: "Light Threshold",
|
||||
subtitle: "Lower values activate sooner",
|
||||
value: $style.ambientLightThreshold,
|
||||
in: 0.1...0.8,
|
||||
step: 0.01,
|
||||
format: SliderFormat.percentage,
|
||||
VStack(spacing: 0) {
|
||||
SettingsToggle(
|
||||
title: "Enable Night Mode",
|
||||
subtitle: "Use a red clock for low light",
|
||||
isOn: $style.nightModeEnabled,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
|
||||
SettingsToggle(
|
||||
title: "Scheduled Night Mode",
|
||||
subtitle: "Enable on a daily schedule",
|
||||
isOn: $style.scheduledNightMode,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
if style.scheduledNightMode {
|
||||
HStack {
|
||||
Text("Start Time")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
TimePickerView(timeString: $style.nightModeStartTime)
|
||||
SettingsToggle(
|
||||
title: "Auto Night Mode",
|
||||
subtitle: "Trigger based on ambient light",
|
||||
isOn: $style.autoNightMode,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if style.autoNightMode {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsSlider(
|
||||
title: "Light Threshold",
|
||||
subtitle: "Lower values activate sooner",
|
||||
value: $style.ambientLightThreshold,
|
||||
in: 0.1...0.8,
|
||||
step: 0.01,
|
||||
format: SliderFormat.percentage,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("End Time")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
TimePickerView(timeString: $style.nightModeEndTime)
|
||||
}
|
||||
}
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
if style.isNightModeActive {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "moon.fill")
|
||||
.foregroundStyle(AppStatus.error)
|
||||
Text("Night Mode Active")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppStatus.error)
|
||||
Spacer()
|
||||
SettingsToggle(
|
||||
title: "Scheduled Night Mode",
|
||||
subtitle: "Enable on a daily schedule",
|
||||
isOn: $style.scheduledNightMode,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if style.scheduledNightMode {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
HStack {
|
||||
Text("Start Time")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
TimePickerView(timeString: $style.nightModeStartTime)
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
HStack {
|
||||
Text("End Time")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
TimePickerView(timeString: $style.nightModeEndTime)
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
}
|
||||
|
||||
if style.isNightModeActive {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "moon.fill")
|
||||
.foregroundStyle(AppStatus.error)
|
||||
Text("Night Mode Active")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppStatus.error)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,32 +22,70 @@ struct OverlaySection: View {
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
SettingsToggle(
|
||||
title: "Battery Level",
|
||||
subtitle: "Show battery percentage",
|
||||
isOn: $style.showBattery,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
VStack(spacing: 0) {
|
||||
SettingsToggle(
|
||||
title: "Battery Level",
|
||||
subtitle: "Show battery percentage",
|
||||
isOn: $style.showBattery,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Date",
|
||||
subtitle: "Display the current date",
|
||||
isOn: $style.showDate,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
if style.showDate {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Date Format")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
SettingsToggle(
|
||||
title: "Date",
|
||||
subtitle: "Display the current date",
|
||||
isOn: $style.showDate,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
Picker("Date Format", selection: $style.dateFormat) {
|
||||
ForEach(dateFormats, id: \.1) { format in
|
||||
Text(format.0).tag(format.1)
|
||||
}
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Next Alarm",
|
||||
subtitle: "Show your next scheduled alarm",
|
||||
isOn: $style.showNextAlarm,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsToggle(
|
||||
title: "Noise Controls",
|
||||
subtitle: "Mini-player for white noise",
|
||||
isOn: $style.showNoiseControls,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if style.showDate {
|
||||
Rectangle()
|
||||
.fill(AppBorder.subtle)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
|
||||
SettingsNavigationRow(
|
||||
title: "Date Format",
|
||||
subtitle: style.dateFormat,
|
||||
backgroundColor: .clear
|
||||
) {
|
||||
SettingsSelectionView(
|
||||
selection: $style.dateFormat,
|
||||
options: dateFormats.map { $0.1 },
|
||||
title: "Date Format",
|
||||
toString: { format in
|
||||
dateFormats.first(where: { $0.1 == format })?.0 ?? format
|
||||
}
|
||||
)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import AudioPlaybackKit
|
||||
|
||||
/// Component for displaying top overlay with battery and date information
|
||||
struct TopOverlayView: View {
|
||||
@ -17,30 +18,76 @@ struct TopOverlayView: View {
|
||||
let color: Color
|
||||
let opacity: Double
|
||||
let dateFormat: String
|
||||
let showNextAlarm: Bool
|
||||
let showNoiseControls: Bool
|
||||
|
||||
@State private var batteryService = BatteryService.shared
|
||||
private var batteryService: BatteryService { .shared }
|
||||
private var alarmService: AlarmService { .shared }
|
||||
private var soundPlayer: SoundPlayer { .shared }
|
||||
|
||||
@State private var clockUpdateTrigger = false
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
HStack {
|
||||
if showDate {
|
||||
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
|
||||
let _ = clockUpdateTrigger // Force re-render on style or alarm changes
|
||||
let _ = alarmService.alarms // Observe all alarms for changes
|
||||
let _ = soundPlayer.isPlaying // Observe player state
|
||||
|
||||
let _ = print("TopOverlayView: Rendering. Alarms count: \(alarmService.alarms.count), Enabled: \(alarmService.enabledAlarms.count)")
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
HStack {
|
||||
if showDate {
|
||||
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if showBattery {
|
||||
BatteryOverlayView(
|
||||
color: color,
|
||||
opacity: opacity,
|
||||
batteryLevel: batteryService.batteryLevel,
|
||||
isCharging: batteryService.isCharging
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if showBattery {
|
||||
BatteryOverlayView(
|
||||
color: color,
|
||||
opacity: opacity,
|
||||
batteryLevel: batteryService.batteryLevel,
|
||||
isCharging: batteryService.isCharging
|
||||
)
|
||||
HStack(alignment: .top) {
|
||||
if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first {
|
||||
NextAlarmOverlay(
|
||||
alarmTime: nextAlarm.time.nextOccurrence(),
|
||||
color: color,
|
||||
opacity: opacity
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if showNoiseControls, let sound = soundPlayer.currentSound {
|
||||
NoiseMiniPlayer(
|
||||
isPlaying: soundPlayer.isPlaying,
|
||||
soundName: sound.name,
|
||||
color: color,
|
||||
opacity: opacity,
|
||||
onToggle: {
|
||||
if soundPlayer.isPlaying {
|
||||
soundPlayer.stopSound()
|
||||
} else {
|
||||
soundPlayer.playSound(sound)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.transition(.opacity)
|
||||
.id(clockUpdateTrigger) // Force re-render on style or alarm changes
|
||||
.onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in
|
||||
clockUpdateTrigger.toggle()
|
||||
}
|
||||
.onAppear {
|
||||
batteryService.startMonitoring()
|
||||
}
|
||||
|
||||
@ -98,7 +98,7 @@ struct SoundCategoryView: View {
|
||||
}
|
||||
|
||||
private var soundGrid: some View {
|
||||
LazyVStack(spacing: Design.Spacing.small) {
|
||||
LazyVStack(spacing: Design.Spacing.medium) {
|
||||
ForEach(filteredSounds) { sound in
|
||||
SoundCard(
|
||||
sound: sound,
|
||||
@ -114,7 +114,8 @@ struct SoundCategoryView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.bottom, Design.Spacing.xxLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,19 +130,26 @@ struct CategoryTab: View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.styled(.subheadingEmphasis)
|
||||
|
||||
if count > 0 {
|
||||
Text("(\(count))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(count)")
|
||||
.styled(.caption, emphasis: .secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(isSelected ? Color.white.opacity(0.2) : AppSurface.secondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(isSelected ? AppAccent.primary : Color(.systemGray6))
|
||||
.background(isSelected ? AppAccent.primary : AppSurface.card)
|
||||
.foregroundColor(isSelected ? .white : AppTextColors.primary)
|
||||
.cornerRadius(20)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.stroke(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -155,68 +163,45 @@ struct SoundCard: View {
|
||||
let onPreview: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
// Sound Icon (Left)
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSelected ? AppAccent.primary : Color(.systemGray5))
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
Image(systemName: soundIcon)
|
||||
.font(.title3)
|
||||
.foregroundColor(isSelected ? .white : AppTextColors.primary)
|
||||
|
||||
if isPreviewing {
|
||||
Circle()
|
||||
.stroke(AppAccent.primary, lineWidth: 2)
|
||||
.frame(width: 58, height: 58)
|
||||
.scaleEffect(1.02)
|
||||
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing)
|
||||
}
|
||||
}
|
||||
|
||||
// Sound Info (Right)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Sound Name
|
||||
Text(sound.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
// Description
|
||||
Text(sound.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
// Category Badge
|
||||
HStack {
|
||||
Text(sound.category.capitalized)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(AppAccent.primary, in: Capsule())
|
||||
Button(action: onSelect) {
|
||||
SettingsCard(
|
||||
backgroundColor: isSelected ? AppAccent.primary.opacity(0.15) : AppSurface.card,
|
||||
borderColor: isSelected ? AppAccent.primary : AppBorder.subtle
|
||||
) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
// Sound Icon (Left)
|
||||
ZStack {
|
||||
Image(systemName: soundIcon)
|
||||
.font(.title3)
|
||||
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||
|
||||
if isPreviewing {
|
||||
Circle()
|
||||
.stroke(AppAccent.primary, lineWidth: 2)
|
||||
.frame(width: 40, height: 40)
|
||||
.scaleEffect(1.05)
|
||||
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing)
|
||||
}
|
||||
}
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
// Sound Info (Center)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sound.name)
|
||||
.styled(.subheadingEmphasis)
|
||||
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(sound.description)
|
||||
.styled(.caption, emphasis: .secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isSelected ? AppAccent.primary.opacity(0.1) : Color(.systemBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected ? AppAccent.primary : Color.clear, lineWidth: 2)
|
||||
)
|
||||
)
|
||||
.onTapGesture {
|
||||
onSelect()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onLongPressGesture {
|
||||
onPreview()
|
||||
}
|
||||
|
||||
@ -30,21 +30,25 @@ struct NoiseView: View {
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let isLandscape = geometry.size.width > geometry.size.height
|
||||
let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
|
||||
ZStack {
|
||||
AppSurface.primary.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
if isLandscape {
|
||||
// Landscape layout: Player on left, sounds on right
|
||||
landscapeLayout
|
||||
} else {
|
||||
// Portrait layout: Stacked vertically
|
||||
portraitLayout
|
||||
GeometryReader { geometry in
|
||||
let isLandscape = geometry.size.width > geometry.size.height
|
||||
let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
|
||||
|
||||
Group {
|
||||
if isLandscape {
|
||||
// Landscape layout: Player on left, sounds on right
|
||||
landscapeLayout
|
||||
} else {
|
||||
// Portrait layout: Stacked vertically
|
||||
portraitLayout
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: maxWidth)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.frame(maxWidth: maxWidth)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
||||
.searchable(
|
||||
@ -79,23 +83,42 @@ struct NoiseView: View {
|
||||
soundControlView
|
||||
.centered()
|
||||
} else {
|
||||
// Placeholder when no sound selected
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
// Placeholder when no sound selected - Enhanced for CRO
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(0.1))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "waveform")
|
||||
.font(.title)
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
}
|
||||
|
||||
Text("Select a sound to begin")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(spacing: 4) {
|
||||
Text("Ready for Sleep?")
|
||||
.typography(.title3Bold)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
|
||||
Text("Select a soothing sound below to begin your relaxation journey.")
|
||||
.typography(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
.background(AppSurface.overlay, in: RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge)
|
||||
.stroke(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentPadding(horizontal: Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.large)
|
||||
.background(Color(.systemBackground))
|
||||
.background(AppSurface.primary)
|
||||
|
||||
// Scrollable sound selection
|
||||
ScrollView {
|
||||
@ -106,6 +129,7 @@ struct NoiseView: View {
|
||||
)
|
||||
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,18 +145,37 @@ struct NoiseView: View {
|
||||
if selectedSound != nil {
|
||||
soundControlView
|
||||
} else {
|
||||
// Placeholder when no sound selected
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
// Placeholder when no sound selected - Enhanced for CRO
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(0.1))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "waveform")
|
||||
.font(.title)
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
}
|
||||
|
||||
Text("Select a sound to begin")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(spacing: 4) {
|
||||
Text("Ready for Sleep?")
|
||||
.typography(.title3Bold)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
|
||||
Text("Select a soothing sound to begin.")
|
||||
.typography(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
.background(AppSurface.overlay, in: RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge)
|
||||
.stroke(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@ -151,6 +194,7 @@ struct NoiseView: View {
|
||||
)
|
||||
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.contentPadding(horizontal: Design.Spacing.medium)
|
||||
|
||||
@ -26,7 +26,7 @@ struct OnboardingView: View {
|
||||
@State private var keepAwakeEnabled = false
|
||||
@State private var showCelebration = false
|
||||
|
||||
private let totalPages = 3
|
||||
private let totalPages = 4
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
@ -42,11 +42,14 @@ struct OnboardingView: View {
|
||||
welcomeWithClockPage
|
||||
.tag(0)
|
||||
|
||||
permissionsPage
|
||||
whiteNoisePage
|
||||
.tag(1)
|
||||
|
||||
getStartedPage
|
||||
permissionsPage
|
||||
.tag(2)
|
||||
|
||||
getStartedPage
|
||||
.tag(3)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
||||
@ -71,8 +74,10 @@ struct OnboardingView: View {
|
||||
Spacer()
|
||||
|
||||
// Live clock preview - immediate value using TimelineView
|
||||
liveClockPreview
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||
OnboardingClockText(date: context.date)
|
||||
}
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
|
||||
Text("The Noise Clock")
|
||||
.typography(.heroBold)
|
||||
@ -93,8 +98,8 @@ struct OnboardingView: View {
|
||||
text: "Wake up gently, on your terms"
|
||||
)
|
||||
featureHighlight(
|
||||
icon: "hand.tap.fill",
|
||||
text: "Long-press for immersive mode"
|
||||
icon: "clock.fill",
|
||||
text: "Automatic full-screen display"
|
||||
)
|
||||
}
|
||||
.padding(.top, Design.Spacing.large)
|
||||
@ -104,12 +109,6 @@ struct OnboardingView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var liveClockPreview: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||
OnboardingClockText(date: context.date)
|
||||
}
|
||||
}
|
||||
|
||||
private func featureHighlight(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
@ -126,7 +125,18 @@ struct OnboardingView: View {
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// MARK: - Page 2: AlarmKit Permissions
|
||||
// MARK: - Page 2: White Noise
|
||||
|
||||
private var whiteNoisePage: some View {
|
||||
OnboardingPageView(
|
||||
icon: "waveform",
|
||||
iconColor: AppAccent.primary,
|
||||
title: "Soothing Sounds",
|
||||
description: "Choose from a variety of white noise, rain, and ambient sounds to help you drift off to sleep."
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Page 3: AlarmKit Permissions
|
||||
|
||||
private var permissionsPage: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
@ -255,7 +265,7 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 3: Get Started (Quick Win)
|
||||
// MARK: - Page 4: Get Started (Quick Win)
|
||||
|
||||
private var getStartedPage: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
@ -276,7 +286,7 @@ struct OnboardingView: View {
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Your alarms will work even in silent mode and Focus mode. Try long-pressing the clock for immersive mode!")
|
||||
Text("Your alarms will work even in silent mode and Focus mode. The interface will automatically fade out to give you a clean view of the time!")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@ -285,7 +295,7 @@ struct OnboardingView: View {
|
||||
// Quick tips
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
tipRow(icon: "alarm.fill", text: "Create your first alarm")
|
||||
tipRow(icon: "hand.tap", text: "Long-press clock for full screen")
|
||||
tipRow(icon: "clock.fill", text: "Wait 5s for full screen")
|
||||
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
@ -402,7 +412,7 @@ struct OnboardingView: View {
|
||||
if granted {
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
withAnimation {
|
||||
currentPage = 2
|
||||
currentPage = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/beep-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/beep-alarm.mp3
Normal file
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/buzzing-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/buzzing-alarm.mp3
Normal file
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/classic-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/classic-alarm.mp3
Normal file
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/digital-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/digital-alarm.mp3
Normal file
Binary file not shown.
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/siren-alarm.mp3
Normal file
BIN
TheNoiseClock/Resources/AlarmSounds.bundle/siren-alarm.mp3
Normal file
Binary file not shown.
83
TheNoiseClock/Shared/Components/SettingsSelectionView.swift
Normal file
83
TheNoiseClock/Shared/Components/SettingsSelectionView.swift
Normal file
@ -0,0 +1,83 @@
|
||||
//
|
||||
// SettingsSelectionView.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Created by Matt Bruce on 9/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// A reusable selection view for settings that navigates to a new screen.
|
||||
struct SettingsSelectionView<T: Hashable>: View {
|
||||
@Binding var selection: T
|
||||
let options: [T]
|
||||
let title: String
|
||||
let toString: (T) -> String
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppSurface.primary.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
SettingsSectionHeader(
|
||||
title: title,
|
||||
systemImage: "checklist",
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Button(action: {
|
||||
selection = option
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Text(toString(option))
|
||||
.typography(.body)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
Spacer()
|
||||
if selection == option {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.font(.body.bold())
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(Color.clear)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if option != options.last {
|
||||
Divider()
|
||||
.background(AppBorder.subtle)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.large)
|
||||
.padding(.bottom, Design.Spacing.xxxLarge)
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
SettingsSelectionView(
|
||||
selection: .constant("Option 1"),
|
||||
options: ["Option 1", "Option 2", "Option 3"],
|
||||
title: "Test Selection",
|
||||
toString: { $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user