Skip to content

Commit d3944da

Browse files
Merge pull request #19 from bettercoderthanyou/bettercoderthanyou/countdown-widget
feat: pomodoro fill icon mode & countdown widget
2 parents 7819ff8 + 6992bc5 commit d3944da

File tree

10 files changed

+408
-6
lines changed

10 files changed

+408
-6
lines changed

Barik/Config/ConfigManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ final class ConfigManager: ObservableObject {
7777
"default.nowplaying",
7878
"default.network",
7979
"default.battery",
80+
"default.countdown",
8081
"divider",
8182
# { "default.time" = { time-zone = "America/Los_Angeles", format = "E d, hh:mm" } },
8283
"default.time"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "tomato-filled.svg",
5+
"idiom" : "universal"
6+
}
7+
],
8+
"info" : {
9+
"author" : "xcode",
10+
"version" : 1
11+
},
12+
"properties" : {
13+
"preserves-vector-representation" : true,
14+
"template-rendering-intent" : "template"
15+
}
16+
}
Lines changed: 7 additions & 0 deletions
Loading

Barik/Views/MenuBarView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ struct MenuBarView: View {
103103
PomodoroWidget()
104104
.environmentObject(config)
105105

106+
case "default.countdown":
107+
CountdownWidget()
108+
.environmentObject(config)
109+
106110
case "spacer":
107111
Spacer().frame(minWidth: 50, maxWidth: .infinity)
108112

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
final class CountdownManager: ObservableObject {
5+
static let shared = CountdownManager()
6+
7+
@Published var label: String = "Christmas"
8+
@Published var targetYear: Int = 2026
9+
@Published var targetMonth: Int = 12
10+
@Published var targetDay: Int = 25
11+
12+
var targetDate: Date {
13+
var components = DateComponents()
14+
components.year = targetYear
15+
components.month = targetMonth
16+
components.day = targetDay
17+
return Calendar.current.startOfDay(for: Calendar.current.date(from: components)!)
18+
}
19+
20+
var daysRemaining: Int {
21+
let today = Calendar.current.startOfDay(for: Date())
22+
let components = Calendar.current.dateComponents([.day], from: today, to: targetDate)
23+
return max(components.day ?? 0, 0)
24+
}
25+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import SwiftUI
2+
3+
struct CountdownPopup: View {
4+
@ObservedObject private var manager = CountdownManager.shared
5+
@State private var showSettings = false
6+
7+
var body: some View {
8+
VStack(spacing: 16) {
9+
ZStack {
10+
Text("Countdown")
11+
.font(.system(size: 13, weight: .semibold))
12+
.foregroundStyle(.white)
13+
HStack {
14+
Spacer()
15+
Button {
16+
withAnimation(.easeInOut(duration: 0.2)) {
17+
showSettings.toggle()
18+
}
19+
} label: {
20+
Image(systemName: showSettings ? "xmark" : "gearshape")
21+
.font(.system(size: 11))
22+
.foregroundStyle(.gray)
23+
}
24+
.buttonStyle(.plain)
25+
}
26+
}
27+
28+
if showSettings {
29+
settingsView
30+
} else {
31+
countdownView
32+
}
33+
}
34+
.frame(width: 260)
35+
.padding(24)
36+
}
37+
38+
@ViewBuilder
39+
private var countdownView: some View {
40+
VStack(spacing: 8) {
41+
Text("\(manager.daysRemaining)")
42+
.font(.system(size: 40, weight: .bold, design: .rounded))
43+
.foregroundStyle(.white)
44+
45+
Text(manager.daysRemaining == 1
46+
? "day until \(manager.label)!"
47+
: "days until \(manager.label)!")
48+
.font(.system(size: 13))
49+
.foregroundStyle(.gray)
50+
.multilineTextAlignment(.center)
51+
52+
Text(formattedTargetDate)
53+
.font(.system(size: 11))
54+
.foregroundStyle(.gray.opacity(0.6))
55+
.padding(.top, 4)
56+
}
57+
}
58+
59+
@ViewBuilder
60+
private var settingsView: some View {
61+
VStack(spacing: 12) {
62+
VStack(alignment: .leading, spacing: 4) {
63+
Text("Label")
64+
.font(.system(size: 11))
65+
.foregroundStyle(.gray)
66+
TextField("Event name", text: $manager.label)
67+
.textFieldStyle(.roundedBorder)
68+
.font(.system(size: 12))
69+
}
70+
71+
VStack(alignment: .leading, spacing: 4) {
72+
Text("Target Date")
73+
.font(.system(size: 11))
74+
.foregroundStyle(.gray)
75+
HStack(spacing: 6) {
76+
Stepper("", value: $manager.targetMonth, in: 1...12)
77+
.labelsHidden()
78+
Text("\(monthName)/\(String(format: "%02d", manager.targetDay))/\(String(manager.targetYear))")
79+
.font(.system(size: 12, design: .monospaced))
80+
.foregroundStyle(.white)
81+
}
82+
83+
HStack(spacing: 8) {
84+
stepperRow("Month", value: $manager.targetMonth, range: 1...12)
85+
stepperRow("Day", value: $manager.targetDay, range: 1...31)
86+
stepperRow("Year", value: $manager.targetYear, range: 2025...2100)
87+
}
88+
}
89+
}
90+
}
91+
92+
private var monthName: String {
93+
let formatter = DateFormatter()
94+
formatter.dateFormat = "MMM"
95+
var components = DateComponents()
96+
components.month = manager.targetMonth
97+
if let date = Calendar.current.date(from: components) {
98+
return formatter.string(from: date)
99+
}
100+
return ""
101+
}
102+
103+
private var formattedTargetDate: String {
104+
let formatter = DateFormatter()
105+
formatter.dateStyle = .long
106+
return formatter.string(from: manager.targetDate)
107+
}
108+
109+
private func stepperRow(_ label: String, value: Binding<Int>, range: ClosedRange<Int>) -> some View {
110+
VStack(spacing: 2) {
111+
Text(label)
112+
.font(.system(size: 9))
113+
.foregroundStyle(.gray)
114+
HStack(spacing: 4) {
115+
Button {
116+
if value.wrappedValue > range.lowerBound {
117+
value.wrappedValue -= 1
118+
}
119+
} label: {
120+
Image(systemName: "minus")
121+
.font(.system(size: 8, weight: .bold))
122+
.foregroundStyle(.white)
123+
.frame(width: 18, height: 18)
124+
.background(Color.gray.opacity(0.3))
125+
.clipShape(Circle())
126+
}
127+
.buttonStyle(.plain)
128+
129+
Text("\(value.wrappedValue)")
130+
.font(.system(size: 11, weight: .semibold, design: .monospaced))
131+
.foregroundStyle(.white)
132+
.lineLimit(1)
133+
.fixedSize()
134+
.frame(minWidth: 20)
135+
136+
Button {
137+
if value.wrappedValue < range.upperBound {
138+
value.wrappedValue += 1
139+
}
140+
} label: {
141+
Image(systemName: "plus")
142+
.font(.system(size: 8, weight: .bold))
143+
.foregroundStyle(.white)
144+
.frame(width: 18, height: 18)
145+
.background(Color.gray.opacity(0.3))
146+
.clipShape(Circle())
147+
}
148+
.buttonStyle(.plain)
149+
}
150+
}
151+
}
152+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import SwiftUI
2+
3+
struct CountdownWidget: View {
4+
@EnvironmentObject var configProvider: ConfigProvider
5+
var config: ConfigData { configProvider.config }
6+
7+
@ObservedObject private var manager = CountdownManager.shared
8+
@State private var rect: CGRect = .zero
9+
10+
private let timer = Timer.publish(every: 3600, on: .main, in: .common).autoconnect()
11+
12+
var body: some View {
13+
CalendarIcon(days: manager.daysRemaining)
14+
.shadow(color: .foregroundShadowOutside, radius: 3)
15+
.background(
16+
GeometryReader { geometry in
17+
Color.clear
18+
.onAppear {
19+
rect = geometry.frame(in: .global)
20+
}
21+
.onChange(of: geometry.frame(in: .global)) { _, newState in
22+
rect = newState
23+
}
24+
}
25+
)
26+
.experimentalConfiguration(cornerRadius: 15)
27+
.frame(maxHeight: .infinity)
28+
.background(.black.opacity(0.001))
29+
.onTapGesture {
30+
MenuBarPopup.show(rect: rect, id: "countdown") {
31+
CountdownPopup()
32+
}
33+
}
34+
.onReceive(timer) { _ in
35+
manager.objectWillChange.send()
36+
}
37+
}
38+
}
39+
40+
private struct CalendarIcon: View {
41+
let days: Int
42+
43+
var body: some View {
44+
ZStack {
45+
// Calendar outline
46+
RoundedRectangle(cornerRadius: 3)
47+
.stroke(Color.foregroundOutside, lineWidth: 1.5)
48+
.frame(width: 18, height: 20)
49+
50+
// Top bar
51+
Rectangle()
52+
.fill(Color.foregroundOutside)
53+
.frame(width: 18, height: 5)
54+
.clipShape(
55+
.rect(topLeadingRadius: 3, topTrailingRadius: 3)
56+
)
57+
.offset(y: -7.5)
58+
59+
// Binding rings
60+
HStack(spacing: 8) {
61+
RoundedRectangle(cornerRadius: 1)
62+
.fill(Color.foregroundOutside)
63+
.frame(width: 2, height: 5)
64+
RoundedRectangle(cornerRadius: 1)
65+
.fill(Color.foregroundOutside)
66+
.frame(width: 2, height: 5)
67+
}
68+
.offset(y: -10)
69+
70+
// Day number
71+
Text("\(days)")
72+
.font(.system(size: 10, weight: .bold))
73+
.foregroundStyle(.foregroundOutside)
74+
.offset(y: 2)
75+
}
76+
.frame(width: 22, height: 24)
77+
}
78+
}

Barik/Widgets/Pomodoro/PomodoroManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class PomodoroManager: ObservableObject {
2929
@Published var completedSessions: Int = 0
3030
@Published var isPaused: Bool = false
3131

32+
@Published var showTimerText: Bool = false
33+
3234
@Published var workDuration: Int = 25
3335
@Published var breakDuration: Int = 5
3436
@Published var longBreakDuration: Int = 15

Barik/Widgets/Pomodoro/PomodoroPopup.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import SwiftUI
2+
import UserNotifications
23

34
struct PomodoroPopup: View {
45
@ObservedObject private var manager = PomodoroManager.shared
56
@State private var showSettings = false
7+
@State private var notificationStatus: UNAuthorizationStatus = .notDetermined
68

79
var body: some View {
810
VStack(spacing: 16) {
@@ -111,8 +113,57 @@ struct PomodoroPopup: View {
111113
durationStepper("Break", value: $manager.breakDuration, range: 1...30)
112114
durationStepper("Long Break", value: $manager.longBreakDuration, range: 1...60)
113115
durationStepper("Sessions", value: $manager.sessionsBeforeLongBreak, range: 1...10)
116+
117+
Divider()
118+
.background(Color.gray.opacity(0.3))
119+
120+
HStack {
121+
Text("Show timer")
122+
.font(.system(size: 12))
123+
.foregroundStyle(.gray)
124+
Spacer()
125+
Toggle("", isOn: $manager.showTimerText)
126+
.toggleStyle(.switch)
127+
.controlSize(.mini)
128+
}
129+
130+
if notificationStatus != .authorized {
131+
Divider()
132+
.background(Color.gray.opacity(0.3))
133+
134+
Button {
135+
if notificationStatus == .notDetermined {
136+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
137+
DispatchQueue.main.async {
138+
notificationStatus = granted ? .authorized : .denied
139+
}
140+
}
141+
} else {
142+
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {
143+
NSWorkspace.shared.open(url)
144+
}
145+
}
146+
} label: {
147+
HStack(spacing: 4) {
148+
Image(systemName: notificationStatus == .denied ? "bell.slash" : "bell.badge")
149+
.font(.system(size: 10))
150+
Text(notificationStatus == .denied ? "Open Settings" : "Enable Notifications")
151+
.font(.system(size: 11))
152+
}
153+
.foregroundStyle(notificationStatus == .denied ? .orange : .blue)
154+
.frame(maxWidth: .infinity)
155+
}
156+
.buttonStyle(.plain)
157+
}
114158
}
115159
.frame(width: 160)
160+
.onAppear {
161+
UNUserNotificationCenter.current().getNotificationSettings { settings in
162+
DispatchQueue.main.async {
163+
notificationStatus = settings.authorizationStatus
164+
}
165+
}
166+
}
116167
}
117168

118169
private var phaseColor: Color {

0 commit comments

Comments
 (0)