refactored and moved to ios26 base
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7c16d1e646
commit
7252500c9a
@ -1,10 +1,10 @@
|
||||
Use /ios-18-role
|
||||
Use /ios-26-role
|
||||
read the PRD.md
|
||||
read the README.md
|
||||
read the Bedrock README.md as well
|
||||
|
||||
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
|
||||
|
||||
Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
|
||||
Always try to build using xcodebuildmcp after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
|
||||
|
||||
Try and use xcode build mcp if it is working and test using screenshots when asked.
|
||||
26
PRD.md
26
PRD.md
@ -22,7 +22,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
||||
- **Safe area handling** with proper Dynamic Island avoidance on iPhone and full-width layout on iPad
|
||||
- **Full-screen mode** with status bar hiding and tab bar expansion
|
||||
- **Orientation-aware spacing** for optimal layout in all orientations
|
||||
- **Modern iOS 18+ Animations**:
|
||||
- **Modern iOS 26 Animations**:
|
||||
- **Selectable Animation Styles**: Choose from effective animation styles including None, Spring, Bounce, and Glitch.
|
||||
- **Numeric Text Transitions**: Smooth scrolling transitions for digits using `.contentTransition(.numericText())` (available in most styles).
|
||||
- **Phase-Based Digit Animations**: Dynamic scale, vertical offset, and jitter effects when digits change.
|
||||
@ -240,7 +240,7 @@ These principles are fundamental to the project's long-term success and must be
|
||||
- **Main App**: `TheNoiseClockApp.swift` - Entry point with WindowGroup
|
||||
- **Branded launch**: AppLaunchView wrapped in a Color.Branding.primary ZStack
|
||||
- **Tab-based navigation**: Three main tabs (Clock, Alarms, Noise)
|
||||
- **SwiftUI framework**: Modern declarative UI framework with iOS 18+ and iOS 26 features
|
||||
- **SwiftUI framework**: Modern declarative UI framework with iOS 26 features and Liquid Glass
|
||||
- **Dark theme**: Preferred color scheme set to dark
|
||||
|
||||
### Data Models
|
||||
@ -343,7 +343,7 @@ These principles are fundamental to the project's long-term success and must be
|
||||
|
||||
### Visual Design
|
||||
- **Rounded corners**: Modern iOS design language
|
||||
- **Modern animations**: iOS 18+ smooth and bouncy animations
|
||||
- **Modern animations**: iOS 26 smooth and bouncy animations
|
||||
- **Color consistency**: Bedrock theme with branded surfaces and accents
|
||||
- **Branded launch**: AppLaunchView with matching launch screen background
|
||||
- **Accessibility**: Proper labels and hidden decorative elements
|
||||
@ -607,19 +607,19 @@ The following changes **automatically require** PRD updates:
|
||||
## Technical Requirements
|
||||
|
||||
### iOS Compatibility
|
||||
- **Minimum iOS version**: iOS 18.0+ (Latest SwiftUI features and performance optimizations)
|
||||
- **Minimum iOS version**: iOS 26.0+ (required for AlarmKit, Liquid Glass, and latest SwiftUI features)
|
||||
- **Target devices**: iPhone and iPad with full adaptive layout support
|
||||
- **Orientation support**: Portrait and landscape with dynamic type support
|
||||
- **Accessibility**: Full VoiceOver and Dynamic Type support
|
||||
|
||||
### Modern iOS Technology Stack
|
||||
- **SwiftUI**: Latest declarative UI framework with iOS 18+ and iOS 26 features
|
||||
- **SwiftUI**: Latest declarative UI framework with iOS 26 Liquid Glass design
|
||||
- **Observation Framework**: Modern @Observable pattern for state management
|
||||
- **SwiftData**: Advanced data persistence with iOS 18+ SwiftData features
|
||||
- **SwiftData**: Advanced data persistence with iOS 26 SwiftData features
|
||||
- **AlarmKit**: System-level alarms that cut through Focus and Silent modes (iOS 26+)
|
||||
- **Async/Await**: Modern concurrency patterns throughout
|
||||
- **Structured Concurrency**: Task groups and actors for complex operations
|
||||
- **Swift 6**: Latest language features and safety improvements
|
||||
- **iOS 26 Features**: Latest platform capabilities where available
|
||||
- **Swift 6.2**: Latest language features with approachable concurrency
|
||||
|
||||
### Dependencies
|
||||
- **AudioPlaybackKit**: Local Swift package for reusable audio functionality
|
||||
@ -776,22 +776,22 @@ The following simulators are available for testing:
|
||||
|
||||
### Project Information
|
||||
- **Created**: September 7, 2025
|
||||
- **Framework**: SwiftUI with iOS 18.0+ target (latest stable features)
|
||||
- **Framework**: SwiftUI with iOS 26.0+ target
|
||||
- **Architecture**: Modern SwiftUI with @Observable pattern, MVVM, and Swift Package modularity
|
||||
- **Package Architecture**: AudioPlaybackKit Swift package for reusable audio functionality
|
||||
- **Testing**: Comprehensive unit and UI test targets with Swift Testing
|
||||
- **Version control**: Git repository with feature branch workflow
|
||||
- **Performance**: Optimized for battery life and smooth operation
|
||||
- **Modern iOS**: Uses latest iOS 18+ and iOS 26 features with Swift 6 language improvements
|
||||
- **Modern iOS**: Uses iOS 26 features including AlarmKit and Liquid Glass with Swift 6.2
|
||||
- **Code Reusability**: Modular Swift package architecture enables code reuse across projects
|
||||
|
||||
### Modern iOS Development Practices
|
||||
- **Swift 6**: Latest language features including strict concurrency checking
|
||||
- **Swift 6.2**: Latest language features with approachable concurrency
|
||||
- **Async/Await**: Modern concurrency patterns throughout the codebase
|
||||
- **Observation Framework**: @Observable for reactive state management
|
||||
- **SwiftUI Navigation**: Latest NavigationStack and navigation APIs with iOS 18+ features
|
||||
- **SwiftUI Navigation**: Latest NavigationStack and navigation APIs with iOS 26 features
|
||||
- **Accessibility**: Full VoiceOver and Dynamic Type support with iOS 26 enhancements
|
||||
- **Adaptive Layout**: Support for all device sizes and orientations
|
||||
- **Adaptive Layout**: Support for all device sizes and orientations with Liquid Glass
|
||||
- **Performance**: Optimized for 120Hz ProMotion displays and iOS 26 performance improvements
|
||||
- **Memory Management**: ARC with proper weak references and cleanup
|
||||
- **Error Handling**: Result types and proper error propagation
|
||||
|
||||
@ -29,7 +29,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
|
||||
- Glow and opacity controls for low-light comfort
|
||||
- Clock tab hides the status bar for a distraction-free display
|
||||
- Selectable animation styles: None, Spring, Bounce, and Glitch
|
||||
- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons
|
||||
- Modern iOS 26 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
|
||||
|
||||
|
||||
@ -7,12 +7,12 @@
|
||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>TheNoiseClockWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -13,7 +13,7 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private enum Tab: Hashable, CustomStringConvertible {
|
||||
private enum AppTab: Hashable, CustomStringConvertible {
|
||||
case clock
|
||||
case alarms
|
||||
case noise
|
||||
@ -29,7 +29,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@State private var selectedTab: Tab = .clock
|
||||
@State private var selectedTab: AppTab = .clock
|
||||
@State private var clockViewModel = ClockViewModel()
|
||||
@State private var alarmViewModel = AlarmViewModel()
|
||||
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
|
||||
@ -48,48 +48,40 @@ struct ContentView: View {
|
||||
ZStack {
|
||||
// Main tab content
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
// Pass isOnClockTab so ClockView can make the right tab bar decision
|
||||
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
|
||||
// This prevents race conditions on tab switch
|
||||
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
||||
Tab("Clock", systemImage: "clock", value: AppTab.clock) {
|
||||
NavigationStack {
|
||||
// Pass isOnClockTab so ClockView can make the right tab bar decision
|
||||
// Tab bar hides ONLY when: isOnClockTab && isDisplayMode
|
||||
// This prevents race conditions on tab switch
|
||||
ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Clock", systemImage: "clock")
|
||||
}
|
||||
.tag(Tab.clock)
|
||||
|
||||
NavigationStack {
|
||||
AlarmView(viewModel: alarmViewModel)
|
||||
Tab("Alarms", systemImage: "alarm", value: AppTab.alarms) {
|
||||
NavigationStack {
|
||||
AlarmView(viewModel: alarmViewModel)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Alarms", systemImage: "alarm")
|
||||
}
|
||||
.tag(Tab.alarms)
|
||||
|
||||
NavigationStack {
|
||||
NoiseView()
|
||||
Tab("Noise", systemImage: "waveform", value: AppTab.noise) {
|
||||
NavigationStack {
|
||||
NoiseView()
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Noise", systemImage: "waveform")
|
||||
}
|
||||
.tag(Tab.noise)
|
||||
|
||||
NavigationStack {
|
||||
ClockSettingsView(
|
||||
style: clockViewModel.style,
|
||||
onCommit: { newStyle in
|
||||
clockViewModel.updateStyle(newStyle)
|
||||
},
|
||||
onResetOnboarding: {
|
||||
onboardingState.reset()
|
||||
}
|
||||
)
|
||||
Tab("Settings", systemImage: "gearshape", value: AppTab.settings) {
|
||||
NavigationStack {
|
||||
ClockSettingsView(
|
||||
style: clockViewModel.style,
|
||||
onCommit: { newStyle in
|
||||
clockViewModel.updateStyle(newStyle)
|
||||
},
|
||||
onResetOnboarding: {
|
||||
onboardingState.reset()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.tag(Tab.settings)
|
||||
}
|
||||
.onChange(of: selectedTab) { oldValue, newValue in
|
||||
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
|
||||
@ -100,7 +92,7 @@ struct ContentView: View {
|
||||
clockViewModel.setFullScreenMode(false)
|
||||
}
|
||||
}
|
||||
.accentColor(AppAccent.primary)
|
||||
.tint(AppAccent.primary)
|
||||
.background(Color.Branding.primary.ignoresSafeArea())
|
||||
// Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island.
|
||||
// No in-app alarm screen is needed - users interact with alarms via the system UI.
|
||||
|
||||
@ -97,7 +97,7 @@ class AlarmSoundService {
|
||||
}
|
||||
|
||||
/// Get alarm sound categories
|
||||
func getAlarmSoundCategories() -> [TheNoiseClock.SoundCategory] {
|
||||
func getAlarmSoundCategories() -> [SoundCategory] {
|
||||
return [.alarm]
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ struct AddAlarmView: View {
|
||||
@State private var volume: Float = 1.0
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Time picker section at top
|
||||
TimePickerSection(selectedTime: $newAlarmTime)
|
||||
@ -48,12 +48,12 @@ struct AddAlarmView: View {
|
||||
NavigationLink(destination: LabelEditView(label: $alarmLabel)) {
|
||||
HStack {
|
||||
Image(systemName: "textformat")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Label")
|
||||
Spacer()
|
||||
Text(alarmLabel)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,12 +61,12 @@ struct AddAlarmView: View {
|
||||
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
|
||||
HStack {
|
||||
Image(systemName: "message")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Message")
|
||||
Spacer()
|
||||
Text(notificationMessage)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
@ -75,12 +75,12 @@ struct AddAlarmView: View {
|
||||
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
|
||||
HStack {
|
||||
Image(systemName: "music.note")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Sound")
|
||||
Spacer()
|
||||
Text(getSoundDisplayName(selectedSoundName))
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,12 +88,12 @@ struct AddAlarmView: View {
|
||||
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
|
||||
HStack {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
Text("Snooze")
|
||||
Spacer()
|
||||
Text("for \(snoozeDuration) min")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -107,7 +107,7 @@ struct AddAlarmView: View {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@ -127,7 +127,7 @@ struct AddAlarmView: View {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,15 +26,15 @@ struct AlarmRowView: View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text(alarm.formattedTime())
|
||||
.font(.headline)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text(alarm.label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
if alarm.isEnabled && !isKeepAwakeEnabled {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
|
||||
@ -27,18 +27,18 @@ struct EmptyAlarmsView: View {
|
||||
|
||||
Image(systemName: "alarm.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.symbolEffect(.bounce, value: true)
|
||||
}
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("No Alarms Set")
|
||||
.typography(.title2Bold)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Create an alarm to wake up gently on your own terms.")
|
||||
.typography(.body)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
@ -54,7 +54,7 @@ struct EmptyAlarmsView: View {
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
.shadow(color: AppAccent.primary.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
@ -21,11 +21,11 @@ struct SnoozeSelectionView: View {
|
||||
ForEach(snoozeOptions, id: \.self) { duration in
|
||||
HStack {
|
||||
Text("\(duration) minutes")
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
if snoozeDuration == duration {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@ -28,11 +28,11 @@ struct SoundSelectionView: View {
|
||||
HStack {
|
||||
Text(sound.name)
|
||||
.font(.body)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
if selectedSound == sound.fileName {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@ -62,7 +62,7 @@ struct SoundSelectionView: View {
|
||||
}
|
||||
}) {
|
||||
Image(systemName: isPlaying && currentlyPlayingSound == selectedSound ? "stop.fill" : "play.fill")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,15 +15,15 @@ struct TimeUntilAlarmSection: View {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "calendar")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
Text(timeUntilAlarm)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(dayText)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
@ -46,7 +46,7 @@ struct EditAlarmView: View {
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Time picker section at top
|
||||
TimePickerSection(selectedTime: $alarmTime)
|
||||
@ -136,7 +136,7 @@ struct EditAlarmView: View {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@ -158,7 +158,7 @@ struct EditAlarmView: View {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import Bedrock
|
||||
|
||||
/// Clock customization settings and data model
|
||||
@Observable
|
||||
class ClockStyle: Codable, Equatable {
|
||||
final class ClockStyle: Codable, Equatable {
|
||||
|
||||
// MARK: - Time Format Settings
|
||||
var use24Hour: Bool = false
|
||||
|
||||
@ -14,7 +14,7 @@ import Bedrock
|
||||
|
||||
/// ViewModel for clock display and management
|
||||
@Observable
|
||||
class ClockViewModel {
|
||||
final class ClockViewModel {
|
||||
|
||||
// MARK: - Properties
|
||||
private(set) var currentTime = Date()
|
||||
|
||||
@ -24,9 +24,9 @@ struct BatteryOverlayView: View {
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: batteryIcon)
|
||||
.foregroundColor(batteryColor)
|
||||
.foregroundStyle(batteryColor)
|
||||
Text("\(batteryLevel)%")
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.opacity(clamped)
|
||||
.font(.callout.weight(.semibold))
|
||||
@ -75,212 +75,15 @@ struct BatteryOverlayView: View {
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
#Preview("Battery Overlay - Full Charge") {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 100,
|
||||
isCharging: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - High Charge") {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 75,
|
||||
isCharging: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Medium Charge") {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 50,
|
||||
isCharging: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Low Charge") {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 25,
|
||||
isCharging: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Critical Charge") {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 10,
|
||||
isCharging: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Charging") {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 85,
|
||||
isCharging: true
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Charging States") {
|
||||
#Preview("Battery Overlay - All States") {
|
||||
VStack(spacing: 20) {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 75,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 75,
|
||||
isCharging: true
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 25,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 25,
|
||||
isCharging: true
|
||||
)
|
||||
BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 100, isCharging: false)
|
||||
BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 50, isCharging: false)
|
||||
BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 15, isCharging: false)
|
||||
BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 5, isCharging: false)
|
||||
BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 75, isCharging: true)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Different Colors") {
|
||||
VStack(spacing: 20) {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 75,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .blue,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 50,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .green,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 25,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .red,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 10,
|
||||
isCharging: false
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Different Opacities") {
|
||||
VStack(spacing: 20) {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 75,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 0.7,
|
||||
batteryLevel: 50,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 0.5,
|
||||
batteryLevel: 25,
|
||||
isCharging: false
|
||||
)
|
||||
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 0.3,
|
||||
batteryLevel: 10,
|
||||
isCharging: false
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Dark Background") {
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 75,
|
||||
isCharging: false
|
||||
)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Light Background") {
|
||||
BatteryOverlayView(
|
||||
color: .black,
|
||||
opacity: 1.0,
|
||||
batteryLevel: 50,
|
||||
isCharging: false
|
||||
)
|
||||
.padding()
|
||||
.background(Color.white)
|
||||
}
|
||||
|
||||
#Preview("Battery Overlay - Clock Context") {
|
||||
ZStack {
|
||||
// Simulate clock background
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
// Simulate clock display
|
||||
Text("12:34")
|
||||
.font(.system(size: 80, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Battery overlay at bottom
|
||||
HStack {
|
||||
Spacer()
|
||||
BatteryOverlayView(
|
||||
color: .white,
|
||||
opacity: 0.8,
|
||||
batteryLevel: 75,
|
||||
isCharging: false
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ struct DateOverlayView: View {
|
||||
let clamped = ColorUtils.clampOpacity(opacity)
|
||||
|
||||
Text(dateString)
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
.opacity(clamped)
|
||||
.font(.callout.weight(.semibold))
|
||||
.onAppear {
|
||||
|
||||
@ -65,7 +65,7 @@ struct DigitView: View {
|
||||
|
||||
private var glowText: some View {
|
||||
baseText
|
||||
.foregroundColor(digitColor)
|
||||
.foregroundStyle(digitColor)
|
||||
.blur(radius: glowRadius)
|
||||
.opacity(glowOpacity)
|
||||
.modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity))
|
||||
@ -73,7 +73,7 @@ struct DigitView: View {
|
||||
|
||||
private var mainText: some View {
|
||||
baseText
|
||||
.foregroundColor(digitColor)
|
||||
.foregroundStyle(digitColor)
|
||||
.opacity(opacity)
|
||||
.modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize))
|
||||
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text")
|
||||
|
||||
@ -34,12 +34,12 @@ struct NextAlarmOverlay: View {
|
||||
Text(alarmString)
|
||||
.typography(.calloutEmphasis)
|
||||
}
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
.opacity(opacity)
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(AppSurface.overlay.opacity(0.3))
|
||||
.cornerRadius(Design.CornerRadius.small)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,21 +27,21 @@ struct NoiseMiniPlayer: View {
|
||||
Button(action: onToggle) {
|
||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(isPlaying ? AppAccent.primary.opacity(0.8) : AppAccent.primary.opacity(0.8))
|
||||
.background(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))
|
||||
.foregroundStyle(color.opacity(0.6))
|
||||
.textCase(.uppercase)
|
||||
|
||||
Text(name)
|
||||
.typography(.calloutEmphasis)
|
||||
.foregroundColor(color)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
@ -49,7 +49,7 @@ struct NoiseMiniPlayer: View {
|
||||
.padding(.trailing, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(AppSurface.overlay.opacity(0.3))
|
||||
.cornerRadius(Design.CornerRadius.appLarge)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.appLarge))
|
||||
.opacity(opacity)
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,7 +170,7 @@ struct TimeDisplayView: View {
|
||||
size: max(12, fontSize * 0.18)
|
||||
)
|
||||
)
|
||||
.foregroundColor(digitColor)
|
||||
.foregroundStyle(digitColor)
|
||||
.opacity(clockOpacity)
|
||||
.padding(.horizontal, max(6, fontSize * 0.05))
|
||||
.padding(.vertical, max(3, fontSize * 0.03))
|
||||
|
||||
@ -17,7 +17,7 @@ struct SoundCategoryView: View {
|
||||
@Binding var selectedSound: Sound?
|
||||
@State private var selectedCategory: SoundCategory = .all
|
||||
@Binding var searchText: String
|
||||
@State private var viewModel = SoundViewModel()
|
||||
@State private var previewViewModel = SoundViewModel()
|
||||
|
||||
// MARK: - Computed Properties
|
||||
private var filteredSounds: [Sound] {
|
||||
@ -103,13 +103,13 @@ struct SoundCategoryView: View {
|
||||
SoundCard(
|
||||
sound: sound,
|
||||
isSelected: selectedSound?.id == sound.id,
|
||||
isPreviewing: viewModel.isPreviewing && viewModel.previewSound?.id == sound.id,
|
||||
isPreviewing: previewViewModel.isPreviewing && previewViewModel.previewSound?.id == sound.id,
|
||||
onSelect: {
|
||||
viewModel.selectSound(sound)
|
||||
previewViewModel.selectSound(sound)
|
||||
selectedSound = sound
|
||||
},
|
||||
onPreview: {
|
||||
viewModel.previewSound(sound)
|
||||
previewViewModel.previewSound(sound)
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -144,8 +144,8 @@ struct CategoryTab: View {
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(isSelected ? AppAccent.primary : AppSurface.card)
|
||||
.foregroundColor(isSelected ? .white : AppTextColors.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
.foregroundStyle(isSelected ? .white : AppTextColors.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.stroke(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: 1)
|
||||
@ -173,7 +173,7 @@ struct SoundCard: View {
|
||||
ZStack {
|
||||
Image(systemName: soundIcon)
|
||||
.font(.title3)
|
||||
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||
.foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||
|
||||
if isPreviewing {
|
||||
Circle()
|
||||
@ -189,7 +189,7 @@ struct SoundCard: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sound.name)
|
||||
.styled(.subheadingEmphasis)
|
||||
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||
.foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(sound.description)
|
||||
|
||||
@ -27,11 +27,11 @@ struct SoundControlView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sound.name)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text(sound.description)
|
||||
.font(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ struct SoundControlView: View {
|
||||
// Category badge
|
||||
Text(sound.category.capitalized)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(AppAccent.primary, in: Capsule())
|
||||
@ -60,11 +60,11 @@ struct SoundControlView: View {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: isPlaying ? "stop.fill" : "play.fill")
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(isPlaying ? "Stop Sound" : "Play Sound")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.medium)
|
||||
.background(
|
||||
|
||||
@ -42,22 +42,22 @@ struct NoiseView: View {
|
||||
HStack {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
TextField("Search sounds", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button(action: { searchText = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(AppTextColors.tertiary)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.small)
|
||||
.background(AppSurface.overlay)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
@ -118,18 +118,18 @@ struct NoiseView: View {
|
||||
|
||||
Image(systemName: "waveform")
|
||||
.font(.title)
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Ready for Sleep?")
|
||||
.typography(.title3Bold)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Select a soothing sound below to begin your relaxation journey.")
|
||||
.typography(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
@ -184,18 +184,18 @@ struct NoiseView: View {
|
||||
|
||||
Image(systemName: "waveform")
|
||||
.font(.title)
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Ready for Sleep?")
|
||||
.typography(.title3Bold)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Select a soothing sound to begin.")
|
||||
.typography(.caption)
|
||||
.foregroundColor(AppTextColors.secondary)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
//
|
||||
// OnboardingBottomControls.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Bottom navigation controls for onboarding flow.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Bottom controls with page indicators and navigation buttons
|
||||
struct OnboardingBottomControls: View {
|
||||
|
||||
@Binding var currentPage: Int
|
||||
let totalPages: Int
|
||||
let onSkip: () -> Void
|
||||
let onFinish: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Page indicators
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
ForEach(0..<totalPages, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index == currentPage ? AppAccent.primary : AppTextColors.tertiary)
|
||||
.frame(width: index == currentPage ? 24 : 8, height: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
Button {
|
||||
if currentPage > 0 {
|
||||
withAnimation { currentPage -= 1 }
|
||||
} else {
|
||||
onSkip()
|
||||
}
|
||||
} label: {
|
||||
Text(currentPage == 0 ? "Skip" : "Back")
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
}
|
||||
|
||||
Button {
|
||||
if currentPage < totalPages - 1 {
|
||||
withAnimation { currentPage += 1 }
|
||||
} else {
|
||||
onFinish()
|
||||
}
|
||||
} label: {
|
||||
Text(currentPage == totalPages - 1 ? "Get Started" : "Next")
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingBottomControls(
|
||||
currentPage: .constant(0),
|
||||
totalPages: 4,
|
||||
onSkip: {},
|
||||
onFinish: {}
|
||||
)
|
||||
.padding()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
//
|
||||
// OnboardingFeatureRow.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Reusable feature highlight row for onboarding pages.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Reusable row displaying an icon and text for onboarding feature highlights
|
||||
struct OnboardingFeatureRow: View {
|
||||
|
||||
let icon: String
|
||||
let text: String
|
||||
var iconSize: CGFloat = 20
|
||||
var iconWidth: CGFloat = 28
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: iconSize))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: iconWidth)
|
||||
|
||||
Text(text)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.frame(maxWidth: 320, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
OnboardingFeatureRow(icon: "moon.stars.fill", text: "Fall asleep to soothing sounds")
|
||||
OnboardingFeatureRow(icon: "alarm.fill", text: "Wake up gently, on your terms")
|
||||
OnboardingFeatureRow(icon: "clock.fill", text: "Automatic full-screen display")
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
//
|
||||
// OnboardingGetStartedPage.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Final "Get Started" page for onboarding.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Page 4: Get Started with quick tips
|
||||
struct OnboardingGetStartedPage: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppStatus.success.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60, weight: .medium))
|
||||
.foregroundStyle(AppStatus.success)
|
||||
}
|
||||
|
||||
Text("You're ready!")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Your alarms will work even in silent mode and Focus mode. The interface will automatically fade out to give you a clean view of the time!")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
OnboardingFeatureRow(icon: "alarm.fill", text: "Create your first alarm")
|
||||
OnboardingFeatureRow(icon: "clock.fill", text: "Wait 5s for full screen")
|
||||
OnboardingFeatureRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingGetStartedPage()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
//
|
||||
// OnboardingPermissionsPage.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// AlarmKit permissions page for onboarding.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Page 3: AlarmKit permission request
|
||||
struct OnboardingPermissionsPage: View {
|
||||
|
||||
@Binding var alarmKitPermissionGranted: Bool
|
||||
@Binding var keepAwakeEnabled: Bool
|
||||
let onAdvanceToFinal: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.font(.system(size: 50, weight: .medium))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
}
|
||||
|
||||
Text("Alarms that actually work")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
OnboardingFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
|
||||
OnboardingFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
|
||||
OnboardingFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
permissionButton
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
keepAwakeSection
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
keepAwakeEnabled = isKeepAwakeEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission Button
|
||||
|
||||
@ViewBuilder
|
||||
private var permissionButton: some View {
|
||||
if alarmKitPermissionGranted {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
Text("Alarms enabled!")
|
||||
}
|
||||
.foregroundStyle(AppStatus.success)
|
||||
.typography(.bodyEmphasis)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppStatus.success.opacity(0.15))
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
} else {
|
||||
Button {
|
||||
requestAlarmKitPermission()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "alarm.fill")
|
||||
Text("Enable Alarms")
|
||||
}
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: 280)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keep Awake Section
|
||||
|
||||
private var keepAwakeSection: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Want the clock always visible?")
|
||||
.typography(.callout)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
enableKeepAwake()
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
|
||||
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
|
||||
}
|
||||
.typography(.callout)
|
||||
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.disabled(keepAwakeEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func requestAlarmKitPermission() {
|
||||
Task {
|
||||
let granted = await AlarmKitService.shared.requestAuthorization()
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
alarmKitPermissionGranted = granted
|
||||
}
|
||||
if granted {
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
onAdvanceToFinal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enableKeepAwake() {
|
||||
let style = loadClockStyle()
|
||||
style.keepAwake = true
|
||||
saveClockStyle(style)
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
keepAwakeEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private func isKeepAwakeEnabled() -> Bool {
|
||||
loadClockStyle().keepAwake
|
||||
}
|
||||
|
||||
private func loadClockStyle() -> ClockStyle {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle()
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func saveClockStyle(_ style: ClockStyle) {
|
||||
if let data = try? JSONEncoder().encode(style) {
|
||||
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingPermissionsPage(
|
||||
alarmKitPermissionGranted: .constant(false),
|
||||
keepAwakeEnabled: .constant(false),
|
||||
onAdvanceToFinal: {}
|
||||
)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
//
|
||||
// OnboardingWelcomePage.swift
|
||||
// TheNoiseClock
|
||||
//
|
||||
// Welcome page with live clock preview for onboarding.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Page 1: Welcome with live clock preview
|
||||
struct OnboardingWelcomePage: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Spacer()
|
||||
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||
OnboardingClockText(date: context.date)
|
||||
}
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
|
||||
Text("The Noise Clock")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Your beautiful bedside companion")
|
||||
.typography(.title3)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
OnboardingFeatureRow(
|
||||
icon: "moon.stars.fill",
|
||||
text: "Fall asleep to soothing sounds"
|
||||
)
|
||||
OnboardingFeatureRow(
|
||||
icon: "alarm.fill",
|
||||
text: "Wake up gently, on your terms"
|
||||
)
|
||||
OnboardingFeatureRow(
|
||||
icon: "clock.fill",
|
||||
text: "Automatic full-screen display"
|
||||
)
|
||||
}
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Clock Text
|
||||
|
||||
/// Separate view for TimelineView content to avoid view builder issues
|
||||
struct OnboardingClockText: View {
|
||||
let date: Date
|
||||
|
||||
private static let formatter: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "h:mm"
|
||||
return df
|
||||
}()
|
||||
|
||||
private var timeString: String {
|
||||
Self.formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(timeString)
|
||||
.font(.system(size: 80, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy(duration: 0.3), value: timeString)
|
||||
.shadow(color: AppAccent.primary.opacity(0.5), radius: 20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
OnboardingWelcomePage()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@ -31,423 +31,54 @@ struct OnboardingView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background
|
||||
AppSurface.primary
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Page content
|
||||
TabView(selection: $currentPage) {
|
||||
welcomeWithClockPage
|
||||
OnboardingWelcomePage()
|
||||
.tag(0)
|
||||
|
||||
whiteNoisePage
|
||||
.tag(1)
|
||||
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."
|
||||
)
|
||||
.tag(1)
|
||||
|
||||
permissionsPage
|
||||
.tag(2)
|
||||
OnboardingPermissionsPage(
|
||||
alarmKitPermissionGranted: $alarmKitPermissionGranted,
|
||||
keepAwakeEnabled: $keepAwakeEnabled,
|
||||
onAdvanceToFinal: {
|
||||
withAnimation { currentPage = 3 }
|
||||
}
|
||||
)
|
||||
.tag(2)
|
||||
|
||||
getStartedPage
|
||||
OnboardingGetStartedPage()
|
||||
.tag(3)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: currentPage)
|
||||
|
||||
// Bottom controls
|
||||
bottomControls
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
.padding(.bottom, Design.Spacing.xxLarge)
|
||||
OnboardingBottomControls(
|
||||
currentPage: $currentPage,
|
||||
totalPages: totalPages,
|
||||
onSkip: onComplete,
|
||||
onFinish: {
|
||||
withAnimation(.spring(duration: 0.45, bounce: 0.2)) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
.padding(.bottom, Design.Spacing.xxLarge)
|
||||
}
|
||||
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 1: Welcome with Live Clock Preview
|
||||
|
||||
private var welcomeWithClockPage: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Spacer()
|
||||
|
||||
// Live clock preview - immediate value using TimelineView
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||
OnboardingClockText(date: context.date)
|
||||
}
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
|
||||
Text("The Noise Clock")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Your beautiful bedside companion")
|
||||
.typography(.title3)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
// Quick feature highlights - benefit focused
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
featureHighlight(
|
||||
icon: "moon.stars.fill",
|
||||
text: "Fall asleep to soothing sounds"
|
||||
)
|
||||
featureHighlight(
|
||||
icon: "alarm.fill",
|
||||
text: "Wake up gently, on your terms"
|
||||
)
|
||||
featureHighlight(
|
||||
icon: "clock.fill",
|
||||
text: "Automatic full-screen display"
|
||||
)
|
||||
}
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func featureHighlight(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 28)
|
||||
|
||||
Text(text)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading
|
||||
.frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// MARK: - Page 2: White Noise
|
||||
|
||||
private var whiteNoisePage: some View {
|
||||
OnboardingPageView(
|
||||
icon: "waveform",
|
||||
iconColor: AppAccent.primary,
|
||||
title: "Soothing Sounds",
|
||||
description: "Choose from a variety of white noise, rain, and ambient sounds to help you drift off to sleep."
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Page 3: AlarmKit Permissions
|
||||
|
||||
private var permissionsPage: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
// Alarm icon with animated waves
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "alarm.waves.left.and.right.fill")
|
||||
.font(.system(size: 50, weight: .medium))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
}
|
||||
|
||||
Text("Alarms that actually work")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
// Feature bullets
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
alarmFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
|
||||
alarmFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
|
||||
alarmFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
// Permission button or success state
|
||||
permissionButton
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
// Optional: Keep Awake for bedside clock mode
|
||||
keepAwakeSection
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
keepAwakeEnabled = isKeepAwakeEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private var keepAwakeSection: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Want the clock always visible?")
|
||||
.typography(.callout)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
enableKeepAwake()
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
|
||||
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
|
||||
}
|
||||
.typography(.callout)
|
||||
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
.disabled(keepAwakeEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func alarmFeatureRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 28)
|
||||
|
||||
Text(text)
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading
|
||||
.frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
private var permissionButton: some View {
|
||||
Group {
|
||||
if alarmKitPermissionGranted {
|
||||
// Success state
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
Text("Alarms enabled!")
|
||||
}
|
||||
.foregroundStyle(AppStatus.success)
|
||||
.typography(.bodyEmphasis)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppStatus.success.opacity(0.15))
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
} else {
|
||||
// Request AlarmKit authorization
|
||||
Button {
|
||||
requestAlarmKitPermission()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "alarm.fill")
|
||||
Text("Enable Alarms")
|
||||
}
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: 280)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 4: Get Started (Quick Win)
|
||||
|
||||
private var getStartedPage: some View {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
Spacer()
|
||||
|
||||
// Celebration icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppStatus.success.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60, weight: .medium))
|
||||
.foregroundStyle(AppStatus.success)
|
||||
}
|
||||
|
||||
Text("You're ready!")
|
||||
.typography(.heroBold)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text("Your alarms will work even in silent mode and Focus mode. The interface will automatically fade out to give you a clean view of the time!")
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
// Quick tips
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
tipRow(icon: "alarm.fill", text: "Create your first alarm")
|
||||
tipRow(icon: "clock.fill", text: "Wait 5s for full screen")
|
||||
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func tipRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: 24)
|
||||
|
||||
Text(text)
|
||||
.typography(.callout)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading
|
||||
.frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// MARK: - Bottom Controls
|
||||
|
||||
private var bottomControls: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Page indicators
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
ForEach(0..<totalPages, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index == currentPage ? AppAccent.primary : AppTextColors.tertiary)
|
||||
.frame(width: index == currentPage ? 24 : 8, height: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
// Back / Skip button
|
||||
Button {
|
||||
if currentPage > 0 {
|
||||
withAnimation {
|
||||
currentPage -= 1
|
||||
}
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
} label: {
|
||||
Text(currentPage == 0 ? "Skip" : "Back")
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
}
|
||||
|
||||
// Next / Get Started button
|
||||
Button {
|
||||
if currentPage < totalPages - 1 {
|
||||
withAnimation {
|
||||
currentPage += 1
|
||||
}
|
||||
} else {
|
||||
triggerCelebration()
|
||||
}
|
||||
} label: {
|
||||
Text(currentPage == totalPages - 1 ? "Get Started" : "Next")
|
||||
.typography(.bodyEmphasis)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppAccent.primary)
|
||||
.cornerRadius(Design.CornerRadius.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func requestAlarmKitPermission() {
|
||||
Task {
|
||||
// Request AlarmKit authorization (iOS 26+)
|
||||
let granted = await AlarmKitService.shared.requestAuthorization()
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
alarmKitPermissionGranted = granted
|
||||
}
|
||||
// Auto-advance after permission granted
|
||||
if granted {
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
withAnimation {
|
||||
currentPage = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enableKeepAwake() {
|
||||
var style = loadClockStyle()
|
||||
style.keepAwake = true
|
||||
saveClockStyle(style)
|
||||
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
keepAwakeEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private func isKeepAwakeEnabled() -> Bool {
|
||||
loadClockStyle().keepAwake
|
||||
}
|
||||
|
||||
private func loadClockStyle() -> ClockStyle {
|
||||
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
|
||||
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
|
||||
return ClockStyle()
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func saveClockStyle(_ style: ClockStyle) {
|
||||
if let data = try? JSONEncoder().encode(style) {
|
||||
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerCelebration() {
|
||||
// Snappier transition for a better feel
|
||||
withAnimation(.spring(duration: 0.45, bounce: 0.2)) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Clock Text
|
||||
|
||||
/// Separate view for TimelineView content to avoid view builder issues
|
||||
private struct OnboardingClockText: View {
|
||||
let date: Date
|
||||
|
||||
private var timeString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "h:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(timeString)
|
||||
.font(.system(size: 80, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy(duration: 0.3), value: timeString)
|
||||
.shadow(color: AppAccent.primary.opacity(0.5), radius: 20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
@ -39,11 +39,11 @@ struct SettingsSelectionView<T: Hashable>: View {
|
||||
HStack {
|
||||
Text(toString(option))
|
||||
.typography(.body)
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
Spacer()
|
||||
if selection == option {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(AppAccent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.font(.body.bold())
|
||||
}
|
||||
}
|
||||
|
||||
@ -199,170 +199,3 @@ struct FontUtils {
|
||||
return baseName + (weightSuffix.isEmpty ? "" : "-" + weightSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestContentView: View {
|
||||
@State private var digit: String = "138"
|
||||
@State private var previewFontSize: CGFloat = 1000
|
||||
@State private var fontWeight: Font.Weight = .bold
|
||||
@State private var fontDesign: Font.Design = .rounded
|
||||
|
||||
private var date: Date {
|
||||
let hours = ["13"].randomElement() ?? "08"
|
||||
let minutes = ["38"].randomElement() ?? "33"
|
||||
let timeString = "\(hours):\(minutes)"
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let todayString = formatter.string(from: Date())
|
||||
let fullDateString = "\(todayString) \(timeString)"
|
||||
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.date(from: fullDateString) ?? Date()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let newDate = date
|
||||
return ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
ForEach(FontFamily.allCases, id: \.rawValue) { (font: FontFamily) in
|
||||
VStack {
|
||||
Text(font.rawValue)
|
||||
.font(Font.system(size: 16, weight: Font.Weight.bold))
|
||||
.padding(.bottom, 5)
|
||||
|
||||
TimeDisplayView(date: newDate,
|
||||
use24Hour: true,
|
||||
showSeconds: false,
|
||||
showAmPm: true,
|
||||
digitColor: .primary,
|
||||
glowIntensity: 0.5,
|
||||
clockOpacity: 1.0,
|
||||
fontFamily: font,
|
||||
fontWeight: fontWeight,
|
||||
fontDesign: fontDesign,
|
||||
forceHorizontalMode: true,
|
||||
isDisplayMode: false,
|
||||
animationStyle: .spring)
|
||||
|
||||
TimeSegment(
|
||||
text: digit,
|
||||
fontSize: .constant(129), // CGFloat
|
||||
opacity: 1.0, // Double
|
||||
digitColor: Color.primary, // Color
|
||||
glowIntensity: 0.5, // Double
|
||||
fontFamily: font, // FontFamily
|
||||
fontWeight: fontWeight,
|
||||
fontDesign: fontDesign,
|
||||
isDisplayMode: false,
|
||||
animationStyle: .spring
|
||||
)
|
||||
}
|
||||
.frame(width: 400, height: 200)
|
||||
.border(Color.black)
|
||||
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FontCombinationsPreview: View {
|
||||
// All designs
|
||||
private let designs: [Font.Design] = Font.Design.allCases
|
||||
|
||||
private let columns: [GridItem] = [
|
||||
GridItem(.flexible(), spacing: 20),
|
||||
GridItem(.flexible(), spacing: 20),
|
||||
GridItem(.flexible(), spacing: 20)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
// Precompute local copies to avoid type-checker stress
|
||||
let fonts: [FontFamily] = FontFamily.allCases.sorted {
|
||||
$0.rawValue.localizedCaseInsensitiveCompare($1.rawValue)
|
||||
== .orderedAscending
|
||||
}
|
||||
let designsLocal: [Font.Design] = designs
|
||||
|
||||
return ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 20) {
|
||||
ForEach(fonts, id: \.self) { font in
|
||||
ForEach(font.fontWeights, id: \.self) { weight in
|
||||
ForEach(designsLocal, id: \.self) { design in
|
||||
FontSampleCell(font: font, weight: weight, design: design)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FontSampleCell: View {
|
||||
let font: FontFamily
|
||||
let weight: Font.Weight
|
||||
let design: Font.Design
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 5) {
|
||||
Text("\(font.rawValue), \(weight.rawValue), \(design.rawValue)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 5)
|
||||
TimeSegment(
|
||||
text: "38",
|
||||
fontSize: .constant(129), // CGFloat
|
||||
opacity: 1.0, // Double
|
||||
digitColor: Color.primary, // Color
|
||||
glowIntensity: 0.5, // Double
|
||||
fontFamily: font, // FontFamily
|
||||
fontWeight: weight,
|
||||
fontDesign: design,
|
||||
isDisplayMode: false,
|
||||
animationStyle: .spring)
|
||||
.frame(width: 100, height: 100)
|
||||
.border(Color.black)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
FontCombinationsPreview()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Descriptions for Font.Weight and Font.Design
|
||||
private extension Font.Weight {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ultraLight: return "ultraLight"
|
||||
case .thin: return "thin"
|
||||
case .light: return "light"
|
||||
case .regular: return "regular"
|
||||
case .medium: return "medium"
|
||||
case .semibold: return "semibold"
|
||||
case .bold: return "bold"
|
||||
case .heavy: return "heavy"
|
||||
case .black: return "black"
|
||||
default:
|
||||
// Fallback for any future/unknown cases
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Font.Design {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .default: return "default"
|
||||
case .serif: return "serif"
|
||||
case .rounded: return "rounded"
|
||||
case .monospaced: return "monospaced"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,8 +30,8 @@ extension View {
|
||||
self
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(isEnabled ? color : color.opacity(Design.Opacity.light))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(Design.CornerRadius.small)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ extension View {
|
||||
func sectionTitleStyle() -> some View {
|
||||
self
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(AppTextColors.primary)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
}
|
||||
|
||||
/// Center content horizontally with spacers
|
||||
|
||||
Loading…
Reference in New Issue
Block a user