diff --git a/Work Hours/AppInfo.swift b/Work Hours/AppInfo.swift index 8a903ef..1cbeaa8 100644 --- a/Work Hours/AppInfo.swift +++ b/Work Hours/AppInfo.swift @@ -5,7 +5,6 @@ // Created by Janez Troha on 24/12/2021. // -import Foundation import Foundation import os.log import SwiftUI @@ -19,5 +18,4 @@ enum AppInfo { static var isRunningTests: Bool { ProcessInfo.processInfo.arguments.contains("isRunningTests") || ProcessInfo.processInfo.environment["CI"] ?? "false" != "false" } - } diff --git a/Work Hours/Assets.xcassets/AppIcon.appiconset/Contents.json b/Work Hours/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..34c750f --- /dev/null +++ b/Work Hours/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512-1.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_128x128.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_128x128.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_128x128@2x.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_128x128@2x.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_16x16.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_16x16.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_16x16@2x.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_16x16@2x.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_256x256.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_256x256.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_256x256.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_256x256.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_32x32.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_32x32.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_32x32@2x.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_32x32@2x.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_256x256@2x.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512-1.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_256x256@2x.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512-1.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_512x512.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_512x512.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512.png diff --git a/Work Hours/Assets.xcassets/AppIcon.iconset/icon_512x512@2x.png b/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png similarity index 100% rename from Work Hours/Assets.xcassets/AppIcon.iconset/icon_512x512@2x.png rename to Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png diff --git a/Work Hours/Defaults.swift b/Work Hours/Defaults.swift new file mode 100644 index 0000000..f7fd553 --- /dev/null +++ b/Work Hours/Defaults.swift @@ -0,0 +1,21 @@ +// +// Defaults.swift +// Work Hours +// +// Created by Janez Troha on 24/12/2021. +// + +import Defaults +import Foundation + +enum StatusBarIcon: String { + case deskclock + case deskclockFill = "deskclock.fill" + case lanyardcardFill = "lanyardcard.fill" + case clockArrowCirclepath = "clock.arrow.circlepath" +} + +extension Defaults.Keys { + static let statusBarIcon = Key("statusBarIcon", default: StatusBarIcon.deskclock.rawValue) + static let stopOnSleep = Key("stopOnSleep", default: true) +} diff --git a/Work Hours/Extensions/Calendar.swift b/Work Hours/Extensions/Calendar.swift index 5f02279..a8bd451 100644 --- a/Work Hours/Extensions/Calendar.swift +++ b/Work Hours/Extensions/Calendar.swift @@ -11,9 +11,11 @@ extension Calendar { static func isSameYear(_ lcp: Date, _ rcp: Date) -> Bool { return Calendar.current.compare(lcp, to: rcp, toGranularity: .year) == .orderedSame } + static func isSameMonth(_ lcp: Date, _ rcp: Date) -> Bool { return Calendar.current.compare(lcp, to: rcp, toGranularity: .month) == .orderedSame } + static func isSameDay(_ lcp: Date, _ rcp: Date) -> Bool { return Calendar.current.compare(lcp, to: rcp, toGranularity: .day) == .orderedSame } diff --git a/Work Hours/Extensions/Date.swift b/Work Hours/Extensions/Date.swift index 67ad107..a1eb8e5 100644 --- a/Work Hours/Extensions/Date.swift +++ b/Work Hours/Extensions/Date.swift @@ -10,21 +10,20 @@ extension Date { static func - (lhs: Date, rhs: Date) -> TimeInterval { return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate } - - init(dateString:String) { - self = Date.iso8601Formatter.date(from: dateString)! - } - - static let iso8601Formatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withFullDate, - .withTime, - .withDashSeparatorInDate, - .withColonSeparatorInTime] - return formatter - }() - + init(dateString: String) { + self = Date.iso8601Formatter.date(from: dateString)! + } + + static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, + .withTime, + .withDashSeparatorInDate, + .withColonSeparatorInTime] + return formatter + }() + static let RFC3339DateFormatter: DateFormatter = { let RFC3339DateFormatter = DateFormatter() RFC3339DateFormatter.locale = Locale(identifier: "en_US_POSIX") @@ -32,7 +31,7 @@ extension Date { RFC3339DateFormatter.timeZone = TimeZone(secondsFromGMT: 0) return RFC3339DateFormatter }() - + static let YearMonthFormatter: DateFormatter = { let YearMonthFormatter = DateFormatter() YearMonthFormatter.locale = Locale(identifier: "en_US_POSIX") @@ -40,6 +39,7 @@ extension Date { YearMonthFormatter.timeZone = TimeZone(secondsFromGMT: 0) return YearMonthFormatter }() + static let YearMonthDayFormatter: DateFormatter = { let YearMonthFormatter = DateFormatter() YearMonthFormatter.locale = Locale(identifier: "en_US_POSIX") @@ -47,7 +47,6 @@ extension Date { YearMonthFormatter.timeZone = TimeZone(secondsFromGMT: 0) return YearMonthFormatter }() - } extension TimeInterval { @@ -58,11 +57,11 @@ extension TimeInterval { return String(format: "%0.2d:%0.2d", hours, minutes) } - + static func hoursAndMinutes(_ diff: Int) -> String { let minutes = (diff / 60) % 60 let hours = (diff / 3600) - + return String(format: "%0.2dh %0.2dm", hours, minutes) } } diff --git a/Work Hours/ReportsGenerator.swift b/Work Hours/ReportsGenerator.swift index 2c2fe1c..9a2cf47 100644 --- a/Work Hours/ReportsGenerator.swift +++ b/Work Hours/ReportsGenerator.swift @@ -18,8 +18,8 @@ enum Action: String { struct Report { let timestamp: String let amount: String - - static func fromData(_ data: [String:Int])->[Report]{ + + static func fromData(_ data: [String: Int]) -> [Report] { var reports = [Report]() for (ts, duration) in data { reports.append(Report(timestamp: ts, amount: TimeInterval.hoursAndMinutes(duration))) @@ -28,7 +28,6 @@ struct Report { } } - enum Events { static var logFile: URL? { let docURL = URL(string: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!)! @@ -42,14 +41,14 @@ enum Events { } return FileManager.documentDirectoryURL.appendingPathComponent("MyWorkHours").appendingPathComponent("log.csv") } - + static func write(_ action: Action, _ timestamp: Date) { guard let logFile = logFile else { return } - + let data = "\(action.rawValue),\(timestamp.ISO8601Format())\n" - + if FileManager.default.fileExists(atPath: logFile.path) { os_log("Appending %s to %s", data, logFile.path) if let fileHandle = try? FileHandle(forWritingTo: logFile.absoluteURL) { @@ -62,15 +61,15 @@ enum Events { try? "action,timestamp\n\(data)".write(to: logFile.absoluteURL, atomically: true, encoding: .utf8) } } - + // Restore last state from event source static func isRunning() -> Date? { guard let logFile = logFile else { return nil } - + if FileManager.default.fileExists(atPath: logFile.path) { - guard let csvFile: CSV = try? CSV(url: logFile, loadColumns:false) else { + guard let csvFile: CSV = try? CSV(url: logFile, loadColumns: false) else { return nil } guard let lastLine = csvFile.enumeratedRows.last else { @@ -81,14 +80,12 @@ enum Events { return Date(dateString: timestamp) } return nil - } - return nil - + return nil } - + static func generateReport(formatter: DateFormatter) -> [Report]? { - var data:[String:Int] = [:] + var data: [String: Int] = [:] guard let logFile = logFile else { return nil } @@ -108,28 +105,27 @@ enum Events { os_log("Unknown line %s", line) return nil } - + // corrupted data if startTimestamp != nil, endTimestamp != nil { // same day, TODO: remove once we are ok with current state of parsing - //if formatter.string(from: startTimestamp!) == formatter.string(from: endTimestamp!) { - let elapsed = Int(endTimestamp!.timeIntervalSince(startTimestamp!)) - let key = formatter.string(from: startTimestamp!) - if let val = data[key] { - data[key] = elapsed + val - }else{ - data[key] = elapsed - } - startTimestamp = nil - endTimestamp = nil - //}else { + // if formatter.string(from: startTimestamp!) == formatter.string(from: endTimestamp!) { + let elapsed = Int(endTimestamp!.timeIntervalSince(startTimestamp!)) + let key = formatter.string(from: startTimestamp!) + if let val = data[key] { + data[key] = elapsed + val + } else { + data[key] = elapsed + } + startTimestamp = nil + endTimestamp = nil + // }else { // return nil - //} + // } } } return Report.fromData(data) } return nil - } } diff --git a/Work Hours/StatusBar.swift b/Work Hours/StatusBar.swift index 075b8e9..6bc1df5 100644 --- a/Work Hours/StatusBar.swift +++ b/Work Hours/StatusBar.swift @@ -11,29 +11,27 @@ import SwiftUI typealias Scheduler = NSBackgroundActivityScheduler - class StatusBarController: NSObject, NSMenuDelegate { var statusItem: NSStatusItem! var statusItemMenu: NSMenu! var timerModel = TimerModel() - func addSubmenu(withTitle: String, action: Selector?) -> NSMenuItem { + func addSubmenu(withTitle: String, action: Selector?) -> NSMenuItem { let item = NSMenuItem(title: withTitle, action: action, keyEquivalent: "") item.target = self return item } - func fromReports(_ reports: [Report])-> NSMenu{ + + func fromReports(_ reports: [Report]) -> NSMenu { let submenu = NSMenu() for report in reports { submenu.addItem(addSubmenu(withTitle: "\(report.timestamp) worked \(report.amount)", action: #selector(copyToPasteboard))) } return submenu } - - @objc func copyToPasteboard() { - - } - + + @objc func copyToPasteboard() {} + override init() { super.init() @@ -78,9 +76,7 @@ class StatusBarController: NSObject, NSMenuDelegate { addApplicationItems() } - func menuDidClose(_: NSMenu) { - - } + func menuDidClose(_: NSMenu) {} func menuWillOpen(_: NSMenu) { updateMenu() @@ -94,7 +90,6 @@ class StatusBarController: NSObject, NSMenuDelegate { } } - func addApplicationItems() { if !timerModel.isRunning { let startItem = NSMenuItem(title: "Start Work", action: #selector(toggle), keyEquivalent: "s") @@ -104,7 +99,7 @@ class StatusBarController: NSObject, NSMenuDelegate { } else { let changeStart = NSMenuItem(title: "Change Start Time", action: #selector(toggle), keyEquivalent: "c") changeStart.target = self - //statusItemMenu.addItem(changeStart) + // statusItemMenu.addItem(changeStart) let stopItem = NSMenuItem(title: "Stop Work", action: #selector(toggle), keyEquivalent: "s") stopItem.target = self @@ -114,14 +109,14 @@ class StatusBarController: NSObject, NSMenuDelegate { statusItemMenu.addItem(NSMenuItem.separator()) let dailyItem = NSMenuItem(title: "Daily Reports", action: nil, keyEquivalent: "") - if let dailyReports = Events.generateReport(formatter: Date.YearMonthDayFormatter){ - dailyItem.submenu = fromReports(dailyReports) + if let dailyReports = Events.generateReport(formatter: Date.YearMonthDayFormatter) { + dailyItem.submenu = fromReports(dailyReports.sorted(by: { $0.timestamp < $1.timestamp }).suffix(7)) } statusItemMenu.addItem(dailyItem) let monthlyItem = NSMenuItem(title: "Monthly Reports", action: nil, keyEquivalent: "") - if let monthlyReports = Events.generateReport(formatter: Date.YearMonthFormatter){ - monthlyItem.submenu = fromReports(monthlyReports) + if let monthlyReports = Events.generateReport(formatter: Date.YearMonthFormatter) { + monthlyItem.submenu = fromReports(monthlyReports.sorted(by: { $0.timestamp < $1.timestamp })) } statusItemMenu.addItem(monthlyItem) diff --git a/Work Hours/TimerModel.swift b/Work Hours/TimerModel.swift index 8cb37a2..ef31a37 100644 --- a/Work Hours/TimerModel.swift +++ b/Work Hours/TimerModel.swift @@ -10,9 +10,6 @@ import Foundation import os.log import SwiftCSV - - - class TimerModel: ObservableObject { @Published var display: String = "00:00" @Published var isRunning: Bool diff --git a/Work Hours/Views/AboutSettingsView.swift b/Work Hours/Views/AboutSettingsView.swift index 7974a6f..5085bcd 100644 --- a/Work Hours/Views/AboutSettingsView.swift +++ b/Work Hours/Views/AboutSettingsView.swift @@ -16,27 +16,26 @@ struct AboutSettingsView: View { VStack(alignment: .leading) { Link("Work Hours", destination: URL(string: "https://niteo.co/work-hours-app")!).font(.title) - + VStack(alignment: .leading, spacing: 0) { Text("Version: \(AppInfo.appVersion) - \(AppInfo.buildVersion)") } - + HStack(spacing: 0) { Text("We’d love to ") Link("hear from you!", destination: URL(string: "https://niteo.co/contact")!) } - + HStack(spacing: 0) { Text("Made with ❤️ at ") Link("Niteo", destination: URL(string: "https://niteo.co/about")!) } } - + }.frame(width: 350, height: 100).padding(25) } - } struct AboutSettingsView_Previews: PreviewProvider { diff --git a/Work Hours/Views/GeneralSettingsView.swift b/Work Hours/Views/GeneralSettingsView.swift index 70e53c3..0c5658e 100644 --- a/Work Hours/Views/GeneralSettingsView.swift +++ b/Work Hours/Views/GeneralSettingsView.swift @@ -5,12 +5,15 @@ // Created by Janez Troha on 24/12/2021. // +import Defaults import LaunchAtLogin import SwiftUI struct GeneralSettingsView: View { @ObservedObject private var atLogin = LaunchAtLogin.observable - + @Default(.statusBarIcon) var statusBarIcon + @Default(.stopOnSleep) var stopOnSleep + var body: some View { Form { Section( @@ -19,8 +22,27 @@ struct GeneralSettingsView: View { Toggle("Automatically launch on system startup", isOn: $atLogin.isEnabled) } } + Section( + footer: Text("Stops timer when your mac goes to sleep.").font(.footnote)) { + VStack(alignment: .leading) { + Toggle("Stop timer on sleep", isOn: $stopOnSleep) + } + } + Section( + footer: Text("Your preffered icon in status bar.").font(.footnote)) { + VStack(alignment: .leading) { + VStack { + Picker("Icon", selection: $statusBarIcon, content: { // <2> + Image(systemName: StatusBarIcon.deskclock.rawValue).tag(StatusBarIcon.deskclock.rawValue) + Image(systemName: StatusBarIcon.deskclockFill.rawValue).tag(StatusBarIcon.deskclockFill.rawValue) + Image(systemName: StatusBarIcon.lanyardcardFill.rawValue).tag(StatusBarIcon.lanyardcardFill.rawValue) + Image(systemName: StatusBarIcon.clockArrowCirclepath.rawValue).tag(StatusBarIcon.clockArrowCirclepath.rawValue) + }).frame(maxWidth: 90) + } + } + } } - + .frame(width: 350, height: 100).padding(25) } } diff --git a/Work Hours/Views/SettingsView.swift b/Work Hours/Views/SettingsView.swift index 085f47e..00b7b84 100644 --- a/Work Hours/Views/SettingsView.swift +++ b/Work Hours/Views/SettingsView.swift @@ -5,16 +5,15 @@ // Created by Janez Troha on 24/12/2021. // -import SwiftUI import AppKit - +import SwiftUI struct SettingsView: View { @State var selected: Tabs enum Tabs: Hashable { case general, about } - + var body: some View { TabView(selection: $selected) { GeneralSettingsView() @@ -29,7 +28,6 @@ struct SettingsView: View { .tag(Tabs.about) } } - } struct SettingsView_Previews: PreviewProvider { diff --git a/Work Hours/Views/TimerView.swift b/Work Hours/Views/TimerView.swift index 0e924d0..260e9c7 100644 --- a/Work Hours/Views/TimerView.swift +++ b/Work Hours/Views/TimerView.swift @@ -5,11 +5,13 @@ // Created by Janez Troha on 19/12/2021. // +import Defaults import SwiftUI struct TimerView: View { @ObservedObject var timerModel: TimerModel @Environment(\.colorScheme) var colorScheme + @Default(.statusBarIcon) var statusBarIcon var body: some View { if timerModel.isRunning { @@ -27,7 +29,7 @@ struct TimerView: View { } else { ZStack { // Moves in from leading out, out to trailing edge. - Image(systemName: "lanyardcard.fill") + Image(systemName: statusBarIcon) .resizable() .opacity(0.9) .frame(width: 16, height: 16, alignment: .center) diff --git a/Work Hours/WorkHours.swift b/Work Hours/WorkHours.swift index c11c54f..01151e9 100644 --- a/Work Hours/WorkHours.swift +++ b/Work Hours/WorkHours.swift @@ -8,9 +8,26 @@ import SwiftUI import Cocoa +import Defaults import os.log import SwiftUI +extension NSWorkspace { + static func onWakeup(_ fn: @escaping (Notification) -> Void) { + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, + object: nil, + queue: nil, + using: fn) + } + + static func onSleep(_ fn: @escaping (Notification) -> Void) { + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, + object: nil, + queue: nil, + using: fn) + } +} + class AppDelegate: NSObject, NSApplicationDelegate { var welcomeWindow: NSWindow? var statusBar: StatusBarController? @@ -25,6 +42,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { statusBar = StatusBarController() statusBar?.updateMenu() + + NSWorkspace.onSleep { _ in + if Defaults[.stopOnSleep] { + self.statusBar?.timerModel.stop() + } + } } func application(_: NSApplication, open urls: [URL]) {