feat: user timer, ui improvements
This commit is contained in:
@@ -200,7 +200,7 @@ private func showReminderWindow(_ content: AnyView) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 600, height: 550),
|
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ struct GazeApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.windowStyle(.hiddenTitleBar)
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
.defaultSize(width: 700, height: 700)
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(replacing: .newItem) { }
|
CommandGroup(replacing: .newItem) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct TimerState {
|
struct TimerState: Equatable {
|
||||||
let type: TimerType
|
let type: TimerType
|
||||||
var remainingSeconds: Int
|
var remainingSeconds: Int
|
||||||
var isPaused: Bool
|
var isPaused: Bool
|
||||||
@@ -21,4 +21,11 @@ struct TimerState {
|
|||||||
self.isActive = isActive
|
self.isActive = isActive
|
||||||
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
|
self.targetDate = Date().addingTimeInterval(Double(intervalSeconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func == (lhs: TimerState, rhs: TimerState) -> Bool {
|
||||||
|
lhs.type == rhs.type && lhs.remainingSeconds == rhs.remainingSeconds
|
||||||
|
&& lhs.isPaused == rhs.isPaused && lhs.isActive == rhs.isActive
|
||||||
|
&& lhs.targetDate.timeIntervalSince1970.rounded()
|
||||||
|
== rhs.targetDate.timeIntervalSince1970.rounded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Represents a user-defined timer with customizable properties
|
/// Represents a user-defined timer with customizable properties
|
||||||
struct UserTimer: Codable, Equatable {
|
struct UserTimer: Codable, Equatable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
var type: UserTimerType
|
var type: UserTimerType
|
||||||
var timeOnScreenSeconds: Int
|
var timeOnScreenSeconds: Int
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class TimerEngine: ObservableObject {
|
|||||||
@Published var timerStates: [TimerType: TimerState] = [:]
|
@Published var timerStates: [TimerType: TimerState] = [:]
|
||||||
@Published var activeReminder: ReminderEvent?
|
@Published var activeReminder: ReminderEvent?
|
||||||
|
|
||||||
|
// Track user timer states separately
|
||||||
|
private var userTimerStates: [String: TimerState] = [:]
|
||||||
|
|
||||||
private var timerSubscription: AnyCancellable?
|
private var timerSubscription: AnyCancellable?
|
||||||
private let settingsManager: SettingsManager
|
private let settingsManager: SettingsManager
|
||||||
|
|
||||||
@@ -35,6 +38,11 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start user timers
|
||||||
|
for userTimer in settingsManager.settings.userTimers {
|
||||||
|
startUserTimer(userTimer)
|
||||||
|
}
|
||||||
|
|
||||||
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
|
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||||
.autoconnect()
|
.autoconnect()
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -48,6 +56,7 @@ class TimerEngine: ObservableObject {
|
|||||||
timerSubscription?.cancel()
|
timerSubscription?.cancel()
|
||||||
timerSubscription = nil
|
timerSubscription = nil
|
||||||
timerStates.removeAll()
|
timerStates.removeAll()
|
||||||
|
userTimerStates.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
@@ -87,6 +96,7 @@ class TimerEngine: ObservableObject {
|
|||||||
private func handleTick() {
|
private func handleTick() {
|
||||||
guard activeReminder == nil else { return }
|
guard activeReminder == nil else { return }
|
||||||
|
|
||||||
|
// Handle regular timers first
|
||||||
for (type, state) in timerStates {
|
for (type, state) in timerStates {
|
||||||
guard state.isActive && !state.isPaused else { continue }
|
guard state.isActive && !state.isPaused else { continue }
|
||||||
// prevent overshoot - in case user closes laptop while timer is running, we don't want to
|
// prevent overshoot - in case user closes laptop while timer is running, we don't want to
|
||||||
@@ -110,6 +120,36 @@ class TimerEngine: ObservableObject {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle user timers
|
||||||
|
handleUserTimerTicks()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleUserTimerTicks() {
|
||||||
|
for (id, state) in userTimerStates {
|
||||||
|
if !state.isActive || state.isPaused { continue }
|
||||||
|
|
||||||
|
// Update user timer countdown
|
||||||
|
userTimerStates[id]?.remainingSeconds -= 1
|
||||||
|
|
||||||
|
if let updatedState = userTimerStates[id], updatedState.remainingSeconds <= 0 {
|
||||||
|
// Trigger the user timer reminder
|
||||||
|
triggerUserTimerReminder(forId: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerUserTimerReminder(forId id: String) {
|
||||||
|
// Here we'd implement how to show a subtle reminder for user timers
|
||||||
|
// For now, just reset the timer
|
||||||
|
if let userTimer = settingsManager.settings.userTimers.first(where: { $0.id == id }) {
|
||||||
|
userTimerStates[id] = TimerState(
|
||||||
|
type: .lookAway, // Placeholder - user timers won't use this
|
||||||
|
intervalSeconds: userTimer.timeOnScreenSeconds,
|
||||||
|
isPaused: false,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerReminder(for type: TimerType) {
|
func triggerReminder(for type: TimerType) {
|
||||||
@@ -125,11 +165,44 @@ class TimerEngine: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User timer management methods
|
||||||
|
func startUserTimer(_ userTimer: UserTimer) {
|
||||||
|
userTimerStates[userTimer.id] = TimerState(
|
||||||
|
type: .lookAway, // Placeholder - we'll need to make this more flexible
|
||||||
|
intervalSeconds: userTimer.timeOnScreenSeconds,
|
||||||
|
isPaused: false,
|
||||||
|
isActive: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopUserTimer(_ userTimerId: String) {
|
||||||
|
userTimerStates[userTimerId] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseUserTimer(_ userTimerId: String) {
|
||||||
|
if var state = userTimerStates[userTimerId] {
|
||||||
|
state.isPaused = true
|
||||||
|
userTimerStates[userTimerId] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeUserTimer(_ userTimerId: String) {
|
||||||
|
if var state = userTimerStates[userTimerId] {
|
||||||
|
state.isPaused = false
|
||||||
|
userTimerStates[userTimerId] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getTimeRemaining(for type: TimerType) -> TimeInterval {
|
func getTimeRemaining(for type: TimerType) -> TimeInterval {
|
||||||
guard let state = timerStates[type] else { return 0 }
|
guard let state = timerStates[type] else { return 0 }
|
||||||
return TimeInterval(state.remainingSeconds)
|
return TimeInterval(state.remainingSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUserTimeRemaining(for userId: String) -> TimeInterval {
|
||||||
|
guard let state = userTimerStates[userId] else { return 0 }
|
||||||
|
return TimeInterval(state.remainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
func getFormattedTimeRemaining(for type: TimerType) -> String {
|
func getFormattedTimeRemaining(for type: TimerType) -> String {
|
||||||
let seconds = Int(getTimeRemaining(for: type))
|
let seconds = Int(getTimeRemaining(for: type))
|
||||||
let minutes = seconds / 60
|
let minutes = seconds / 60
|
||||||
@@ -143,4 +216,18 @@ class TimerEngine: ObservableObject {
|
|||||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUserFormattedTimeRemaining(for userId: String) -> String {
|
||||||
|
let seconds = Int(getUserTimeRemaining(for: userId))
|
||||||
|
let minutes = seconds / 60
|
||||||
|
let remainingSeconds = seconds % 60
|
||||||
|
|
||||||
|
if minutes >= 60 {
|
||||||
|
let hours = minutes / 60
|
||||||
|
let remainingMinutes = minutes % 60
|
||||||
|
return String(format: "%d:%02d:%02d", hours, remainingMinutes, remainingSeconds)
|
||||||
|
} else {
|
||||||
|
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ struct MenuBarContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show user timers if any exist
|
||||||
|
ForEach(settingsManager.settings.userTimers, id: \.id) { userTimer in
|
||||||
|
UserTimerStatusRow(
|
||||||
|
timer: userTimer,
|
||||||
|
state: nil, // We'll implement proper state tracking later
|
||||||
|
onTap: {
|
||||||
|
onOpenSettingsTab(3) // Switch to User Timers tab
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
@@ -316,6 +327,82 @@ struct InactiveTimerRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UserTimerStatusRow: View {
|
||||||
|
let timer: UserTimer
|
||||||
|
let state: TimerState?
|
||||||
|
var onTap: () -> Void
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
.frame(width: 20)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(timer.message ?? "Custom Timer")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if let state = state {
|
||||||
|
Text(timeRemaining(state))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
} else {
|
||||||
|
Text("Not active")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassEffect(
|
||||||
|
isHovered ? .regular.tint(.purple.opacity(0.5)) : .regular,
|
||||||
|
in: .rect(cornerRadius: 6)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovered = hovering
|
||||||
|
}
|
||||||
|
.help(tooltipText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tooltipText: String {
|
||||||
|
let typeText = timer.type == .subtle ? "Subtle" : "Overlay"
|
||||||
|
let durationText = "\(timer.timeOnScreenSeconds)s on screen"
|
||||||
|
return "\(typeText) timer - \(durationText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeRemaining(_ state: TimerState) -> String {
|
||||||
|
let seconds = state.remainingSeconds
|
||||||
|
let minutes = seconds / 60
|
||||||
|
let remainingSeconds = seconds % 60
|
||||||
|
|
||||||
|
if minutes >= 60 {
|
||||||
|
let hours = minutes / 60
|
||||||
|
let remainingMinutes = minutes % 60
|
||||||
|
return String(format: "%dh %dm", hours, remainingMinutes)
|
||||||
|
} else if minutes > 0 {
|
||||||
|
return String(format: "%dm %ds", minutes, remainingSeconds)
|
||||||
|
} else {
|
||||||
|
return String(format: "%ds", remainingSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview("Menu Bar Content") {
|
#Preview("Menu Bar Content") {
|
||||||
let settingsManager = SettingsManager.shared
|
let settingsManager = SettingsManager.shared
|
||||||
let timerEngine = TimerEngine(settingsManager: settingsManager)
|
let timerEngine = TimerEngine(settingsManager: settingsManager)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ struct BlinkSetupView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 450)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(.clear)
|
.background(.clear)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ struct LookAwaySetupView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 450)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(.clear)
|
.background(.clear)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct OnboardingContainerView: View {
|
|||||||
@State private var postureEnabled = true
|
@State private var postureEnabled = true
|
||||||
@State private var postureIntervalMinutes = 30
|
@State private var postureIntervalMinutes = 30
|
||||||
@State private var launchAtLogin = false
|
@State private var launchAtLogin = false
|
||||||
|
@State private var subtleReminderSizePercentage = 5.0
|
||||||
@State private var isAnimatingOut = false
|
@State private var isAnimatingOut = false
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ struct OnboardingContainerView: View {
|
|||||||
|
|
||||||
SettingsOnboardingView(
|
SettingsOnboardingView(
|
||||||
launchAtLogin: $launchAtLogin,
|
launchAtLogin: $launchAtLogin,
|
||||||
|
subtleReminderSizePercentage: $subtleReminderSizePercentage,
|
||||||
isOnboarding: true
|
isOnboarding: true
|
||||||
)
|
)
|
||||||
.tag(4)
|
.tag(4)
|
||||||
@@ -136,6 +138,7 @@ struct OnboardingContainerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 1000, minHeight: 750)
|
||||||
.opacity(isAnimatingOut ? 0 : 1)
|
.opacity(isAnimatingOut ? 0 : 1)
|
||||||
.scaleEffect(isAnimatingOut ? 0.3 : 1.0)
|
.scaleEffect(isAnimatingOut ? 0.3 : 1.0)
|
||||||
}
|
}
|
||||||
@@ -159,6 +162,7 @@ struct OnboardingContainerView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
settingsManager.settings.launchAtLogin = launchAtLogin
|
settingsManager.settings.launchAtLogin = launchAtLogin
|
||||||
|
settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage
|
||||||
settingsManager.settings.hasCompletedOnboarding = true
|
settingsManager.settings.hasCompletedOnboarding = true
|
||||||
|
|
||||||
// Apply launch at login setting
|
// Apply launch at login setting
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ struct PostureSetupView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 450)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(.clear)
|
.background(.clear)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsOnboardingView: View {
|
struct SettingsOnboardingView: View {
|
||||||
@Binding var launchAtLogin: Bool
|
@Binding var launchAtLogin: Bool
|
||||||
|
@Binding var subtleReminderSizePercentage: Double
|
||||||
var isOnboarding: Bool = true
|
var isOnboarding: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -48,6 +49,29 @@ struct SettingsOnboardingView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
||||||
|
|
||||||
|
// Subtle Reminder Size Configuration
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Subtle Reminder Size")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("Adjust the size of blink and posture reminders")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Slider(
|
||||||
|
value: $subtleReminderSizePercentage,
|
||||||
|
in: 2...35,
|
||||||
|
step: 1
|
||||||
|
)
|
||||||
|
Text("\(Int(subtleReminderSizePercentage))%")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
||||||
|
|
||||||
// Links Section
|
// Links Section
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("Support & Contribute")
|
Text("Support & Contribute")
|
||||||
@@ -116,7 +140,7 @@ struct SettingsOnboardingView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 450)
|
.frame(minWidth: 650, minHeight: 650)
|
||||||
.padding()
|
.padding()
|
||||||
.background(.clear)
|
.background(.clear)
|
||||||
}
|
}
|
||||||
@@ -137,6 +161,7 @@ struct SettingsOnboardingView: View {
|
|||||||
#Preview("Settings Onboarding - Launch Disabled") {
|
#Preview("Settings Onboarding - Launch Disabled") {
|
||||||
SettingsOnboardingView(
|
SettingsOnboardingView(
|
||||||
launchAtLogin: .constant(false),
|
launchAtLogin: .constant(false),
|
||||||
|
subtleReminderSizePercentage: .constant(5.0),
|
||||||
isOnboarding: true
|
isOnboarding: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -144,6 +169,7 @@ struct SettingsOnboardingView: View {
|
|||||||
#Preview("Settings Onboarding - Launch Enabled") {
|
#Preview("Settings Onboarding - Launch Enabled") {
|
||||||
SettingsOnboardingView(
|
SettingsOnboardingView(
|
||||||
launchAtLogin: .constant(true),
|
launchAtLogin: .constant(true),
|
||||||
|
subtleReminderSizePercentage: .constant(10.0),
|
||||||
isOnboarding: true
|
isOnboarding: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
295
Gaze/Views/Onboarding/UserTimersView.swift
Normal file
295
Gaze/Views/Onboarding/UserTimersView.swift
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
//
|
||||||
|
// UserTimersView.swift
|
||||||
|
// Gaze
|
||||||
|
//
|
||||||
|
// Created by Mike Freno on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct UserTimersView: View {
|
||||||
|
@Binding var userTimers: [UserTimer]
|
||||||
|
@State private var editingTimer: UserTimer?
|
||||||
|
@State private var showingAddTimer = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 30) {
|
||||||
|
Image(systemName: "clock.badge.checkmark")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
|
||||||
|
Text("Custom Timers")
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
|
||||||
|
Text("Create your own reminder schedules")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("Add up to 3 custom timers with your own intervals and messages")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffect(.regular.tint(.purple), in: .rect(cornerRadius: 8))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Active Timers (\(userTimers.count)/3)")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if userTimers.count < 3 {
|
||||||
|
Button(action: {
|
||||||
|
showingAddTimer = true
|
||||||
|
}) {
|
||||||
|
Label("Add Timer", systemImage: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if userTimers.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "clock.badge.questionmark")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No custom timers yet")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Click 'Add Timer' to create your first custom reminder")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(40)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(userTimers) { timer in
|
||||||
|
UserTimerRow(
|
||||||
|
timer: timer,
|
||||||
|
onEdit: {
|
||||||
|
editingTimer = timer
|
||||||
|
},
|
||||||
|
onDelete: {
|
||||||
|
if let index = userTimers.firstIndex(where: {
|
||||||
|
$0.id == timer.id
|
||||||
|
}) {
|
||||||
|
userTimers.remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(.clear)
|
||||||
|
.sheet(isPresented: $showingAddTimer) {
|
||||||
|
UserTimerEditSheet(
|
||||||
|
timer: nil,
|
||||||
|
onSave: { newTimer in
|
||||||
|
userTimers.append(newTimer)
|
||||||
|
showingAddTimer = false
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
showingAddTimer = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(item: $editingTimer) { timer in
|
||||||
|
UserTimerEditSheet(
|
||||||
|
timer: timer,
|
||||||
|
onSave: { updatedTimer in
|
||||||
|
if let index = userTimers.firstIndex(where: { $0.id == timer.id }) {
|
||||||
|
userTimers[index] = updatedTimer
|
||||||
|
}
|
||||||
|
editingTimer = nil
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
editingTimer = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserTimerRow: View {
|
||||||
|
let timer: UserTimer
|
||||||
|
var onEdit: () -> Void
|
||||||
|
var onDelete: () -> Void
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: timer.type == .subtle ? "eye.circle" : "rectangle.on.rectangle")
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(timer.message ?? "Custom Timer")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(timer.type.displayName) • \(timer.timeOnScreenSeconds)s on screen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Button(action: onEdit) {
|
||||||
|
Image(systemName: "pencil.circle")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button(action: onDelete) {
|
||||||
|
Image(systemName: "trash.circle")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.secondary.opacity(isHovered ? 0.1 : 0.05))
|
||||||
|
)
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovered = hovering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserTimerEditSheet: View {
|
||||||
|
let timer: UserTimer?
|
||||||
|
var onSave: (UserTimer) -> Void
|
||||||
|
var onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var message: String
|
||||||
|
@State private var type: UserTimerType
|
||||||
|
@State private var timeOnScreen: Int
|
||||||
|
|
||||||
|
init(
|
||||||
|
timer: UserTimer?,
|
||||||
|
onSave: @escaping (UserTimer) -> Void,
|
||||||
|
onCancel: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.timer = timer
|
||||||
|
self.onSave = onSave
|
||||||
|
self.onCancel = onCancel
|
||||||
|
|
||||||
|
_message = State(initialValue: timer?.message ?? "")
|
||||||
|
_type = State(initialValue: timer?.type ?? .subtle)
|
||||||
|
_timeOnScreen = State(initialValue: timer?.timeOnScreenSeconds ?? 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Text(timer == nil ? "Add Custom Timer" : "Edit Custom Timer")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Display Type")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Picker("Display Type", selection: $type) {
|
||||||
|
ForEach(UserTimerType.allCases) { timerType in
|
||||||
|
Text(timerType.displayName).tag(timerType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
type == .subtle
|
||||||
|
? "Small reminder in corner of screen"
|
||||||
|
: "Full screen reminder with animation"
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Duration on Screen")
|
||||||
|
.font(.headline)
|
||||||
|
HStack {
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(timeOnScreen) },
|
||||||
|
set: { timeOnScreen = Int($0) }
|
||||||
|
),
|
||||||
|
in: 5...120,
|
||||||
|
step: 5
|
||||||
|
)
|
||||||
|
Text("\(timeOnScreen)s")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Message (Optional)")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("Enter custom reminder message", text: $message)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Text("Leave blank to show a default timer notification")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: 12))
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Cancel", action: onCancel)
|
||||||
|
.keyboardShortcut(.escape)
|
||||||
|
|
||||||
|
Button(timer == nil ? "Add" : "Save") {
|
||||||
|
let newTimer = UserTimer(
|
||||||
|
id: timer?.id ?? UUID().uuidString,
|
||||||
|
type: type,
|
||||||
|
timeOnScreenSeconds: timeOnScreen,
|
||||||
|
message: message.isEmpty ? nil : message
|
||||||
|
)
|
||||||
|
onSave(newTimer)
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.return)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("User Timers - Empty") {
|
||||||
|
UserTimersView(userTimers: .constant([]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("User Timers - With Timers") {
|
||||||
|
UserTimersView(
|
||||||
|
userTimers: .constant([
|
||||||
|
UserTimer(
|
||||||
|
id: "1", type: .subtle, timeOnScreenSeconds: 30, message: "Take a break"),
|
||||||
|
UserTimer(
|
||||||
|
id: "2", type: .overlay, timeOnScreenSeconds: 60,
|
||||||
|
message: "Stretch your legs"),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,19 +18,26 @@ struct SettingsWindowView: View {
|
|||||||
@State private var postureEnabled: Bool
|
@State private var postureEnabled: Bool
|
||||||
@State private var postureIntervalMinutes: Int
|
@State private var postureIntervalMinutes: Int
|
||||||
@State private var launchAtLogin: Bool
|
@State private var launchAtLogin: Bool
|
||||||
|
@State private var subtleReminderSizePercentage: Double
|
||||||
|
|
||||||
init(settingsManager: SettingsManager, initialTab: Int = 0) {
|
init(settingsManager: SettingsManager, initialTab: Int = 0) {
|
||||||
self.settingsManager = settingsManager
|
self.settingsManager = settingsManager
|
||||||
|
|
||||||
_currentTab = State(initialValue: initialTab)
|
_currentTab = State(initialValue: initialTab)
|
||||||
_lookAwayEnabled = State(initialValue: settingsManager.settings.lookAwayTimer.enabled)
|
_lookAwayEnabled = State(initialValue: settingsManager.settings.lookAwayTimer.enabled)
|
||||||
_lookAwayIntervalMinutes = State(initialValue: settingsManager.settings.lookAwayTimer.intervalSeconds / 60)
|
_lookAwayIntervalMinutes = State(
|
||||||
_lookAwayCountdownSeconds = State(initialValue: settingsManager.settings.lookAwayCountdownSeconds)
|
initialValue: settingsManager.settings.lookAwayTimer.intervalSeconds / 60)
|
||||||
|
_lookAwayCountdownSeconds = State(
|
||||||
|
initialValue: settingsManager.settings.lookAwayCountdownSeconds)
|
||||||
_blinkEnabled = State(initialValue: settingsManager.settings.blinkTimer.enabled)
|
_blinkEnabled = State(initialValue: settingsManager.settings.blinkTimer.enabled)
|
||||||
_blinkIntervalMinutes = State(initialValue: settingsManager.settings.blinkTimer.intervalSeconds / 60)
|
_blinkIntervalMinutes = State(
|
||||||
|
initialValue: settingsManager.settings.blinkTimer.intervalSeconds / 60)
|
||||||
_postureEnabled = State(initialValue: settingsManager.settings.postureTimer.enabled)
|
_postureEnabled = State(initialValue: settingsManager.settings.postureTimer.enabled)
|
||||||
_postureIntervalMinutes = State(initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60)
|
_postureIntervalMinutes = State(
|
||||||
|
initialValue: settingsManager.settings.postureTimer.intervalSeconds / 60)
|
||||||
_launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin)
|
_launchAtLogin = State(initialValue: settingsManager.settings.launchAtLogin)
|
||||||
|
_subtleReminderSizePercentage = State(
|
||||||
|
initialValue: settingsManager.settings.subtleReminderSizePercentage)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -64,16 +71,22 @@ struct SettingsWindowView: View {
|
|||||||
Label("Posture", systemImage: "figure.stand")
|
Label("Posture", systemImage: "figure.stand")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UserTimersView(userTimers: $settingsManager.settings.userTimers)
|
||||||
|
.tag(3)
|
||||||
|
.tabItem {
|
||||||
|
Label("User Timers", systemImage: "plus.circle")
|
||||||
|
}
|
||||||
|
|
||||||
SettingsOnboardingView(
|
SettingsOnboardingView(
|
||||||
launchAtLogin: $launchAtLogin,
|
launchAtLogin: $launchAtLogin,
|
||||||
|
subtleReminderSizePercentage: $subtleReminderSizePercentage,
|
||||||
isOnboarding: false
|
isOnboarding: false
|
||||||
)
|
)
|
||||||
.tag(3)
|
.tag(4)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("General", systemImage: "gearshape.fill")
|
Label("General", systemImage: "gearshape.fill")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -94,8 +107,10 @@ struct SettingsWindowView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 550)
|
.frame(minWidth: 700, minHeight: 800)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))) { notification in
|
.onReceive(
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))
|
||||||
|
) { notification in
|
||||||
if let tab = notification.object as? Int {
|
if let tab = notification.object as? Int {
|
||||||
currentTab = tab
|
currentTab = tab
|
||||||
}
|
}
|
||||||
@@ -120,6 +135,7 @@ struct SettingsWindowView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
settingsManager.settings.launchAtLogin = launchAtLogin
|
settingsManager.settings.launchAtLogin = launchAtLogin
|
||||||
|
settingsManager.settings.subtleReminderSizePercentage = subtleReminderSizePercentage
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if launchAtLogin {
|
if launchAtLogin {
|
||||||
|
|||||||
Reference in New Issue
Block a user