Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
3844e19b39
commit
e959060af5
@ -19,7 +19,7 @@ public class SoundPlayer {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private var players: [String: AVAudioPlayer] = [:]
|
private var players: [String: AVAudioPlayer] = [:]
|
||||||
private var currentPlayer: AVAudioPlayer?
|
private var currentPlayer: AVAudioPlayer?
|
||||||
private var currentSound: Sound?
|
public private(set) var currentSound: Sound?
|
||||||
private var shouldResumeAfterInterruption = false
|
private var shouldResumeAfterInterruption = false
|
||||||
private let wakeLockService = WakeLockService.shared
|
private let wakeLockService = WakeLockService.shared
|
||||||
private let soundConfigurationService = SoundConfigurationService.shared
|
private let soundConfigurationService = SoundConfigurationService.shared
|
||||||
@ -102,13 +102,17 @@ public class SoundPlayer {
|
|||||||
public func stopSound() {
|
public func stopSound() {
|
||||||
currentPlayer?.stop()
|
currentPlayer?.stop()
|
||||||
currentPlayer = nil
|
currentPlayer = nil
|
||||||
currentSound = nil
|
|
||||||
shouldResumeAfterInterruption = false
|
shouldResumeAfterInterruption = false
|
||||||
|
|
||||||
// Disable wake lock when stopping audio
|
// Disable wake lock when stopping audio
|
||||||
wakeLockService.disableWakeLock()
|
wakeLockService.disableWakeLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func clearCurrentSound() {
|
||||||
|
stopSound()
|
||||||
|
currentSound = nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
/// Helper method to get URL for sound file, handling bundles and direct paths
|
/// 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 isPreviewing: Bool = false
|
||||||
public var previewSound: Sound?
|
public var previewSound: Sound?
|
||||||
|
|
||||||
|
public var selectedSound: Sound?
|
||||||
|
|
||||||
public var isPlaying: Bool {
|
public var isPlaying: Bool {
|
||||||
soundPlayer.isPlaying
|
soundPlayer.isPlaying
|
||||||
}
|
}
|
||||||
@ -49,6 +51,8 @@ public class SoundViewModel {
|
|||||||
}
|
}
|
||||||
// Stop any preview
|
// Stop any preview
|
||||||
stopPreview()
|
stopPreview()
|
||||||
|
|
||||||
|
selectedSound = sound
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview Functionality
|
// 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
|
### 3. Display Modes
|
||||||
- **Normal mode**: Standard interface with navigation and settings
|
- **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 automatically hide after 5 seconds of inactivity on the Clock tab
|
||||||
- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode
|
- **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
|
- **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)
|
- **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
|
||||||
- **Smooth transitions**: Animated transitions between modes
|
- **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
|
- **Safe area expansion**: Clock expands into tab bar area when hidden
|
||||||
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
|
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
|
||||||
- **Orientation handling**: Full-screen mode works in both portrait and landscape
|
- **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
|
- **Keep awake functionality**: Optional screen wake lock to prevent device sleep when the app is active
|
||||||
- **Battery optimization**: Wake lock automatically disabled when exiting display mode
|
- **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
|
### 4. Information Overlays
|
||||||
- **Battery level display**: Real-time battery percentage with dynamic icon
|
- **Battery level display**: Real-time battery percentage with dynamic icon
|
||||||
@ -567,8 +568,8 @@ The following changes **automatically require** PRD updates:
|
|||||||
### Clock Tab
|
### Clock Tab
|
||||||
1. **View time**: Real-time clock display
|
1. **View time**: Real-time clock display
|
||||||
2. **Access settings**: Tap gear icon in navigation bar
|
2. **Access settings**: Tap gear icon in navigation bar
|
||||||
3. **Enter display mode**: Long-press anywhere on clock (0.6 seconds)
|
3. **Automatic Full-Screen**: UI automatically hides after 5 seconds of inactivity
|
||||||
4. **Exit display mode**: Long-press again to return to normal mode
|
4. **Restore UI**: Tap anywhere to bring back the navigation and tab bar
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
1. **Time format**: Toggle 24-hour, seconds, AM/PM display
|
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
|
- Clock tab hides the status bar for a distraction-free display
|
||||||
- Selectable animation styles: None, Spring, Bounce, and Glitch
|
- Selectable animation styles: None, Spring, Bounce, and Glitch
|
||||||
- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons
|
- 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**
|
**White Noise**
|
||||||
- Multiple ambient categories and curated sound packs
|
- 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
|
- Snooze support via AlarmKit's countdown feature
|
||||||
|
|
||||||
**Display Mode**
|
**Display Mode**
|
||||||
- Long-press to enter immersive display mode
|
- Automatic full-screen display after 5 seconds of inactivity
|
||||||
- Auto-hides navigation and status bar
|
- Tap anywhere to restore navigation and status bar
|
||||||
- Optional wake-lock to keep the screen on
|
- Optional wake-lock to keep the screen on
|
||||||
|
|
||||||
### What's New
|
### What's New
|
||||||
|
|||||||
@ -94,10 +94,10 @@ struct ContentView: View {
|
|||||||
.onChange(of: selectedTab) { oldValue, newValue in
|
.onChange(of: selectedTab) { oldValue, newValue in
|
||||||
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
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")
|
Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false")
|
||||||
// Safety net: also explicitly disable display mode when leaving clock tab
|
// Safety net: also explicitly disable full-screen mode when leaving clock tab
|
||||||
// The ClockView's toolbar modifier already responds to isOnClockTab changing
|
// The ClockView's toolbar modifier already responds to isOnClockTab changing
|
||||||
clockViewModel.setDisplayMode(false)
|
clockViewModel.setFullScreenMode(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accentColor(AppAccent.primary)
|
.accentColor(AppAccent.primary)
|
||||||
|
|||||||
@ -18,6 +18,9 @@ import Bedrock
|
|||||||
@Observable
|
@Observable
|
||||||
class AlarmService {
|
class AlarmService {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
static let shared = AlarmService()
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private(set) var alarms: [Alarm] = []
|
private(set) var alarms: [Alarm] = []
|
||||||
private var alarmLookup: [UUID: Int] = [:]
|
private var alarmLookup: [UUID: Int] = [:]
|
||||||
@ -37,6 +40,7 @@ class AlarmService {
|
|||||||
alarms.append(alarm)
|
alarms.append(alarm)
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
|
||||||
@ -49,6 +53,7 @@ class AlarmService {
|
|||||||
alarms[index] = alarm
|
alarms[index] = alarm
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
|
||||||
@ -57,6 +62,7 @@ class AlarmService {
|
|||||||
alarms.removeAll { $0.id == id }
|
alarms.removeAll { $0.id == id }
|
||||||
updateAlarmLookup()
|
updateAlarmLookup()
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
|
||||||
@ -65,6 +71,7 @@ class AlarmService {
|
|||||||
alarms[index].isEnabled.toggle()
|
alarms[index].isEnabled.toggle()
|
||||||
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
|
||||||
saveAlarms()
|
saveAlarms()
|
||||||
|
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlarm(id: UUID) -> Alarm? {
|
func getAlarm(id: UUID) -> Alarm? {
|
||||||
@ -129,7 +136,11 @@ class AlarmService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all enabled alarms (for rescheduling with AlarmKit)
|
/// Get all enabled alarms (for rescheduling with AlarmKit)
|
||||||
func getEnabledAlarms() -> [Alarm] {
|
var enabledAlarms: [Alarm] {
|
||||||
return alarms.filter { $0.isEnabled }
|
return alarms.filter { $0.isEnabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnabledAlarms() -> [Alarm] {
|
||||||
|
return enabledAlarms
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class AlarmViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(alarmService: AlarmService = AlarmService()) {
|
init(alarmService: AlarmService = AlarmService.shared) {
|
||||||
self.alarmService = alarmService
|
self.alarmService = alarmService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,9 @@ struct AlarmView: View {
|
|||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
|
ZStack {
|
||||||
|
AppSurface.primary.ignoresSafeArea()
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
if viewModel.alarms.isEmpty {
|
if viewModel.alarms.isEmpty {
|
||||||
VStack(spacing: Design.Spacing.large) {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
@ -39,14 +42,10 @@ struct AlarmView: View {
|
|||||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
} else {
|
} else {
|
||||||
List {
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
if !isKeepAwakeEnabled {
|
if !isKeepAwakeEnabled {
|
||||||
Section {
|
|
||||||
AlarmLimitationsBanner()
|
AlarmLimitationsBanner()
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.alarms) { alarm in
|
ForEach(viewModel.alarms) { alarm in
|
||||||
@ -62,13 +61,15 @@ struct AlarmView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteAlarm)
|
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.top, Design.Spacing.large)
|
||||||
|
}
|
||||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.navigationTitle(isPad ? "" : "Alarms")
|
.navigationTitle(isPad ? "" : "Alarms")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct AlarmRowView: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
Text(alarm.formattedTime())
|
Text(alarm.formattedTime())
|
||||||
@ -48,10 +49,15 @@ struct AlarmRowView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Toggle("", isOn: Binding(
|
SettingsToggle(
|
||||||
|
title: "",
|
||||||
|
subtitle: "",
|
||||||
|
isOn: Binding(
|
||||||
get: { alarm.isEnabled },
|
get: { alarm.isEnabled },
|
||||||
set: { _ in onToggle() }
|
set: { _ in onToggle() }
|
||||||
))
|
),
|
||||||
|
accentColor: AppAccent.primary
|
||||||
|
)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@ -59,6 +65,7 @@ struct AlarmRowView: View {
|
|||||||
onEdit()
|
onEdit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var isKeepAwakeEnabled: Bool {
|
private var isKeepAwakeEnabled: Bool {
|
||||||
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {
|
||||||
|
|||||||
@ -16,19 +16,53 @@ struct EmptyAlarmsView: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
// Icon
|
Spacer()
|
||||||
Image(systemName: "alarm")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
// Instructional text
|
// Icon with subtle animation
|
||||||
Text("Create an alarm to begin")
|
ZStack {
|
||||||
.font(.subheadline)
|
Circle()
|
||||||
.foregroundColor(.secondary)
|
.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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.clear)
|
.background(AppSurface.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,15 +14,22 @@ struct LabelEditView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.large) {
|
Form {
|
||||||
|
Section {
|
||||||
TextField("Alarm Label", text: $label)
|
TextField("Alarm Label", text: $label)
|
||||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
.typography(.body)
|
||||||
.contentPadding(horizontal: Design.Spacing.large)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
Spacer()
|
} footer: {
|
||||||
|
Text("Enter a name for your alarm.")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
.navigationTitle("Label")
|
.navigationTitle("Label")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.contentPadding(vertical: Design.Spacing.large)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,37 +14,24 @@ struct NotificationMessageEditView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.large) {
|
Form {
|
||||||
TextField("Notification message", text: $message)
|
Section {
|
||||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
TextEditor(text: $message)
|
||||||
.contentPadding(horizontal: Design.Spacing.large)
|
.frame(minHeight: 100)
|
||||||
|
.typography(.body)
|
||||||
// Preview section
|
.foregroundStyle(AppTextColors.primary)
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
Text("Preview:")
|
} footer: {
|
||||||
.font(.headline)
|
Text("This message will appear when the alarm rings.")
|
||||||
.foregroundColor(.secondary)
|
.typography(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.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()
|
.listRowBackground(AppSurface.card)
|
||||||
.background(Color(.systemGray6))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
.contentPadding(horizontal: Design.Spacing.large)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
.navigationTitle("Message")
|
.navigationTitle("Message")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.contentPadding(vertical: Design.Spacing.large)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// View for selecting snooze duration
|
/// View for selecting snooze duration
|
||||||
struct SnoozeSelectionView: View {
|
struct SnoozeSelectionView: View {
|
||||||
@ -20,6 +21,7 @@ struct SnoozeSelectionView: View {
|
|||||||
ForEach(snoozeOptions, id: \.self) { duration in
|
ForEach(snoozeOptions, id: \.self) { duration in
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(duration) minutes")
|
Text("\(duration) minutes")
|
||||||
|
.foregroundColor(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if snoozeDuration == duration {
|
if snoozeDuration == duration {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
@ -27,12 +29,16 @@ struct SnoozeSelectionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
snoozeDuration = duration
|
snoozeDuration = duration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
.navigationTitle("Snooze")
|
.navigationTitle("Snooze")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
import AudioPlaybackKit
|
import AudioPlaybackKit
|
||||||
|
|
||||||
/// View for selecting alarm sounds with preview functionality
|
/// View for selecting alarm sounds with preview functionality
|
||||||
@ -27,6 +28,7 @@ struct SoundSelectionView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text(sound.name)
|
Text(sound.name)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
.foregroundColor(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if selectedSound == sound.fileName {
|
if selectedSound == sound.fileName {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
@ -34,6 +36,7 @@ struct SoundSelectionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
// Stop any currently playing sound when selecting a new one
|
// Stop any currently playing sound when selecting a new one
|
||||||
if isPlaying {
|
if isPlaying {
|
||||||
@ -44,6 +47,9 @@ struct SoundSelectionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
.navigationTitle("Sound")
|
.navigationTitle("Sound")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@ -28,8 +28,9 @@ struct TimePickerSection: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(AppSurface.primary)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 200)
|
.frame(height: 200)
|
||||||
.onOrientationChange() // Force updates on orientation changes
|
.onOrientationChange() // Force updates on orientation changes
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,8 +25,9 @@ struct TimeUntilAlarmSection: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(AppSurface.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timeUntilAlarm: String {
|
private var timeUntilAlarm: String {
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
import AudioPlaybackKit
|
import AudioPlaybackKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@ -69,11 +70,13 @@ struct EditAlarmView: View {
|
|||||||
.foregroundColor(AppAccent.primary)
|
.foregroundColor(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Label")
|
Text("Label")
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(alarmLabel)
|
Text(alarmLabel)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
|
|
||||||
// Notification Message Section
|
// Notification Message Section
|
||||||
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
|
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
|
||||||
@ -82,12 +85,14 @@ struct EditAlarmView: View {
|
|||||||
.foregroundColor(AppAccent.primary)
|
.foregroundColor(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Message")
|
Text("Message")
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(notificationMessage)
|
Text(notificationMessage)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(AppTextColors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
|
|
||||||
// Sound Section
|
// Sound Section
|
||||||
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
|
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
|
||||||
@ -96,11 +101,13 @@ struct EditAlarmView: View {
|
|||||||
.foregroundColor(AppAccent.primary)
|
.foregroundColor(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Sound")
|
Text("Sound")
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(getSoundDisplayName(selectedSoundName))
|
Text(getSoundDisplayName(selectedSoundName))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
|
|
||||||
// Snooze Section
|
// Snooze Section
|
||||||
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
||||||
@ -109,13 +116,17 @@ struct EditAlarmView: View {
|
|||||||
.foregroundColor(AppAccent.primary)
|
.foregroundColor(AppAccent.primary)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
Text("Snooze")
|
Text("Snooze")
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("for \(snoozeDuration) min")
|
Text("for \(snoozeDuration) min")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(AppSurface.card)
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
}
|
}
|
||||||
.navigationTitle("Edit Alarm")
|
.navigationTitle("Edit Alarm")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@ -46,6 +46,8 @@ class ClockStyle: Codable, Equatable {
|
|||||||
// MARK: - Overlay Settings
|
// MARK: - Overlay Settings
|
||||||
var showBattery: Bool = true
|
var showBattery: Bool = true
|
||||||
var showDate: 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 dateFormat: String = "d MMMM EEE" // Default: "7 September Mon"
|
||||||
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
var clockOpacity: Double = AppConstants.Defaults.clockOpacity
|
||||||
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
|
||||||
@ -83,6 +85,8 @@ class ClockStyle: Codable, Equatable {
|
|||||||
case digitAnimationStyle
|
case digitAnimationStyle
|
||||||
case showBattery
|
case showBattery
|
||||||
case showDate
|
case showDate
|
||||||
|
case showNextAlarm
|
||||||
|
case showNoiseControls
|
||||||
case dateFormat
|
case dateFormat
|
||||||
case clockOpacity
|
case clockOpacity
|
||||||
case overlayOpacity
|
case overlayOpacity
|
||||||
@ -135,6 +139,8 @@ class ClockStyle: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
|
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
|
||||||
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
|
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.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
|
||||||
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
|
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
|
||||||
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity
|
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(digitAnimationStyle.rawValue, forKey: .digitAnimationStyle)
|
||||||
try container.encode(showBattery, forKey: .showBattery)
|
try container.encode(showBattery, forKey: .showBattery)
|
||||||
try container.encode(showDate, forKey: .showDate)
|
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(dateFormat, forKey: .dateFormat)
|
||||||
try container.encode(clockOpacity, forKey: .clockOpacity)
|
try container.encode(clockOpacity, forKey: .clockOpacity)
|
||||||
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
try container.encode(overlayOpacity, forKey: .overlayOpacity)
|
||||||
@ -463,6 +471,8 @@ class ClockStyle: Codable, Equatable {
|
|||||||
lhs.digitAnimationStyle == rhs.digitAnimationStyle &&
|
lhs.digitAnimationStyle == rhs.digitAnimationStyle &&
|
||||||
lhs.showBattery == rhs.showBattery &&
|
lhs.showBattery == rhs.showBattery &&
|
||||||
lhs.showDate == rhs.showDate &&
|
lhs.showDate == rhs.showDate &&
|
||||||
|
lhs.showNextAlarm == rhs.showNextAlarm &&
|
||||||
|
lhs.showNoiseControls == rhs.showNoiseControls &&
|
||||||
lhs.dateFormat == rhs.dateFormat &&
|
lhs.dateFormat == rhs.dateFormat &&
|
||||||
lhs.clockOpacity == rhs.clockOpacity &&
|
lhs.clockOpacity == rhs.clockOpacity &&
|
||||||
lhs.overlayOpacity == rhs.overlayOpacity &&
|
lhs.overlayOpacity == rhs.overlayOpacity &&
|
||||||
|
|||||||
@ -19,7 +19,7 @@ class ClockViewModel {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
private(set) var currentTime = Date()
|
private(set) var currentTime = Date()
|
||||||
private(set) var style = ClockStyle()
|
private(set) var style = ClockStyle()
|
||||||
private(set) var isDisplayMode = false
|
private(set) var isFullScreenMode = false
|
||||||
|
|
||||||
// Wake lock service
|
// Wake lock service
|
||||||
private let wakeLockService = WakeLockService.shared
|
private let wakeLockService = WakeLockService.shared
|
||||||
@ -65,28 +65,28 @@ class ClockViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
func toggleDisplayMode() {
|
func toggleFullScreenMode() {
|
||||||
let oldValue = isDisplayMode
|
let oldValue = isFullScreenMode
|
||||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
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()
|
updateWakeLockState()
|
||||||
if isDisplayMode {
|
if isFullScreenMode {
|
||||||
requestKeepAwakePromptIfNeeded()
|
requestKeepAwakePromptIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDisplayMode(_ enabled: Bool) {
|
func setFullScreenMode(_ enabled: Bool) {
|
||||||
guard isDisplayMode != enabled else {
|
guard isFullScreenMode != enabled else {
|
||||||
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping")
|
Design.debugLog("[ClockViewModel] setFullScreenMode(\(enabled)) - already at this value, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)")
|
Design.debugLog("[ClockViewModel] setFullScreenMode: \(isFullScreenMode) -> \(enabled)")
|
||||||
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||||
isDisplayMode = enabled
|
isFullScreenMode = enabled
|
||||||
}
|
}
|
||||||
updateWakeLockState()
|
updateWakeLockState()
|
||||||
if enabled {
|
if enabled {
|
||||||
@ -110,6 +110,8 @@ class ClockViewModel {
|
|||||||
style.fontDesign = newStyle.fontDesign
|
style.fontDesign = newStyle.fontDesign
|
||||||
style.showBattery = newStyle.showBattery
|
style.showBattery = newStyle.showBattery
|
||||||
style.showDate = newStyle.showDate
|
style.showDate = newStyle.showDate
|
||||||
|
style.showNextAlarm = newStyle.showNextAlarm
|
||||||
|
style.showNoiseControls = newStyle.showNoiseControls
|
||||||
style.overlayOpacity = newStyle.overlayOpacity
|
style.overlayOpacity = newStyle.overlayOpacity
|
||||||
style.backgroundHex = newStyle.backgroundHex
|
style.backgroundHex = newStyle.backgroundHex
|
||||||
style.keepAwake = newStyle.keepAwake
|
style.keepAwake = newStyle.keepAwake
|
||||||
@ -237,8 +239,8 @@ class ClockViewModel {
|
|||||||
|
|
||||||
/// 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 full-screen mode and keep awake is enabled
|
||||||
if isDisplayMode && style.keepAwake {
|
if isFullScreenMode && style.keepAwake {
|
||||||
wakeLockService.enableWakeLock()
|
wakeLockService.enableWakeLock()
|
||||||
} else {
|
} else {
|
||||||
wakeLockService.disableWakeLock()
|
wakeLockService.disableWakeLock()
|
||||||
|
|||||||
@ -24,9 +24,9 @@ struct ClockView: View {
|
|||||||
|
|
||||||
/// Tab bar should ONLY be hidden when BOTH conditions are true:
|
/// Tab bar should ONLY be hidden when BOTH conditions are true:
|
||||||
/// 1. We're on the clock tab (prevents hiding when user switches away)
|
/// 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 {
|
private var shouldHideTabBar: Bool {
|
||||||
isOnClockTab && viewModel.isDisplayMode
|
isOnClockTab && viewModel.isFullScreenMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
@ -54,7 +54,7 @@ struct ClockView: View {
|
|||||||
ClockDisplayContainer(
|
ClockDisplayContainer(
|
||||||
currentTime: viewModel.currentTime,
|
currentTime: viewModel.currentTime,
|
||||||
style: viewModel.style,
|
style: viewModel.style,
|
||||||
isDisplayMode: viewModel.isDisplayMode
|
isFullScreenMode: viewModel.isFullScreenMode
|
||||||
)
|
)
|
||||||
.padding(.leading, symmetricInset)
|
.padding(.leading, symmetricInset)
|
||||||
.padding(.trailing, symmetricInset)
|
.padding(.trailing, symmetricInset)
|
||||||
@ -92,7 +92,7 @@ struct ClockView: View {
|
|||||||
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
|
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
|
||||||
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
|
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
|
||||||
.onChange(of: shouldHideTabBar) { oldValue, newValue in
|
.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(
|
.simultaneousGesture(
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 0)
|
||||||
@ -116,8 +116,8 @@ struct ClockView: View {
|
|||||||
idleTimer?.invalidate()
|
idleTimer?.invalidate()
|
||||||
idleTimer = nil
|
idleTimer = nil
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.isDisplayMode) { _, isDisplayMode in
|
.onChange(of: viewModel.isFullScreenMode) { _, isFullScreenMode in
|
||||||
if isDisplayMode {
|
if isFullScreenMode {
|
||||||
idleTimer?.invalidate()
|
idleTimer?.invalidate()
|
||||||
idleTimer = nil
|
idleTimer = nil
|
||||||
} else {
|
} else {
|
||||||
@ -130,29 +130,29 @@ struct ClockView: View {
|
|||||||
private func resetIdleTimer() {
|
private func resetIdleTimer() {
|
||||||
idleTimer?.invalidate()
|
idleTimer?.invalidate()
|
||||||
idleTimer = nil
|
idleTimer = nil
|
||||||
guard !viewModel.isDisplayMode else { return }
|
guard !viewModel.isFullScreenMode else { return }
|
||||||
idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
||||||
enterDisplayModeFromIdle()
|
enterFullScreenFromIdle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enterDisplayModeFromIdle() {
|
private func enterFullScreenFromIdle() {
|
||||||
// Guard against entering display mode if we're no longer on the clock tab
|
// Guard against entering full-screen if we're no longer on the clock tab
|
||||||
guard isViewActive else {
|
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
|
return
|
||||||
}
|
}
|
||||||
guard !viewModel.isDisplayMode else {
|
guard !viewModel.isFullScreenMode else {
|
||||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode")
|
Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: already in full-screen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode")
|
Design.debugLog("[ClockView] enterFullScreenFromIdle - entering full-screen")
|
||||||
viewModel.toggleDisplayMode()
|
viewModel.toggleFullScreenMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleUserInteraction() {
|
private func handleUserInteraction() {
|
||||||
if viewModel.isDisplayMode {
|
if viewModel.isFullScreenMode {
|
||||||
viewModel.toggleDisplayMode()
|
viewModel.toggleFullScreenMode()
|
||||||
}
|
}
|
||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,13 +14,13 @@ struct ClockDisplayContainer: View {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
let currentTime: Date
|
let currentTime: Date
|
||||||
let style: ClockStyle
|
let style: ClockStyle
|
||||||
let isDisplayMode: Bool
|
let isFullScreenMode: Bool
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let isPortrait = geometry.size.height >= geometry.size.width
|
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
|
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
|
||||||
|
|
||||||
// Time display - fills all available space
|
// Time display - fills all available space
|
||||||
@ -36,13 +36,13 @@ struct ClockDisplayContainer: View {
|
|||||||
fontWeight: style.fontWeight,
|
fontWeight: style.fontWeight,
|
||||||
fontDesign: style.fontDesign,
|
fontDesign: style.fontDesign,
|
||||||
forceHorizontalMode: style.forceHorizontalMode,
|
forceHorizontalMode: style.forceHorizontalMode,
|
||||||
isDisplayMode: isDisplayMode,
|
isDisplayMode: isFullScreenMode,
|
||||||
animationStyle: style.digitAnimationStyle
|
animationStyle: style.digitAnimationStyle
|
||||||
)
|
)
|
||||||
.padding(.top, topSpacing)
|
.padding(.top, topSpacing)
|
||||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
.transition(.opacity)
|
.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(
|
ClockDisplayContainer(
|
||||||
currentTime: Date(),
|
currentTime: Date(),
|
||||||
style: ClockStyle(),
|
style: ClockStyle(),
|
||||||
isDisplayMode: false
|
isFullScreenMode: false
|
||||||
)
|
)
|
||||||
.frame(width: 400, height: 600)
|
.frame(width: 400, height: 600)
|
||||||
.background(Color.black)
|
.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
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
if style.showBattery || style.showDate {
|
if style.showBattery || style.showDate || style.showNextAlarm || style.showNoiseControls {
|
||||||
TopOverlayView(
|
TopOverlayView(
|
||||||
showBattery: style.showBattery,
|
showBattery: style.showBattery,
|
||||||
showDate: style.showDate,
|
showDate: style.showDate,
|
||||||
color: style.effectiveDigitColor,
|
color: style.effectiveDigitColor,
|
||||||
opacity: style.clockOpacity,
|
opacity: style.clockOpacity,
|
||||||
dateFormat: style.dateFormat
|
dateFormat: style.dateFormat,
|
||||||
|
showNextAlarm: style.showNextAlarm,
|
||||||
|
showNoiseControls: style.showNoiseControls
|
||||||
)
|
)
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.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,20 +20,24 @@ struct AdvancedAppearanceSection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
VStack(spacing: 0) {
|
||||||
Text("Digit Animation")
|
SettingsNavigationRow(
|
||||||
.font(.subheadline)
|
title: "Digit Animation",
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
subtitle: style.digitAnimationStyle.displayName,
|
||||||
|
backgroundColor: .clear
|
||||||
|
) {
|
||||||
|
SettingsSelectionView(
|
||||||
|
selection: $style.digitAnimationStyle,
|
||||||
|
options: DigitAnimationStyle.allCases,
|
||||||
|
title: "Digit Animation",
|
||||||
|
toString: { $0.displayName }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Picker("Digit Animation", selection: $style.digitAnimationStyle) {
|
Rectangle()
|
||||||
ForEach(DigitAnimationStyle.allCases, id: \.self) { animation in
|
.fill(AppBorder.subtle)
|
||||||
Text(animation.displayName).tag(animation)
|
.frame(height: 1)
|
||||||
}
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
.tint(AppAccent.primary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Randomize Color",
|
title: "Randomize Color",
|
||||||
@ -42,6 +46,11 @@ struct AdvancedAppearanceSection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsSlider(
|
SettingsSlider(
|
||||||
title: "Glow",
|
title: "Glow",
|
||||||
subtitle: "Adjust the glow intensity",
|
subtitle: "Adjust the glow intensity",
|
||||||
@ -52,6 +61,11 @@ struct AdvancedAppearanceSection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsSlider(
|
SettingsSlider(
|
||||||
title: "Clock Opacity",
|
title: "Clock Opacity",
|
||||||
subtitle: "Set the clock transparency",
|
subtitle: "Set the clock transparency",
|
||||||
@ -62,6 +76,7 @@ struct AdvancedAppearanceSection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text("Fine-tune the visual appearance of your clock.")
|
Text("Fine-tune the visual appearance of your clock.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct AdvancedDisplaySection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Keep Awake",
|
title: "Keep Awake",
|
||||||
subtitle: "Prevent sleep in display mode",
|
subtitle: "Prevent sleep in display mode",
|
||||||
@ -27,6 +28,11 @@ struct AdvancedDisplaySection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Live Activities",
|
title: "Live Activities",
|
||||||
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
|
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
|
||||||
@ -35,6 +41,11 @@ struct AdvancedDisplaySection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if style.autoBrightness {
|
if style.autoBrightness {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Current Brightness")
|
Text("Current Brightness")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
@ -44,6 +55,9 @@ struct AdvancedDisplaySection: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,24 +22,21 @@ struct BasicAppearanceSection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
SettingsNavigationRow(
|
||||||
Text("Color Theme")
|
title: "Color Theme",
|
||||||
.font(.subheadline.weight(.medium))
|
subtitle: style.selectedColorTheme,
|
||||||
.foregroundStyle(AppTextColors.primary)
|
backgroundColor: .clear
|
||||||
|
) {
|
||||||
Picker("Color Theme", selection: $style.selectedColorTheme) {
|
SettingsSelectionView(
|
||||||
ForEach(ClockStyle.availableColorThemes(), id: \.0) { theme in
|
selection: $style.selectedColorTheme,
|
||||||
HStack {
|
options: ClockStyle.availableColorThemes().map { $0.0 },
|
||||||
Circle()
|
title: "Color Theme",
|
||||||
.fill(themeColor(for: theme.0))
|
toString: { theme in
|
||||||
.frame(width: 20, height: 20)
|
ClockStyle.availableColorThemes().first(where: { $0.0 == theme })?.1 ?? theme
|
||||||
Text(theme.1)
|
|
||||||
}
|
}
|
||||||
.tag(theme.0)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
.onChange(of: style.selectedColorTheme) { _, newTheme in
|
.onChange(of: style.selectedColorTheme) { _, newTheme in
|
||||||
if newTheme != "Custom" {
|
if newTheme != "Custom" {
|
||||||
style.applyColorTheme(newTheme)
|
style.applyColorTheme(newTheme)
|
||||||
@ -47,13 +44,27 @@ struct BasicAppearanceSection: View {
|
|||||||
backgroundColor = Color(hex: style.backgroundHex) ?? .black
|
backgroundColor = Color(hex: style.backgroundHex) ?? .black
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if style.selectedColorTheme == "Custom" {
|
if style.selectedColorTheme == "Custom" {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
|
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.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)
|
ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct BasicDisplaySection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "24‑Hour Format",
|
title: "24‑Hour Format",
|
||||||
subtitle: "Use military time",
|
subtitle: "Use military time",
|
||||||
@ -27,6 +28,11 @@ struct BasicDisplaySection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Show Seconds",
|
title: "Show Seconds",
|
||||||
subtitle: "Display seconds in the clock",
|
subtitle: "Display seconds in the clock",
|
||||||
@ -35,6 +41,11 @@ struct BasicDisplaySection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if !style.use24Hour {
|
if !style.use24Hour {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Show AM/PM",
|
title: "Show AM/PM",
|
||||||
subtitle: "Add an AM/PM indicator",
|
subtitle: "Add an AM/PM indicator",
|
||||||
@ -43,6 +54,11 @@ struct BasicDisplaySection: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Auto Brightness",
|
title: "Auto Brightness",
|
||||||
subtitle: "Adapt brightness to ambient light",
|
subtitle: "Adapt brightness to ambient light",
|
||||||
@ -51,6 +67,11 @@ struct BasicDisplaySection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown {
|
if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Horizontal Mode",
|
title: "Horizontal Mode",
|
||||||
subtitle: "Force a wide layout in portrait",
|
subtitle: "Force a wide layout in portrait",
|
||||||
@ -59,6 +80,7 @@ struct BasicDisplaySection: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text("Basic display settings for your clock.")
|
Text("Basic display settings for your clock.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@ -37,18 +37,20 @@ struct FontSection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
// Font Family
|
||||||
Text("Family")
|
SettingsNavigationRow(
|
||||||
.font(.subheadline.weight(.medium))
|
title: "Family",
|
||||||
.foregroundStyle(AppTextColors.primary)
|
subtitle: style.fontFamily.rawValue,
|
||||||
|
backgroundColor: .clear
|
||||||
Picker("Family", selection: $style.fontFamily) {
|
) {
|
||||||
ForEach(sortedFontFamilies, id: \.self) { family in
|
SettingsSelectionView(
|
||||||
Text(family.rawValue).tag(family)
|
selection: $style.fontFamily,
|
||||||
|
options: sortedFontFamilies,
|
||||||
|
title: "Font Family",
|
||||||
|
toString: { $0.rawValue }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
.onChange(of: style.fontFamily) { _, newFamily in
|
.onChange(of: style.fontFamily) { _, newFamily in
|
||||||
if newFamily != .system {
|
if newFamily != .system {
|
||||||
style.fontDesign = .default
|
style.fontDesign = .default
|
||||||
@ -59,36 +61,52 @@ struct FontSection: View {
|
|||||||
style.fontWeight = weights.first ?? .regular
|
style.fontWeight = weights.first ?? .regular
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
Rectangle()
|
||||||
Text("Weight")
|
.fill(AppBorder.subtle)
|
||||||
.font(.subheadline.weight(.medium))
|
.frame(height: 1)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
Picker("Weight", selection: $style.fontWeight) {
|
// Font Weight
|
||||||
ForEach(availableWeights, id: \.self) { weight in
|
SettingsNavigationRow(
|
||||||
Text(weight.rawValue).tag(weight)
|
title: "Weight",
|
||||||
}
|
subtitle: style.fontWeight.rawValue,
|
||||||
}
|
backgroundColor: .clear
|
||||||
.pickerStyle(.menu)
|
) {
|
||||||
|
SettingsSelectionView(
|
||||||
|
selection: $style.fontWeight,
|
||||||
|
options: availableWeights,
|
||||||
|
title: "Font Weight",
|
||||||
|
toString: { $0.rawValue }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if style.fontFamily == .system {
|
if style.fontFamily == .system {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
Rectangle()
|
||||||
Text("Design")
|
.fill(AppBorder.subtle)
|
||||||
.font(.subheadline.weight(.medium))
|
.frame(height: 1)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
Picker("Design", selection: $style.fontDesign) {
|
// Font Design
|
||||||
ForEach(Font.Design.allCases, id: \.self) { design in
|
SettingsNavigationRow(
|
||||||
Text(design.rawValue).tag(design)
|
title: "Design",
|
||||||
}
|
subtitle: style.fontDesign.rawValue,
|
||||||
}
|
backgroundColor: .clear
|
||||||
.pickerStyle(.menu)
|
) {
|
||||||
|
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 {
|
HStack {
|
||||||
Text("Preview")
|
Text("Preview")
|
||||||
.font(.subheadline.weight(.medium))
|
.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))
|
.font(FontUtils.createFont(name: style.fontFamily, weight: style.fontWeight, design: style.fontDesign, size: 24))
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
}
|
}
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct NightModeSection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Enable Night Mode",
|
title: "Enable Night Mode",
|
||||||
subtitle: "Use a red clock for low light",
|
subtitle: "Use a red clock for low light",
|
||||||
@ -27,6 +28,11 @@ struct NightModeSection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Auto Night Mode",
|
title: "Auto Night Mode",
|
||||||
subtitle: "Trigger based on ambient light",
|
subtitle: "Trigger based on ambient light",
|
||||||
@ -35,6 +41,11 @@ struct NightModeSection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if style.autoNightMode {
|
if style.autoNightMode {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsSlider(
|
SettingsSlider(
|
||||||
title: "Light Threshold",
|
title: "Light Threshold",
|
||||||
subtitle: "Lower values activate sooner",
|
subtitle: "Lower values activate sooner",
|
||||||
@ -46,6 +57,11 @@ struct NightModeSection: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Scheduled Night Mode",
|
title: "Scheduled Night Mode",
|
||||||
subtitle: "Enable on a daily schedule",
|
subtitle: "Enable on a daily schedule",
|
||||||
@ -54,6 +70,11 @@ struct NightModeSection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if style.scheduledNightMode {
|
if style.scheduledNightMode {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Start Time")
|
Text("Start Time")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
@ -61,6 +82,13 @@ struct NightModeSection: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
TimePickerView(timeString: $style.nightModeStartTime)
|
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 {
|
HStack {
|
||||||
Text("End Time")
|
Text("End Time")
|
||||||
@ -69,9 +97,16 @@ struct NightModeSection: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
TimePickerView(timeString: $style.nightModeEndTime)
|
TimePickerView(timeString: $style.nightModeEndTime)
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
|
|
||||||
if style.isNightModeActive {
|
if style.isNightModeActive {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
Image(systemName: "moon.fill")
|
Image(systemName: "moon.fill")
|
||||||
.foregroundStyle(AppStatus.error)
|
.foregroundStyle(AppStatus.error)
|
||||||
@ -80,6 +115,9 @@ struct NightModeSection: View {
|
|||||||
.foregroundStyle(AppStatus.error)
|
.foregroundStyle(AppStatus.error)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ struct OverlaySection: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Battery Level",
|
title: "Battery Level",
|
||||||
subtitle: "Show battery percentage",
|
subtitle: "Show battery percentage",
|
||||||
@ -29,6 +30,11 @@ struct OverlaySection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppBorder.subtle)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Date",
|
title: "Date",
|
||||||
subtitle: "Display the current date",
|
subtitle: "Display the current date",
|
||||||
@ -36,18 +42,50 @@ struct OverlaySection: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
if style.showDate {
|
Rectangle()
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
.fill(AppBorder.subtle)
|
||||||
Text("Date Format")
|
.frame(height: 1)
|
||||||
.font(.subheadline.weight(.medium))
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
|
||||||
|
|
||||||
Picker("Date Format", selection: $style.dateFormat) {
|
SettingsToggle(
|
||||||
ForEach(dateFormats, id: \.1) { format in
|
title: "Next Alarm",
|
||||||
Text(format.0).tag(format.1)
|
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 SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import AudioPlaybackKit
|
||||||
|
|
||||||
/// Component for displaying top overlay with battery and date information
|
/// Component for displaying top overlay with battery and date information
|
||||||
struct TopOverlayView: View {
|
struct TopOverlayView: View {
|
||||||
@ -17,11 +18,24 @@ struct TopOverlayView: View {
|
|||||||
let color: Color
|
let color: Color
|
||||||
let opacity: Double
|
let opacity: Double
|
||||||
let dateFormat: String
|
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
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
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 {
|
HStack {
|
||||||
if showDate {
|
if showDate {
|
||||||
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
|
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
|
||||||
@ -38,9 +52,42 @@ struct TopOverlayView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
.id(clockUpdateTrigger) // Force re-render on style or alarm changes
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in
|
||||||
|
clockUpdateTrigger.toggle()
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
batteryService.startMonitoring()
|
batteryService.startMonitoring()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ struct SoundCategoryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var soundGrid: some View {
|
private var soundGrid: some View {
|
||||||
LazyVStack(spacing: Design.Spacing.small) {
|
LazyVStack(spacing: Design.Spacing.medium) {
|
||||||
ForEach(filteredSounds) { sound in
|
ForEach(filteredSounds) { sound in
|
||||||
SoundCard(
|
SoundCard(
|
||||||
sound: sound,
|
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) {
|
Button(action: action) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.subheadline.weight(.medium))
|
.styled(.subheadingEmphasis)
|
||||||
|
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
Text("(\(count))")
|
Text("\(count)")
|
||||||
.font(.caption)
|
.styled(.caption, emphasis: .secondary)
|
||||||
.foregroundColor(.secondary)
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(isSelected ? Color.white.opacity(0.2) : AppSurface.secondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
.background(isSelected ? AppAccent.primary : Color(.systemGray6))
|
.background(isSelected ? AppAccent.primary : AppSurface.card)
|
||||||
.foregroundColor(isSelected ? .white : AppTextColors.primary)
|
.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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@ -155,68 +163,45 @@ struct SoundCard: View {
|
|||||||
let onPreview: () -> Void
|
let onPreview: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
SettingsCard(
|
||||||
|
backgroundColor: isSelected ? AppAccent.primary.opacity(0.15) : AppSurface.card,
|
||||||
|
borderColor: isSelected ? AppAccent.primary : AppBorder.subtle
|
||||||
|
) {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
// Sound Icon (Left)
|
// Sound Icon (Left)
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
|
||||||
.fill(isSelected ? AppAccent.primary : Color(.systemGray5))
|
|
||||||
.frame(width: 50, height: 50)
|
|
||||||
|
|
||||||
Image(systemName: soundIcon)
|
Image(systemName: soundIcon)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(isSelected ? .white : AppTextColors.primary)
|
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||||
|
|
||||||
if isPreviewing {
|
if isPreviewing {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(AppAccent.primary, lineWidth: 2)
|
.stroke(AppAccent.primary, lineWidth: 2)
|
||||||
.frame(width: 58, height: 58)
|
.frame(width: 40, height: 40)
|
||||||
.scaleEffect(1.02)
|
.scaleEffect(1.05)
|
||||||
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing)
|
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
|
||||||
// Sound Info (Right)
|
// Sound Info (Center)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
// Sound Name
|
|
||||||
Text(sound.name)
|
Text(sound.name)
|
||||||
.font(.subheadline.weight(.medium))
|
.styled(.subheadingEmphasis)
|
||||||
.foregroundColor(AppTextColors.primary)
|
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
// Description
|
|
||||||
Text(sound.description)
|
Text(sound.description)
|
||||||
.font(.caption)
|
.styled(.caption, emphasis: .secondary)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(2)
|
.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())
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
.onLongPressGesture {
|
||||||
onPreview()
|
onPreview()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,9 @@ struct NoiseView: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
AppSurface.primary.ignoresSafeArea()
|
||||||
|
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let isLandscape = geometry.size.width > geometry.size.height
|
let isLandscape = geometry.size.width > geometry.size.height
|
||||||
let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
|
let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
|
||||||
@ -46,6 +49,7 @@ struct NoiseView: View {
|
|||||||
.frame(maxWidth: maxWidth)
|
.frame(maxWidth: maxWidth)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
||||||
.searchable(
|
.searchable(
|
||||||
text: $searchText,
|
text: $searchText,
|
||||||
@ -79,23 +83,42 @@ struct NoiseView: View {
|
|||||||
soundControlView
|
soundControlView
|
||||||
.centered()
|
.centered()
|
||||||
} else {
|
} else {
|
||||||
// Placeholder when no sound selected
|
// Placeholder when no sound selected - Enhanced for CRO
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: "music.note")
|
ZStack {
|
||||||
.font(.largeTitle)
|
Circle()
|
||||||
.foregroundColor(.secondary)
|
.fill(AppAccent.primary.opacity(0.1))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
Text("Select a sound to begin")
|
Image(systemName: "waveform")
|
||||||
.font(.subheadline)
|
.font(.title)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(AppAccent.primary)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
.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)
|
.contentPadding(horizontal: Design.Spacing.large)
|
||||||
.padding(.top, Design.Spacing.large)
|
.padding(.top, Design.Spacing.large)
|
||||||
.background(Color(.systemBackground))
|
.background(AppSurface.primary)
|
||||||
|
|
||||||
// Scrollable sound selection
|
// Scrollable sound selection
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@ -106,6 +129,7 @@ struct NoiseView: View {
|
|||||||
)
|
)
|
||||||
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
||||||
}
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,18 +145,37 @@ struct NoiseView: View {
|
|||||||
if selectedSound != nil {
|
if selectedSound != nil {
|
||||||
soundControlView
|
soundControlView
|
||||||
} else {
|
} else {
|
||||||
// Placeholder when no sound selected
|
// Placeholder when no sound selected - Enhanced for CRO
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: "music.note")
|
ZStack {
|
||||||
.font(.largeTitle)
|
Circle()
|
||||||
.foregroundColor(.secondary)
|
.fill(AppAccent.primary.opacity(0.1))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
Text("Select a sound to begin")
|
Image(systemName: "waveform")
|
||||||
.font(.subheadline)
|
.font(.title)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(AppAccent.primary)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
.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()
|
Spacer()
|
||||||
@ -151,6 +194,7 @@ struct NoiseView: View {
|
|||||||
)
|
)
|
||||||
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
||||||
}
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentPadding(horizontal: Design.Spacing.medium)
|
.contentPadding(horizontal: Design.Spacing.medium)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ struct OnboardingView: View {
|
|||||||
@State private var keepAwakeEnabled = false
|
@State private var keepAwakeEnabled = false
|
||||||
@State private var showCelebration = false
|
@State private var showCelebration = false
|
||||||
|
|
||||||
private let totalPages = 3
|
private let totalPages = 4
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
@ -42,11 +42,14 @@ struct OnboardingView: View {
|
|||||||
welcomeWithClockPage
|
welcomeWithClockPage
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
permissionsPage
|
whiteNoisePage
|
||||||
.tag(1)
|
.tag(1)
|
||||||
|
|
||||||
getStartedPage
|
permissionsPage
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
|
||||||
|
getStartedPage
|
||||||
|
.tag(3)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
||||||
@ -71,7 +74,9 @@ struct OnboardingView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Live clock preview - immediate value using TimelineView
|
// Live clock preview - immediate value using TimelineView
|
||||||
liveClockPreview
|
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||||
|
OnboardingClockText(date: context.date)
|
||||||
|
}
|
||||||
.padding(.bottom, Design.Spacing.medium)
|
.padding(.bottom, Design.Spacing.medium)
|
||||||
|
|
||||||
Text("The Noise Clock")
|
Text("The Noise Clock")
|
||||||
@ -93,8 +98,8 @@ struct OnboardingView: View {
|
|||||||
text: "Wake up gently, on your terms"
|
text: "Wake up gently, on your terms"
|
||||||
)
|
)
|
||||||
featureHighlight(
|
featureHighlight(
|
||||||
icon: "hand.tap.fill",
|
icon: "clock.fill",
|
||||||
text: "Long-press for immersive mode"
|
text: "Automatic full-screen display"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.top, Design.Spacing.large)
|
.padding(.top, Design.Spacing.large)
|
||||||
@ -104,12 +109,6 @@ struct OnboardingView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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 {
|
private func featureHighlight(icon: String, text: String) -> some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
@ -126,7 +125,18 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
.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 {
|
private var permissionsPage: some View {
|
||||||
VStack(spacing: Design.Spacing.xxLarge) {
|
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 {
|
private var getStartedPage: some View {
|
||||||
VStack(spacing: Design.Spacing.xxLarge) {
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
@ -276,7 +286,7 @@ struct OnboardingView: View {
|
|||||||
.typography(.heroBold)
|
.typography(.heroBold)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.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)
|
.typography(.body)
|
||||||
.foregroundStyle(AppTextColors.secondary)
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@ -285,7 +295,7 @@ struct OnboardingView: View {
|
|||||||
// Quick tips
|
// Quick tips
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
tipRow(icon: "alarm.fill", text: "Create your first alarm")
|
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")
|
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||||
}
|
}
|
||||||
.padding(.top, Design.Spacing.medium)
|
.padding(.top, Design.Spacing.medium)
|
||||||
@ -402,7 +412,7 @@ struct OnboardingView: View {
|
|||||||
if granted {
|
if granted {
|
||||||
try? await Task.sleep(for: .milliseconds(800))
|
try? await Task.sleep(for: .milliseconds(800))
|
||||||
withAnimation {
|
withAnimation {
|
||||||
currentPage = 2
|
currentPage = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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