├── .github └── FUNDING.yml ├── docs ├── images │ ├── reminders-icon.png │ ├── reminders-icon-old.png │ ├── reminder-menubar-dark.png │ ├── reminders-permission.png │ ├── reminder-menubar-light.png │ ├── reminders-menubar-demo.gif │ ├── add-localization-instruction.png │ └── reminders-permission-not-enabled.png ├── adding-new-languages.md └── fix-for-opencore-legacy-patcher.md ├── reminders-menubar ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── menuBarIcon │ │ │ ├── Contents.json │ │ │ ├── icon-note-4.imageset │ │ │ │ ├── icon-note-4.svg │ │ │ │ └── Contents.json │ │ │ ├── icon-small-dot.imageset │ │ │ │ ├── icon-small-dot.svg │ │ │ │ └── Contents.json │ │ │ ├── icon-bell-1.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-bell-1.svg │ │ │ ├── icon-bell-2.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-bell-2.svg │ │ │ ├── icon-note-1.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-note-1.svg │ │ │ ├── icon-note-2.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-note-2.svg │ │ │ ├── icon-note-3.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-note-3.svg │ │ │ ├── icon-note-5.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-note-5.svg │ │ │ ├── icon-reminder-1.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-reminder-1.svg │ │ │ ├── icon-reminder-2.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-reminder-2.svg │ │ │ └── icon-reminder-3.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── icon-reminder-3.svg │ │ ├── AppIcon.appiconset │ │ │ ├── reminders-16x16.png │ │ │ ├── reminders-32x32.png │ │ │ ├── reminders-128x128.png │ │ │ ├── reminders-256x256.png │ │ │ ├── reminders-512x512.png │ │ │ ├── reminders-128x128@2x.png │ │ │ ├── reminders-16x16@2x.png │ │ │ ├── reminders-256x256@2x.png │ │ │ ├── reminders-32x32@2x.png │ │ │ ├── reminders-512x512@2x.png │ │ │ └── Contents.json │ │ └── empty.imageset │ │ │ ├── Contents.json │ │ │ └── empty.svg │ ├── Colors.xcassets │ │ ├── Contents.json │ │ ├── borderContrast.colorset │ │ │ └── Contents.json │ │ ├── buttonHover.colorset │ │ │ └── Contents.json │ │ ├── backgroundTheme.colorset │ │ │ └── Contents.json │ │ ├── textFieldBackgroundTransparent.colorset │ │ │ └── Contents.json │ │ └── textFieldBackground.colorset │ │ │ └── Contents.json │ ├── RmbColorScheme.swift │ ├── RmbColorKey.swift │ ├── remindersLocalized.swift │ └── InfoPlist.xcstrings ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Extensions │ ├── UserDefaults+Extensions.swift │ ├── Binding+Extensions.swift │ ├── Color+Extensions.swift │ ├── NSTextField+Extensions.swift │ ├── URL+Extensions.swift │ ├── Array+Extension.swift │ ├── NSTableView+Extensions.swift │ ├── Calendar+Extensions.swift │ ├── EKReminderPriority+Extensions.swift │ ├── String+Extensions.swift │ ├── ArrayReminderItem+Extension.swift │ ├── Date+Extensions.swift │ └── EKReminder+Extensions.swift ├── reminders_menubar.entitlements ├── Models │ ├── ReminderList.swift │ ├── LabeledReminders.swift │ ├── PrioritizedReminders.swift │ ├── RmbMenuBarCounterType.swift │ ├── Release.swift │ ├── RmbIcon.swift │ ├── ReminderItem.swift │ ├── ReminderInterval.swift │ ├── RmbReminder.swift │ └── RemindersData.swift ├── Views │ ├── Helpers │ │ ├── OnKeyboardShortcut.swift │ │ ├── SelectableView.swift │ │ └── RmbDatePicker.swift │ ├── SettingsBarView │ │ ├── SettingsBarView.swift │ │ ├── SettingsBarToggleButton.swift │ │ ├── SettingsBarFilterMenu.swift │ │ └── SettingsBarGearMenu.swift │ ├── UpcomingRemindersView │ │ ├── UpcomingRemindersContent.swift │ │ └── UpcomingRemindersTitle.swift │ ├── ReminderItemView │ │ ├── ReminderExternalLinksView.swift │ │ ├── ReminderCompleteButton.swift │ │ ├── ReminderEllipsisMenuView │ │ │ ├── ReminderChangePriorityOptionMenu.swift │ │ │ ├── ReminderChangeListOptionMenu.swift │ │ │ ├── ReminderEllipsisMenuView.swift │ │ │ └── ReminderChangeDueDateOptionMenu.swift │ │ ├── ReminderDateDescriptionView.swift │ │ ├── ReminderItemView.swift │ │ └── ReminderEditPopover.swift │ ├── NoReminderItemsView.swift │ ├── CalendarTitle.swift │ ├── Windows │ │ ├── KeyboardShortcutView.swift │ │ └── AboutView.swift │ ├── ContentView.swift │ └── FormNewReminderView │ │ ├── NewReminderInfoOptionsView.swift │ │ └── FormNewReminderView.swift ├── Constants.swift ├── Services │ ├── GithubService.swift │ ├── PriorityParser.swift │ ├── KeyboardShortcutService.swift │ ├── CalendarParser.swift │ ├── DateParser.swift │ ├── RemindersService.swift │ └── UserPreferences.swift ├── Info.plist ├── AppUpdateCheckHelper.swift ├── AppCommands.swift └── AppDelegate.swift ├── reminders-menubar-launcher ├── main.swift ├── reminders_menubar_launcher.entitlements ├── AppDelegate.swift └── Info.plist ├── reminders-menubar.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── .gitignore ├── .swiftlint.yml └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: DamascenoRafael 4 | ko_fi: damascenorafael 5 | -------------------------------------------------------------------------------- /docs/images/reminders-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/reminders-icon.png -------------------------------------------------------------------------------- /docs/images/reminders-icon-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/reminders-icon-old.png -------------------------------------------------------------------------------- /docs/images/reminder-menubar-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/reminder-menubar-dark.png -------------------------------------------------------------------------------- /docs/images/reminders-permission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/reminders-permission.png -------------------------------------------------------------------------------- /docs/images/reminder-menubar-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/reminder-menubar-light.png -------------------------------------------------------------------------------- /docs/images/reminders-menubar-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/reminders-menubar-demo.gif -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/images/add-localization-instruction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/add-localization-instruction.png -------------------------------------------------------------------------------- /reminders-menubar/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/images/reminders-permission-not-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/docs/images/reminders-permission-not-enabled.png -------------------------------------------------------------------------------- /reminders-menubar-launcher/main.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | let delegate = AppDelegate() 4 | 5 | NSApplication.shared.delegate = delegate 6 | 7 | _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) 8 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-16x16.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-32x32.png -------------------------------------------------------------------------------- /reminders-menubar.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-128x128.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-256x256.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-512x512.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-128x128@2x.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-16x16@2x.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-256x256@2x.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-32x32@2x.png -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DamascenoRafael/reminders-menubar/HEAD/reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/reminders-512x512@2x.png -------------------------------------------------------------------------------- /reminders-menubar-launcher/reminders_menubar_launcher.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/UserDefaults+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension UserDefaults { 4 | func boolWithDefaultValueTrue(forKey key: String) -> Bool { 5 | guard self.object(forKey: key) != nil else { 6 | return true 7 | } 8 | return self.bool(forKey: key) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /reminders-menubar.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/Binding+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Binding where Value: Equatable { 4 | init(_ source: Binding, replacingNilWith nilProxy: Value) { 5 | self.init( 6 | get: { source.wrappedValue ?? nilProxy }, 7 | set: { source.wrappedValue = $0 } 8 | ) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/Color+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | static func rmbColor(for colorKey: RmbColorKey, and colorSchemeContrast: ColorSchemeContrast) -> Color { 5 | let isTransparencyEnabled = UserPreferences.shared.backgroundIsTransparent && colorSchemeContrast == .standard 6 | return colorKey.color(withTransparency: isTransparencyEnabled) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/NSTextField+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // Workaround to remove focus ring highlight border from textfield (on macOS Big Sur) 4 | // https://stackoverflow.com/questions/59813943/swiftui-remove-focus-ring-highlight-border-from-macos-textfield 5 | 6 | extension NSTextField { 7 | open override var focusRingType: NSFocusRingType { 8 | get { .none } 9 | set { } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/URL+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | var displayedUrl: String { 5 | var displayedUrlString = self.absoluteString 6 | if self.absoluteString.starts(with: "http"), let host = self.host { 7 | displayedUrlString = host 8 | } 9 | return displayedUrlString.replacingOccurrences(of: "^www.", with: "", options: .regularExpression) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /reminders-menubar/reminders_menubar.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | com.apple.security.personal-information.calendars 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/empty.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "empty.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-4.imageset/icon-note-4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /reminders-menubar/Models/ReminderList.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | struct ReminderList: Identifiable, Equatable { 4 | let id: String 5 | let calendar: EKCalendar 6 | let reminders: LabeledReminders 7 | 8 | init(for calendar: EKCalendar, with reminderItems: [ReminderItem]) { 9 | self.id = calendar.calendarIdentifier 10 | self.calendar = calendar 11 | self.reminders = LabeledReminders(for: reminderItems) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/Array+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Array { 4 | func separated(by condition: (Element) -> Bool) -> (matching: [Element], notMatching: [Element]) { 5 | var elements = self 6 | let partition = elements.partition(by: { condition($0) }) 7 | let matching = Array(elements[partition...]) 8 | let notMatching = Array(elements[.. 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /reminders-menubar.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "69c39523ac0471922b625cc56b8722155cfd2afd660bdd4f5ff67335b5b87714", 3 | "pins" : [ 4 | { 5 | "identity" : "keyboardshortcuts", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sindresorhus/KeyboardShortcuts", 8 | "state" : { 9 | "revision" : "045cf174010beb335fa1d2567d18c057b8787165", 10 | "version" : "2.3.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-small-dot.imageset/icon-small-dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-bell-1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-bell-1.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-bell-2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-bell-2.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-note-1.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-note-2.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-note-3.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-note-4.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-note-5.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-reminder-1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-reminder-1.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-reminder-2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-reminder-2.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-reminder-3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-reminder-3.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-small-dot.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-small-dot.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-reminder-3.imageset/icon-reminder-3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/NSTableView+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension NSTableView { 4 | open override func viewDidMoveToWindow() { 5 | super.viewDidMoveToWindow() 6 | 7 | // Workaround to present the list without the background transparency 8 | // https://stackoverflow.com/questions/60454752/swiftui-background-color-of-list-mac-os 9 | backgroundColor = NSColor.clear 10 | enclosingScrollView?.drawsBackground = false 11 | 12 | // Removing sticky section header 13 | floatsGroupRows = false 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /reminders-menubar/Models/PrioritizedReminders.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | struct PrioritizedReminders { 4 | let high: [ReminderItem] 5 | let medium: [ReminderItem] 6 | let low: [ReminderItem] 7 | let none: [ReminderItem] 8 | 9 | init(_ reminderItems: [ReminderItem]) { 10 | let remindersByPriority = Dictionary(grouping: reminderItems, by: { $0.reminder.ekPriority }) 11 | high = remindersByPriority[.high] ?? [] 12 | medium = remindersByPriority[.medium] ?? [] 13 | low = remindersByPriority[.low] ?? [] 14 | none = remindersByPriority[.none] ?? [] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /reminders-menubar/Views/Helpers/OnKeyboardShortcut.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct OnKeyboardShortcut: ViewModifier { 4 | let shortcut: KeyboardShortcut 5 | let action: () -> Void 6 | 7 | func body(content: Content) -> some View { 8 | content 9 | .overlay( 10 | Button(action: action) { 11 | Text(verbatim: "") 12 | } 13 | .labelsHidden() 14 | .opacity(0) 15 | .frame(width: 0, height: 0) 16 | .keyboardShortcut(shortcut) 17 | .accessibilityHidden(true) 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /reminders-menubar/Models/RmbMenuBarCounterType.swift: -------------------------------------------------------------------------------- 1 | enum RmbMenuBarCounterType: String, Codable, CaseIterable { 2 | case due 3 | case today 4 | case allReminders 5 | case disabled 6 | 7 | var title: String { 8 | switch self { 9 | case .due: 10 | return rmbLocalized(.showMenuBarDueCountOptionButton) 11 | case .today: 12 | return rmbLocalized(.showMenuBarTodayCountOptionButton) 13 | case .allReminders: 14 | return rmbLocalized(.showMenuBarAllRemindersCountOptionButton) 15 | case .disabled: 16 | return rmbLocalized(.showMenuBarNoCountOptionButton) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/adding-new-languages.md: -------------------------------------------------------------------------------- 1 | # Adding new languages :globe_with_meridians: 2 | 3 | Download the repository and open **reminders-menubar.xcodeproj** in Xcode. 4 | 5 | 1. In Project navigator select the project *reminders-menubar* 6 | 2. In the list of projects and targets select the project *reminders-menubar* 7 | 3. In the opened panel select the "Info" tab 8 | 4. Under "Localizations" select the "+" button and choose the new location 9 | 5. Edit the new location in the **Localizable.xcstrings** file with the translations 10 | 6. Edit the new location in the **InfoPlist.xcstrings** file with the translations 11 | 12 | ![Add localization instruction](images/add-localization-instruction.png) 13 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/Calendar+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Calendar { 4 | func endOfDay(for date: Date) -> Date? { 5 | var dateComponents = DateComponents() 6 | dateComponents.day = 1 7 | dateComponents.second = -1 8 | return self.date(byAdding: dateComponents, to: self.startOfDay(for: date)) 9 | } 10 | 11 | func daysBetween(_ startDate: Date, and endDate: Date) -> Int { 12 | let dateComponents = Calendar.current.dateComponents( 13 | [.day], 14 | from: startOfDay(for: startDate), 15 | to: startOfDay(for: endDate) 16 | ) 17 | return dateComponents.day ?? 0 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-5.imageset/icon-note-5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-3.imageset/icon-note-3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /reminders-menubar/Models/Release.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Release: Decodable { 4 | let version: String 5 | 6 | enum CodingKeys: String, CodingKey { 7 | case version = "tag_name" 8 | } 9 | } 10 | 11 | extension Release { 12 | static func == (lhs: Release, rhs: Release) -> Bool { 13 | return lhs.version.compare(rhs.version, options: .numeric) == .orderedSame 14 | } 15 | 16 | static func < (lhs: Release, rhs: Release) -> Bool { 17 | return lhs.version.compare(rhs.version, options: .numeric) == .orderedAscending 18 | } 19 | 20 | static func > (lhs: Release, rhs: Release) -> Bool { 21 | return lhs.version.compare(rhs.version, options: .numeric) == .orderedDescending 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/EKReminderPriority+Extensions.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKReminderPriority { 4 | var systemImage: String? { 5 | switch self { 6 | case .high: 7 | return "exclamationmark.3" 8 | case .medium: 9 | return "exclamationmark.2" 10 | case .low: 11 | return "exclamationmark" 12 | default: 13 | return nil 14 | } 15 | } 16 | 17 | var nextPriority: EKReminderPriority { 18 | switch self { 19 | case .low: 20 | return .medium 21 | case .medium: 22 | return .high 23 | case .high: 24 | return .none 25 | default: 26 | return .low 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-1.imageset/icon-note-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-note-2.imageset/icon-note-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-bell-2.imageset/icon-bell-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-reminder-1.imageset/icon-reminder-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/RmbColorScheme.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum RmbColorScheme: String, CaseIterable { 4 | case system 5 | case light 6 | case dark 7 | 8 | var colorScheme: ColorScheme? { 9 | switch self { 10 | case .system: 11 | return nil 12 | case .light: 13 | return .light 14 | case .dark: 15 | return .dark 16 | } 17 | } 18 | 19 | var title: String { 20 | switch self { 21 | case .system: 22 | return rmbLocalized(.appAppearanceColorSystemModeOptionButton) 23 | case .light: 24 | return rmbLocalized(.appAppearanceColorLightModeOptionButton) 25 | case .dark: 26 | return rmbLocalized(.appAppearanceColorDarkModeOptionButton) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Colors.xcassets/borderContrast.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "83", 9 | "green" : "83", 10 | "red" : "83" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "180", 27 | "green" : "180", 28 | "red" : "180" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Colors.xcassets/buttonHover.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "230", 9 | "green" : "228", 10 | "red" : "227" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "68", 27 | "green" : "65", 28 | "red" : "64" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Colors.xcassets/backgroundTheme.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.118", 27 | "green" : "0.118", 28 | "red" : "0.118" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /reminders-menubar/Models/RmbIcon.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | enum RmbIcon: String, CaseIterable { 4 | case note1 = "icon-note-1" 5 | case note2 = "icon-note-2" 6 | case note3 = "icon-note-3" 7 | case note4 = "icon-note-4" 8 | case note5 = "icon-note-5" 9 | case bell1 = "icon-bell-1" 10 | case bell2 = "icon-bell-2" 11 | case reminder1 = "icon-reminder-1" 12 | case reminder2 = "icon-reminder-2" 13 | case reminder3 = "icon-reminder-3" 14 | case sfsymbols1 = "checklist" 15 | case sfsymbols2 = "circle.inset.filled" 16 | case smalldot = "icon-small-dot" 17 | 18 | static var defaultIcon: RmbIcon { 19 | return self.note1 20 | } 21 | 22 | var image: NSImage { 23 | return NSImage(named: self.rawValue) ?? NSImage(systemSymbolName: self.rawValue, accessibilityDescription: nil)! 24 | } 25 | 26 | var name: String { 27 | return self.rawValue 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /reminders-menubar/Views/SettingsBarView/SettingsBarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsBarView: View { 4 | var body: some View { 5 | HStack { 6 | SettingsBarFilterMenu() 7 | 8 | Spacer() 9 | 10 | SettingsBarToggleButton() 11 | 12 | Spacer() 13 | 14 | SettingsBarGearMenu() 15 | } 16 | .frame(maxWidth: .infinity) 17 | .padding(10) 18 | } 19 | } 20 | 21 | struct SettingsBarView_Previews: PreviewProvider { 22 | static var previews: some View { 23 | Group { 24 | ForEach(ColorScheme.allCases, id: \.self) { color in 25 | SettingsBarView() 26 | .environmentObject(RemindersData()) 27 | .colorScheme(color) 28 | .previewDisplayName("\(color) mode") 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Colors.xcassets/textFieldBackgroundTransparent.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.450", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.450", 26 | "blue" : "0.250", 27 | "green" : "0.250", 28 | "red" : "0.250" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /reminders-menubar/Views/UpcomingRemindersView/UpcomingRemindersContent.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct UpcomingRemindersContent: View { 4 | @EnvironmentObject var remindersData: RemindersData 5 | 6 | var body: some View { 7 | Group { 8 | if remindersData.upcomingReminders.isEmpty { 9 | NoReminderItemsView(emptyList: .noUpcomingReminders) 10 | } 11 | ForEach(remindersData.upcomingReminders) { reminderItem in 12 | ReminderItemView( 13 | reminderItem: reminderItem, 14 | isShowingCompleted: false, 15 | showCalendarTitleOnDueDate: true 16 | ) 17 | } 18 | } 19 | } 20 | } 21 | 22 | struct UpcomingRemindersContent_Previews: PreviewProvider { 23 | static var previews: some View { 24 | UpcomingRemindersContent().environmentObject(RemindersData()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /reminders-menubar/Models/ReminderItem.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | struct ReminderItem: Identifiable, Equatable { 4 | let id: String 5 | let reminder: EKReminder 6 | let childReminders: LabeledReminders 7 | let isChild: Bool 8 | let hasChildren: Bool 9 | 10 | init(for reminder: EKReminder, isChild: Bool = false, withChildren childReminders: [ReminderItem] = []) { 11 | self.id = reminder.calendarItemIdentifier 12 | self.reminder = reminder 13 | self.childReminders = LabeledReminders(for: childReminders) 14 | self.isChild = isChild 15 | self.hasChildren = !childReminders.isEmpty 16 | } 17 | 18 | static func == (lhs: ReminderItem, rhs: ReminderItem) -> Bool { 19 | return ( 20 | lhs.id == rhs.id 21 | && lhs.reminder.lastModifiedDate == rhs.reminder.lastModifiedDate 22 | && lhs.childReminders == rhs.childReminders 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /reminders-menubar/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum AppConstants { 4 | static let currentVersion: String = { 5 | guard let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { 6 | return "-" 7 | } 8 | 9 | return "v\(bundleVersion)" 10 | }() 11 | 12 | static let appName = "Reminders MenuBar" 13 | static let mainBundleId = "br.com.damascenorafael.reminders-menubar" 14 | static let launcherBundleId = "br.com.damascenorafael.reminders-menubar-launcher" 15 | } 16 | 17 | enum GithubConstants { 18 | static let repository = "DamascenoRafael/reminders-menubar" 19 | static let repositoryPage = "https://github.com/\(repository)" 20 | static let latestReleasePage = "\(repositoryPage)/releases/latest" 21 | } 22 | 23 | enum ApiGithubConstants { 24 | static let latestRelease = "https://api.github.com/repos/\(GithubConstants.repository)/releases/latest" 25 | } 26 | -------------------------------------------------------------------------------- /reminders-menubar-launcher/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | class AppDelegate: NSObject, NSApplicationDelegate { 4 | func applicationDidFinishLaunching(_ aNotification: Notification) { 5 | defer { 6 | NSApp.terminate(self) 7 | } 8 | 9 | guard NSRunningApplication.runningApplications(withBundleIdentifier: AppConstants.mainBundleId).isEmpty else { 10 | // main app is already running 11 | return 12 | } 13 | 14 | guard let appUrl = NSWorkspace.shared.urlForApplication(withBundleIdentifier: AppConstants.mainBundleId) else { 15 | // could not found URL for the main app 16 | return 17 | } 18 | 19 | let group = DispatchGroup() 20 | group.enter() 21 | NSWorkspace.shared.openApplication( 22 | at: appUrl, 23 | configuration: NSWorkspace.OpenConfiguration(), 24 | completionHandler: { _, _ in 25 | group.leave() 26 | } 27 | ) 28 | 29 | _ = group.wait(timeout: .distantFuture) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /reminders-menubar/Services/GithubService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // swiftlint:disable:next convenience_type 4 | class GithubService { 5 | static let urlSession = URLSession(configuration: .ephemeral) 6 | 7 | static func getLatestRelease(completion: @escaping (Result) -> Void) { 8 | guard let latestReleaseUrl = URL(string: ApiGithubConstants.latestRelease) else { 9 | return 10 | } 11 | 12 | let request = URLRequest(url: latestReleaseUrl, cachePolicy: .reloadIgnoringLocalCacheData) 13 | 14 | urlSession.dataTask(with: request) { data, _, error in 15 | guard let data else { 16 | if let error { 17 | completion(.failure(error)) 18 | } 19 | return 20 | } 21 | 22 | do { 23 | let release = try JSONDecoder().decode(Release.self, from: data) 24 | completion(.success(release)) 25 | } catch let error { 26 | completion(.failure(error)) 27 | } 28 | } 29 | .resume() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /reminders-menubar/Views/SettingsBarView/SettingsBarToggleButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsBarToggleButton: View { 4 | @ObservedObject var userPreferences = UserPreferences.shared 5 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 6 | 7 | @State var toggleIsHovered = false 8 | 9 | var body: some View { 10 | Button(action: { 11 | userPreferences.showUncompletedOnly.toggle() 12 | }) { 13 | Image(systemName: userPreferences.showUncompletedOnly ? "circle" : "largecircle.fill.circle") 14 | .padding(4) 15 | .padding(.horizontal, 4) 16 | } 17 | .buttonStyle(BorderlessButtonStyle()) 18 | .background(toggleIsHovered ? Color.rmbColor(for: .buttonHover, and: colorSchemeContrast) : nil) 19 | .cornerRadius(4) 20 | .onHover { isHovered in 21 | toggleIsHovered = isHovered 22 | } 23 | .help(rmbLocalized(.showCompletedRemindersToggleButtonHelp)) 24 | } 25 | } 26 | 27 | struct SettingsBarToggleButton_Previews: PreviewProvider { 28 | static var previews: some View { 29 | SettingsBarToggleButton() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-bell-1.imageset/icon-bell-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /reminders-menubar/Models/ReminderInterval.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ReminderInterval: String, Codable, CaseIterable { 4 | case due 5 | case today 6 | case week 7 | case month 8 | case all 9 | 10 | var title: String { 11 | switch self { 12 | case .due: 13 | return rmbLocalized(.upcomingRemindersDueTitle) 14 | case .today: 15 | return rmbLocalized(.upcomingRemindersTodayTitle) 16 | case .week: 17 | return rmbLocalized(.upcomingRemindersInAWeekTitle) 18 | case .month: 19 | return rmbLocalized(.upcomingRemindersInAMonthTitle) 20 | case .all: 21 | return rmbLocalized(.upcomingRemindersAllTitle) 22 | } 23 | } 24 | 25 | var endingDate: Date? { 26 | switch self { 27 | case .due: 28 | return Date() 29 | case .today: 30 | return Calendar.current.endOfDay(for: Date()) 31 | case .week: 32 | return Calendar.current.date(byAdding: .weekOfMonth, value: 1, to: Date()) 33 | case .month: 34 | return Calendar.current.date(byAdding: .month, value: 1, to: Date()) 35 | case .all: 36 | return nil 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/RmbColorKey.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum RmbColorKey: String { 4 | case buttonHover 5 | case backgroundTheme 6 | case textFieldBackground // textFieldBackgroundTransparent 7 | case borderContrast 8 | 9 | private var transparencyPostfix: String { "Transparent" } 10 | 11 | private var hasTransparencyPostfixString: Bool { 12 | switch self { 13 | case .textFieldBackground: 14 | return true 15 | default: 16 | return false 17 | } 18 | } 19 | 20 | private var hasTransparencyOpacityOption: Bool { 21 | switch self { 22 | case .backgroundTheme: 23 | return true 24 | default: 25 | return false 26 | } 27 | } 28 | 29 | func color(withTransparency isTransparencyEnabled: Bool) -> Color { 30 | guard isTransparencyEnabled else { 31 | return Color(rawValue) 32 | } 33 | 34 | if hasTransparencyPostfixString { 35 | return Color(rawValue + transparencyPostfix) 36 | } 37 | 38 | if hasTransparencyOpacityOption { 39 | return Color(rawValue).opacity(0.3) 40 | } 41 | 42 | return Color(rawValue) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /reminders-menubar-launcher/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.productivity 25 | LSBackgroundOnly 26 | 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | NSHumanReadableCopyright 30 | Copyright © Rafael Damasceno, GNU General Public License v3. 31 | NSPrincipalClass 32 | NSApplication 33 | 34 | 35 | -------------------------------------------------------------------------------- /reminders-menubar/Views/Helpers/SelectableView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SelectableView: View { 4 | var title: String 5 | var isSelected: Bool 6 | var color: Color? 7 | var withPadding: Bool 8 | 9 | init(title: String, isSelected: Bool, color: Color? = nil, withPadding: Bool = true) { 10 | self.title = title 11 | self.isSelected = isSelected 12 | self.color = color 13 | self.withPadding = withPadding 14 | } 15 | 16 | init(title: String, color: Color) { 17 | self.title = title 18 | self.color = color 19 | self.isSelected = false 20 | self.withPadding = false 21 | } 22 | 23 | var body: some View { 24 | if isSelected { 25 | Image(systemName: "checkmark") 26 | } else if withPadding { 27 | Image(.empty) 28 | } 29 | 30 | let coloredDot = color != nil 31 | ? Text(verbatim: "● ").foregroundColor(color) 32 | : Text(verbatim: "") 33 | 34 | Group { 35 | coloredDot + 36 | Text(title) 37 | } 38 | } 39 | } 40 | 41 | struct SelectableButton_Previews: PreviewProvider { 42 | static var previews: some View { 43 | SelectableView(title: "Option", isSelected: true) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/menuBarIcon/icon-reminder-2.imageset/icon-reminder-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderExternalLinksView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ReminderExternalLinksView: View { 4 | var attachedUrl: URL? 5 | var mailUrl: URL? 6 | 7 | var body: some View { 8 | HStack { 9 | if let attachedUrl { 10 | Link(destination: attachedUrl) { 11 | Image(systemName: "safari") 12 | Text(attachedUrl.displayedUrl) 13 | } 14 | .modifier(ReminderExternalLinkStyle()) 15 | } 16 | 17 | if let mailUrl { 18 | Link(destination: mailUrl) { 19 | Image(systemName: "envelope") 20 | } 21 | .modifier(ReminderExternalLinkStyle()) 22 | } 23 | 24 | Spacer() 25 | } 26 | } 27 | 28 | struct ReminderExternalLinkStyle: ViewModifier { 29 | func body(content: Content) -> some View { 30 | return content 31 | .font(.callout) 32 | .foregroundColor(.primary) 33 | .frame(height: 24) 34 | .padding(.horizontal, 8) 35 | .background(Color.secondary.opacity(0.2)) 36 | .clipShape(RoundedRectangle(cornerRadius: 8)) 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | ReminderExternalLinksView(attachedUrl: URL(string: "https://www.github.com"), mailUrl: nil) 43 | } 44 | -------------------------------------------------------------------------------- /reminders-menubar/Views/NoReminderItemsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct NoReminderItemsView: View { 5 | enum EmptyListType { 6 | case noReminders 7 | case allItemsCompleted 8 | case noUpcomingReminders 9 | 10 | var message: String { 11 | switch self { 12 | case .noReminders: 13 | return rmbLocalized(.emptyListNoRemindersMessage) 14 | case .allItemsCompleted: 15 | return rmbLocalized(.emptyListAllItemsCompletedMessage) 16 | case .noUpcomingReminders: 17 | return rmbLocalized(.emptyListNoUpcomingRemindersMessage) 18 | } 19 | } 20 | } 21 | 22 | var emptyList: EmptyListType 23 | 24 | var body: some View { 25 | HStack(alignment: .center) { 26 | Image(systemName: "tray") 27 | Text(emptyList.message) 28 | } 29 | .font(.callout) 30 | .padding(.leading, 0.5) 31 | .padding(.bottom, 4) 32 | } 33 | } 34 | 35 | struct EmptyCalendarView_Previews: PreviewProvider { 36 | static var previews: some View { 37 | Group { 38 | ForEach(ColorScheme.allCases, id: \.self) { color in 39 | NoReminderItemsView(emptyList: .noReminders) 40 | .colorScheme(color) 41 | .previewDisplayName("\(color) mode") 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | subscript(safe offset: Int) -> String? { 5 | guard offset >= 0, offset < endIndex.utf16Offset(in: self) else { 6 | return nil 7 | } 8 | 9 | let offsetIndex = Index(utf16Offset: offset, in: self) 10 | return String(self[offsetIndex]) 11 | } 12 | 13 | func substring(in nsRange: NSRange) -> String { 14 | guard let range = Range(nsRange, in: self) else { 15 | return "" 16 | } 17 | 18 | return String(self[range]) 19 | } 20 | 21 | var fullRange: NSRange { 22 | return NSRange(location: 0, length: endIndex.utf16Offset(in: self)) 23 | } 24 | 25 | func toDetectedLinkAttributedString() -> String { 26 | let range = NSRange(self.startIndex..., in: self) 27 | let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) 28 | guard let matches = detector?.matches(in: self, options: [], range: range), !matches.isEmpty else { 29 | return self 30 | } 31 | 32 | let attributedString = NSMutableAttributedString(string: self) 33 | for match in matches { 34 | if let url = match.url { 35 | attributedString.addAttribute(.link, value: url, range: match.range) 36 | } 37 | } 38 | 39 | return attributedString.string 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/fix-for-opencore-legacy-patcher.md: -------------------------------------------------------------------------------- 1 | # Fix for OpenCore Legacy Patcher 2 | 3 | If you are using *OpenCore Legacy Patcher* it is possible that you are not being able to grant access permission to reminders and therefore you are facing a window saying *"Access to Reminders is not enabled for Reminders MenuBar"*. 4 | 5 |
6 | macOS window about access to Reminders not enabled for Reminders MenuBar 11 |
12 | 13 | This issue is related to *OpenCore Legacy Patcher* as stated in the official documentation: [OpenCore Legacy Patcher | Unable to grant special permissions to apps](https://dortania.github.io/OpenCore-Legacy-Patcher/ACCEL.html#unable-to-grant-special-permissions-to-apps-ie-camera-access-to-zoom) 14 | 15 | A workaround is to use TCCPlus to add this permission. I would suggest looking up some threads on the subject and if possible making a backup before trying commands that might affect the use of macOS. 16 | 17 | I cannot guarantee that TCCPlus still works or if it's reliable for new versions of macOS. The workaround below was tested by other users on issue [#159](https://github.com/DamascenoRafael/reminders-menubar/issues/159), but if you decide to proceed it is at your own risk. 18 | 19 | After downloading and extracting [TCCPlus](https://github.com/jslegendre/tccplus) in the *Downloads* folder, open the *Terminal* and run the following commands: 20 | 21 | ```shell 22 | cd ~/Downloads/ 23 | chmod +x tccplus 24 | ./tccplus add Reminders br.com.damascenorafael.reminders-menubar 25 | ``` 26 | -------------------------------------------------------------------------------- /reminders-menubar/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.productivity 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | LSUIElement 28 | 29 | NSHumanReadableCopyright 30 | Copyright © Rafael Damasceno, GNU General Public License v3. 31 | NSPrincipalClass 32 | NSApplication 33 | NSRemindersUsageDescription 34 | The App uses Apple Reminders as a source for lists and tasks. Access is required to view and edit reminders. 35 | NSSupportsAutomaticTermination 36 | 37 | NSSupportsSuddenTermination 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderCompleteButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct ReminderCompleteButton: View { 5 | var reminderItem: ReminderItem 6 | 7 | var body: some View { 8 | Button(action: { 9 | reminderItem.reminder.isCompleted.toggle() 10 | RemindersService.shared.save(reminder: reminderItem.reminder) 11 | if reminderItem.reminder.isCompleted { 12 | reminderItem.childReminders.uncompleted.forEach { uncompletedChild in 13 | uncompletedChild.reminder.isCompleted = true 14 | RemindersService.shared.save(reminder: uncompletedChild.reminder) 15 | } 16 | } 17 | }) { 18 | Image(systemName: reminderItem.reminder.isCompleted ? "largecircle.fill.circle" : "circle") 19 | .resizable() 20 | .frame(width: 16, height: 16) 21 | .padding(.top, 1) 22 | .foregroundColor(Color(reminderItem.reminder.calendar.color)) 23 | } 24 | .buttonStyle(PlainButtonStyle()) 25 | } 26 | } 27 | 28 | #Preview { 29 | var reminder: EKReminder { 30 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 31 | calendar.color = .systemTeal 32 | 33 | let reminder = EKReminder(eventStore: .init()) 34 | reminder.title = "Look for awesome projects on GitHub" 35 | reminder.isCompleted = false 36 | reminder.calendar = calendar 37 | 38 | return reminder 39 | } 40 | let reminderItem = ReminderItem(for: reminder) 41 | 42 | ReminderCompleteButton(reminderItem: reminderItem) 43 | } 44 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "reminders-16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "reminders-16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "reminders-32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "reminders-32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "reminders-128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "reminders-128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "reminders-256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "reminders-256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "reminders-512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "reminders-512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/ArrayReminderItem+Extension.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension Array where Element == ReminderItem { 4 | var sortedReminders: [ReminderItem] { 5 | return sortedReminders(self) 6 | } 7 | 8 | var sortedRemindersByPriority: [ReminderItem] { 9 | let remindersByPriority = PrioritizedReminders(self) 10 | 11 | return sortedReminders(remindersByPriority.high) + 12 | sortedReminders(remindersByPriority.medium) + 13 | sortedReminders(remindersByPriority.low) + 14 | sortedReminders(remindersByPriority.none) 15 | } 16 | 17 | private func sortedReminders(_ reminders: [ReminderItem]) -> [ReminderItem] { 18 | var (dueDateReminders, undatedReminders) = reminders.separated(by: { $0.reminder.hasDueDate }) 19 | 20 | dueDateReminders.sort(by: { 21 | let firstDate = $0.reminder.completionDate ?? $0.reminder.dueDateComponents?.date ?? Date.distantPast 22 | let secondDate = $1.reminder.completionDate ?? $1.reminder.dueDateComponents?.date ?? Date.distantPast 23 | let comparisonResult: ComparisonResult = $0.reminder.isCompleted ? .orderedDescending : .orderedAscending 24 | return firstDate.compare(secondDate) == comparisonResult 25 | }) 26 | 27 | undatedReminders.sort(by: { 28 | let firstDate = $0.reminder.completionDate ?? $0.reminder.creationDate ?? Date.distantPast 29 | let secondDate = $1.reminder.completionDate ?? $1.reminder.creationDate ?? Date.distantPast 30 | return firstDate.compare(secondDate) == .orderedDescending 31 | }) 32 | 33 | return dueDateReminders + undatedReminders 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/Colors.xcassets/textFieldBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xED", 9 | "green" : "0xED", 10 | "red" : "0xED" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.161", 27 | "green" : "0.161", 28 | "red" : "0.161" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "contrast", 37 | "value" : "high" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0xED", 45 | "green" : "0xED", 46 | "red" : "0xED" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | }, 51 | { 52 | "appearances" : [ 53 | { 54 | "appearance" : "luminosity", 55 | "value" : "dark" 56 | }, 57 | { 58 | "appearance" : "contrast", 59 | "value" : "high" 60 | } 61 | ], 62 | "color" : { 63 | "color-space" : "srgb", 64 | "components" : { 65 | "alpha" : "1.000", 66 | "blue" : "51", 67 | "green" : "51", 68 | "red" : "51" 69 | } 70 | }, 71 | "idiom" : "universal" 72 | } 73 | ], 74 | "info" : { 75 | "author" : "xcode", 76 | "version" : 1 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderEllipsisMenuView/ReminderChangePriorityOptionMenu.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct ReminderChangePriorityOptionMenu: View { 5 | var reminder: EKReminder 6 | 7 | @ViewBuilder 8 | func changePriorityButton(_ priority: EKReminderPriority, text: String) -> some View { 9 | let isSelected = priority == reminder.ekPriority 10 | Button(action: { 11 | reminder.ekPriority = priority 12 | RemindersService.shared.save(reminder: reminder) 13 | }) { 14 | SelectableView(title: text, isSelected: isSelected) 15 | } 16 | } 17 | 18 | var body: some View { 19 | Menu { 20 | changePriorityButton(.low, text: rmbLocalized(.editReminderPriorityLowOption)) 21 | changePriorityButton(.medium, text: rmbLocalized(.editReminderPriorityMediumOption)) 22 | changePriorityButton(.high, text: rmbLocalized(.editReminderPriorityHighOption)) 23 | Divider() 24 | changePriorityButton(.none, text: rmbLocalized(.editReminderPriorityNoneOption)) 25 | } label: { 26 | HStack { 27 | Image(systemName: "exclamationmark.circle") 28 | Text(rmbLocalized(.changeReminderPriorityMenuOption)) 29 | } 30 | } 31 | } 32 | } 33 | 34 | #Preview { 35 | var reminder: EKReminder { 36 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 37 | calendar.color = .systemTeal 38 | 39 | let reminder = EKReminder(eventStore: .init()) 40 | reminder.title = "Look for awesome projects on GitHub" 41 | reminder.isCompleted = false 42 | reminder.calendar = calendar 43 | reminder.dueDateComponents = Date().dateComponents(withTime: true) 44 | reminder.ekPriority = .high 45 | 46 | return reminder 47 | } 48 | 49 | ReminderChangePriorityOptionMenu(reminder: reminder) 50 | } 51 | -------------------------------------------------------------------------------- /reminders-menubar/Services/PriorityParser.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | class PriorityParser { 4 | struct PriorityParserResult { 5 | private let range: NSRange 6 | let string: String 7 | let priority: EKReminderPriority 8 | 9 | var highlightedText: RmbHighlightedTextField.HighlightedText { 10 | RmbHighlightedTextField.HighlightedText(range: range, color: .systemRed) 11 | } 12 | 13 | init() { 14 | self.range = NSRange() 15 | self.string = "" 16 | self.priority = .none 17 | } 18 | 19 | init(range: NSRange, string: String, priority: EKReminderPriority) { 20 | self.range = range 21 | self.string = string 22 | self.priority = priority 23 | } 24 | } 25 | 26 | private init() { 27 | // This prevents others from using the default '()' initializer for this class. 28 | } 29 | 30 | static private func exclamationCount(_ string: Substring) -> Int { 31 | return string.count(where: { $0 == "!" }) 32 | } 33 | 34 | static private func priority(forExclamationCount count: Int) -> EKReminderPriority { 35 | switch count { 36 | case 3: 37 | return .high 38 | case 2: 39 | return .medium 40 | case 1: 41 | return .low 42 | default: 43 | return .none 44 | } 45 | } 46 | 47 | static func getPriority(from textString: String) -> PriorityParserResult? { 48 | guard let substringMatch = textString 49 | .split(separator: " ") 50 | .first(where: { $0.first == "!" && $0.count <= 3 && $0.count == exclamationCount($0) }) else { 51 | return nil 52 | } 53 | 54 | return PriorityParserResult( 55 | range: NSRange(substringMatch.startIndex.. String { 38 | let interval = rule?.interval ?? 1 39 | 40 | switch rule?.frequency { 41 | case .daily: 42 | return rmbLocalized(.reminderRecurrenceDailyLabel, arguments: interval) 43 | case .weekly: 44 | return rmbLocalized(.reminderRecurrenceWeeklyLabel, arguments: interval) 45 | case .monthly: 46 | return rmbLocalized(.reminderRecurrenceMonthlyLabel, arguments: interval) 47 | case .yearly: 48 | return rmbLocalized(.reminderRecurrenceYearlyLabel, arguments: interval) 49 | default: 50 | return "" 51 | } 52 | } 53 | } 54 | 55 | #Preview { 56 | ReminderDateDescriptionView( 57 | dateDescription: Date().relativeDateDescription(withTime: true), 58 | isExpired: false, 59 | hasRecurrenceRules: false, 60 | recurrenceRules: nil, 61 | calendarTitle: "Reminders", 62 | showCalendarTitleOnDueDate: true 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /reminders-menubar/AppCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AppCommands: Commands { 4 | @CommandsBuilder var body: some Commands { 5 | CommandMenu(Text(verbatim: "Edit")) { 6 | // NOTE: macOS 13.0 already has the below shortcuts for TextField. 7 | // Shortcuts only need to be registered for versions earlier than macOS 13.0. 8 | if #unavailable(macOS 13.0) { 9 | Button { 10 | NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) 11 | } label: { 12 | Text(verbatim: "Select All") 13 | } 14 | .keyboardShortcut(KeyEquivalent("a"), modifiers: .command) 15 | 16 | Button { 17 | NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil) 18 | } label: { 19 | Text(verbatim: "Cut") 20 | } 21 | .keyboardShortcut(KeyEquivalent("x"), modifiers: .command) 22 | 23 | Button { 24 | NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) 25 | } label: { 26 | Text(verbatim: "Copy") 27 | } 28 | .keyboardShortcut(KeyEquivalent("c"), modifiers: .command) 29 | 30 | Button { 31 | NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) 32 | } label: { 33 | Text(verbatim: "Paste") 34 | } 35 | .keyboardShortcut(KeyEquivalent("v"), modifiers: .command) 36 | 37 | Button { 38 | NSApp.sendAction(Selector(("undo:")), to: nil, from: nil) 39 | } label: { 40 | Text(verbatim: "Undo") 41 | } 42 | .keyboardShortcut(KeyEquivalent("z"), modifiers: .command) 43 | 44 | Button { 45 | NSApp.sendAction(Selector(("redo:")), to: nil, from: nil) 46 | } label: { 47 | Text(verbatim: "Redo") 48 | } 49 | .keyboardShortcut(KeyEquivalent("z"), modifiers: [.command, .shift]) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /reminders-menubar/Views/CalendarTitle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct CalendarTitle: View { 5 | @EnvironmentObject var remindersData: RemindersData 6 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 7 | 8 | var calendar: EKCalendar 9 | @State var calendarFolderIsHovered = false 10 | 11 | var body: some View { 12 | HStack(alignment: .center) { 13 | Text(calendar.title) 14 | .font(.headline) 15 | .foregroundColor(Color(calendar.color)) 16 | .padding(.bottom, 5) 17 | 18 | Spacer() 19 | 20 | Button(action: { 21 | remindersData.calendarForSaving = calendar 22 | }) { 23 | let isSelected = remindersData.calendarForSaving?.calendarIdentifier == calendar.calendarIdentifier 24 | Image(systemName: isSelected ? "folder.fill" : "folder") 25 | .font(Font.headline.weight(.medium)) 26 | .foregroundColor(calendarFolderIsHovered ? Color(calendar.color) : nil) 27 | .frame(width: 15, height: 15, alignment: .center) 28 | .padding(5) 29 | } 30 | .buttonStyle(BorderlessButtonStyle()) 31 | .background(calendarFolderIsHovered ? Color.rmbColor(for: .buttonHover, and: colorSchemeContrast) : nil) 32 | .cornerRadius(6) 33 | .onHover { isHovered in 34 | calendarFolderIsHovered = isHovered 35 | } 36 | .padding(.horizontal, 7.5) 37 | .help(rmbLocalized(.selectListForSavingReminderButtonHelp)) 38 | } 39 | } 40 | } 41 | 42 | struct CalendarTitleView_Previews: PreviewProvider { 43 | static var calendar: EKCalendar { 44 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 45 | calendar.title = "Reminders" 46 | calendar.color = .systemTeal 47 | 48 | return calendar 49 | } 50 | 51 | static var previews: some View { 52 | Group { 53 | ForEach(ColorScheme.allCases, id: \.self) { color in 54 | CalendarTitle(calendar: calendar) 55 | .colorScheme(color) 56 | .previewDisplayName("\(color) mode") 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /reminders-menubar/Views/Helpers/RmbDatePicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RmbDatePicker: NSViewRepresentable { 4 | @Binding var selection: Date 5 | var displayedComponents: NSDatePicker.ElementFlags 6 | private var font: NSFont? 7 | 8 | init(selection: Binding, components: DatePickerComponents) { 9 | _selection = selection 10 | self.displayedComponents = components.displayedComponents 11 | } 12 | 13 | enum DatePickerComponents { 14 | case date 15 | case time 16 | 17 | var displayedComponents: NSDatePicker.ElementFlags { 18 | switch self { 19 | case .date: 20 | return .yearMonthDay 21 | case .time: 22 | return .hourMinute 23 | } 24 | } 25 | } 26 | 27 | func makeNSView(context: Context) -> NSDatePicker { 28 | let picker = NSDatePicker() 29 | picker.font = font ?? picker.font 30 | picker.isBordered = false 31 | picker.datePickerStyle = .textField 32 | picker.presentsCalendarOverlay = true 33 | picker.datePickerElements = displayedComponents 34 | picker.action = #selector(Coordinator.onValueChange(_:)) 35 | picker.target = context.coordinator 36 | picker.locale = rmbCurrentLocale() 37 | return picker 38 | } 39 | 40 | func updateNSView(_ picker: NSDatePicker, context: Context) { 41 | picker.dateValue = selection 42 | } 43 | 44 | func makeCoordinator() -> Coordinator { 45 | Coordinator(owner: self) 46 | } 47 | 48 | class Coordinator: NSObject { 49 | private let owner: RmbDatePicker 50 | 51 | init(owner: RmbDatePicker) { 52 | self.owner = owner 53 | } 54 | 55 | @objc func onValueChange(_ sender: Any?) { 56 | if let picker = sender as? NSDatePicker { 57 | owner.selection = picker.dateValue 58 | } 59 | } 60 | } 61 | } 62 | 63 | extension RmbDatePicker { 64 | func font(_ font: NSFont?) -> RmbDatePicker { 65 | var view = self 66 | view.font = font 67 | return view 68 | } 69 | } 70 | 71 | struct RmbDatePicker_Previews: PreviewProvider { 72 | static var date = Date() 73 | 74 | static var previews: some View { 75 | RmbDatePicker(selection: .constant(date), components: .date) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /reminders-menubar/Views/SettingsBarView/SettingsBarFilterMenu.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsBarFilterMenu: View { 4 | @EnvironmentObject var remindersData: RemindersData 5 | @ObservedObject var userPreferences = UserPreferences.shared 6 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 7 | 8 | @State var filterIsHovered = false 9 | 10 | var body: some View { 11 | Menu { 12 | VStack { 13 | Button(action: { 14 | userPreferences.showUpcomingReminders.toggle() 15 | }) { 16 | let isSelected = userPreferences.showUpcomingReminders 17 | SelectableView(title: rmbLocalized(.upcomingRemindersTitle), isSelected: isSelected) 18 | } 19 | 20 | Divider() 21 | 22 | ForEach(remindersData.calendars, id: \.calendarIdentifier) { calendar in 23 | let calendarIdentifier = calendar.calendarIdentifier 24 | Button(action: { 25 | let index = remindersData.calendarIdentifiersFilter.firstIndex(of: calendarIdentifier) 26 | if let index { 27 | remindersData.calendarIdentifiersFilter.remove(at: index) 28 | } else { 29 | remindersData.calendarIdentifiersFilter.append(calendarIdentifier) 30 | } 31 | }) { 32 | let isSelected = remindersData.calendarIdentifiersFilter.contains(calendarIdentifier) 33 | SelectableView(title: calendar.title, isSelected: isSelected, color: Color(calendar.color)) 34 | } 35 | } 36 | } 37 | } label: { 38 | Image(systemName: "line.horizontal.3.decrease.circle") 39 | } 40 | .menuStyle(BorderlessButtonMenuStyle()) 41 | .frame(width: 32, height: 16) 42 | .padding(3) 43 | .background(filterIsHovered ? Color.rmbColor(for: .buttonHover, and: colorSchemeContrast) : nil) 44 | .cornerRadius(4) 45 | .onHover { isHovered in 46 | filterIsHovered = isHovered 47 | } 48 | .help(rmbLocalized(.remindersFilterSelectionHelp)) 49 | } 50 | } 51 | 52 | struct SettingsBarFilterMenu_Previews: PreviewProvider { 53 | static var previews: some View { 54 | SettingsBarFilterMenu() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderEllipsisMenuView/ReminderChangeListOptionMenu.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct ReminderChangeListOptionMenu: View { 5 | @EnvironmentObject var remindersData: RemindersData 6 | 7 | var reminder: EKReminder 8 | var reminderHasChildren: Bool 9 | 10 | var body: some View { 11 | if !reminderHasChildren { 12 | Menu { 13 | ForEach(remindersData.calendars, id: \.calendarIdentifier) { calendar in 14 | // TODO: Fix the warning from Xcode when editing the reminder calendar: 15 | // [utility] You are about to trigger decoding the resolution token map from JSON data. 16 | // This is probably not what you want for performance to trigger it from -isEqual:, 17 | // unless you are running Tests then it's fine 18 | // {class: REMAccountStorage, self-map: (null), other-map: (null)} 19 | Button(action: { 20 | reminder.calendar = calendar 21 | RemindersService.shared.save(reminder: reminder) 22 | }) { 23 | let isSelected = calendar.calendarIdentifier == reminder.calendar.calendarIdentifier 24 | SelectableView( 25 | title: calendar.title, 26 | isSelected: isSelected, 27 | color: Color(calendar.color) 28 | ) 29 | } 30 | } 31 | } label: { 32 | HStack { 33 | Image(systemName: "folder") 34 | Text(rmbLocalized(.changeReminderListMenuOption)) 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | var reminder: EKReminder { 43 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 44 | calendar.color = .systemTeal 45 | 46 | let reminder = EKReminder(eventStore: .init()) 47 | reminder.title = "Look for awesome projects on GitHub" 48 | reminder.isCompleted = false 49 | reminder.calendar = calendar 50 | reminder.dueDateComponents = Date().dateComponents(withTime: true) 51 | reminder.ekPriority = .high 52 | 53 | return reminder 54 | } 55 | 56 | ReminderChangeListOptionMenu(reminder: reminder, reminderHasChildren: false) 57 | .environmentObject(RemindersData()) 58 | } 59 | -------------------------------------------------------------------------------- /reminders-menubar/Services/KeyboardShortcutService.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import KeyboardShortcuts 3 | 4 | extension KeyboardShortcuts.Name { 5 | static let openRemindersMenuBar = Self("OpenRemindersMenuBar", default: .init(.r, modifiers: [.command, .option])) 6 | } 7 | 8 | private enum ShortcutsKeys { 9 | static let isOpenRemindersMenuBarEnabled = "isOpenRemindersMenuBarEnabled" 10 | } 11 | 12 | @MainActor 13 | class KeyboardShortcutService: ObservableObject { 14 | static let shared = KeyboardShortcutService() 15 | 16 | private init() { 17 | // This prevents others from using the default '()' initializer for this class. 18 | } 19 | 20 | private static let defaults = UserDefaults.standard 21 | 22 | @Published var isOpenRemindersMenuBarEnabled: Bool = { 23 | return defaults.bool(forKey: ShortcutsKeys.isOpenRemindersMenuBarEnabled) 24 | }() { 25 | didSet { 26 | KeyboardShortcutService.defaults.set( 27 | isOpenRemindersMenuBarEnabled, 28 | forKey: ShortcutsKeys.isOpenRemindersMenuBarEnabled 29 | ) 30 | setEnabled(isOpenRemindersMenuBarEnabled, for: .openRemindersMenuBar) 31 | } 32 | } 33 | 34 | func activeShortcut(for shortcutName: KeyboardShortcuts.Name) -> String { 35 | guard isEnabled(shortcutName) else { 36 | return "" 37 | } 38 | 39 | return KeyboardShortcuts.Shortcut(name: shortcutName)?.description ?? "" 40 | } 41 | 42 | func action(for shortcutName: KeyboardShortcuts.Name, action: @escaping () -> Void) { 43 | KeyboardShortcuts.onKeyDown(for: shortcutName) { 44 | action() 45 | } 46 | let isEnabled = isEnabled(shortcutName) 47 | setEnabled(isEnabled, for: shortcutName) 48 | } 49 | 50 | func reset(_ shortcutName: KeyboardShortcuts.Name) { 51 | KeyboardShortcuts.reset(shortcutName) 52 | } 53 | 54 | private func isEnabled(_ shortcutName: KeyboardShortcuts.Name) -> Bool { 55 | switch shortcutName { 56 | case .openRemindersMenuBar: 57 | return isOpenRemindersMenuBarEnabled 58 | default: 59 | return false 60 | } 61 | } 62 | 63 | private func setEnabled(_ isEnabled: Bool, for shortcutName: KeyboardShortcuts.Name) { 64 | if isEnabled { 65 | KeyboardShortcuts.enable(shortcutName) 66 | } else { 67 | KeyboardShortcuts.disable(shortcutName) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderEllipsisMenuView/ReminderEllipsisMenuView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct ReminderEllipsisMenuView: View { 5 | @Binding var showingEditPopover: Bool 6 | @Binding var showingRemoveAlert: Bool 7 | 8 | var reminder: EKReminder 9 | var reminderHasChildren: Bool 10 | 11 | var body: some View { 12 | Menu { 13 | showEditPopoverOptionButton() 14 | 15 | ReminderChangePriorityOptionMenu(reminder: reminder) 16 | 17 | ReminderChangeListOptionMenu(reminder: reminder, reminderHasChildren: reminderHasChildren) 18 | 19 | ReminderChangeDueDateOptionMenu(reminder: reminder) 20 | 21 | Divider() 22 | 23 | showRemoveAlertOptionButton() 24 | } label: { 25 | Image(systemName: "ellipsis") 26 | } 27 | .menuStyle(BorderlessButtonMenuStyle(showsMenuIndicator: false)) 28 | .frame(width: 16, height: 16) 29 | .padding(.top, 1) 30 | .padding(.trailing, 10) 31 | .help(rmbLocalized(.remindersOptionsButtonHelp)) 32 | } 33 | 34 | func showEditPopoverOptionButton() -> some View { 35 | Button(action: { 36 | showingEditPopover = true 37 | }) { 38 | HStack { 39 | Image(systemName: "pencil") 40 | Text(rmbLocalized(.editReminderOptionButton)) 41 | } 42 | } 43 | } 44 | 45 | func showRemoveAlertOptionButton() -> some View { 46 | Button(action: { 47 | showingRemoveAlert = true 48 | }) { 49 | HStack { 50 | Image(systemName: "minus.circle") 51 | Text(rmbLocalized(.removeReminderOptionButton)) 52 | } 53 | } 54 | } 55 | } 56 | 57 | #Preview { 58 | var reminder: EKReminder { 59 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 60 | calendar.color = .systemTeal 61 | 62 | let reminder = EKReminder(eventStore: .init()) 63 | reminder.title = "Look for awesome projects on GitHub" 64 | reminder.isCompleted = false 65 | reminder.calendar = calendar 66 | reminder.dueDateComponents = Date().dateComponents(withTime: true) 67 | reminder.ekPriority = .high 68 | 69 | return reminder 70 | } 71 | 72 | ReminderEllipsisMenuView( 73 | showingEditPopover: .constant(false), 74 | showingRemoveAlert: .constant(false), 75 | reminder: reminder, 76 | reminderHasChildren: false 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | *.playground/ 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # macOS folder metadata 93 | .DS_Store 94 | -------------------------------------------------------------------------------- /reminders-menubar/Views/Windows/KeyboardShortcutView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import KeyboardShortcuts 3 | 4 | struct KeyboardShortcutView: View { 5 | @ObservedObject var keyboardShortcutService = KeyboardShortcutService.shared 6 | 7 | var body: some View { 8 | HStack { 9 | VStack(alignment: .leading, spacing: 8) { 10 | Spacer() 11 | 12 | VStack(alignment: .leading, spacing: 16) { 13 | Toggle( 14 | rmbLocalized(.keyboardShortcutEnableOpenShortcutOption, arguments: AppConstants.appName), 15 | isOn: $keyboardShortcutService.isOpenRemindersMenuBarEnabled 16 | ) 17 | 18 | Group { 19 | HStack { 20 | KeyboardShortcuts.Recorder(for: .openRemindersMenuBar) 21 | 22 | Button(action: { 23 | KeyboardShortcutService.shared.reset(.openRemindersMenuBar) 24 | }) { 25 | Text(rmbLocalized(.keyboardShortcutRestoreDefaultButton)) 26 | .padding(.horizontal, 4) 27 | .frame(minWidth: 113) 28 | } 29 | } 30 | } 31 | .padding(.leading, 20) 32 | .disabled(!keyboardShortcutService.isOpenRemindersMenuBarEnabled) 33 | } 34 | 35 | Spacer() 36 | } 37 | } 38 | .padding(.top, 16) 39 | .padding(.bottom, 24) 40 | .padding(.horizontal, 32) 41 | .frame(width: 520, height: 180) 42 | } 43 | 44 | static func showWindow() { 45 | let viewController = NSHostingController(rootView: KeyboardShortcutView()) 46 | let windowController = NSWindowController(window: NSWindow(contentViewController: viewController)) 47 | 48 | if let window = windowController.window { 49 | window.title = rmbLocalized(.keyboardShortcutWindowTitle) 50 | window.titlebarAppearsTransparent = true 51 | window.animationBehavior = .alertPanel 52 | window.styleMask = [.titled, .closable] 53 | } 54 | 55 | windowController.showWindow(nil) 56 | NSApp.activate(ignoringOtherApps: true) 57 | } 58 | } 59 | 60 | struct KeyboardShortcutView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | KeyboardShortcutView() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/Date+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Date { 4 | var isPast: Bool { 5 | return self.timeIntervalSinceNow < 0 6 | } 7 | 8 | var isToday: Bool { 9 | return Calendar.current.isDateInToday(self) 10 | } 11 | 12 | var isYesterday: Bool { 13 | return Calendar.current.isDateInYesterday(self) 14 | } 15 | 16 | var isDayBeforeYesterday: Bool { 17 | let dayBeforeYesterday = Calendar.current.date(byAdding: .day, value: -2, to: Date()) ?? self 18 | return self.isSameDay(as: dayBeforeYesterday) 19 | } 20 | 21 | var isThisYear: Bool { 22 | return Calendar.current.isDate(self, equalTo: Date(), toGranularity: .year) 23 | } 24 | 25 | var elapsedTimeInterval: TimeInterval { 26 | return Date().timeIntervalSince(self) 27 | } 28 | 29 | static func nextExactHour(of date: Date = Date(), allowDayChange: Bool = false) -> Date { 30 | let today = Date() 31 | let todayNextHour = Calendar.current.date(byAdding: .hour, value: 1, to: today)! 32 | let isNextHourChangingDay = !todayNextHour.isToday 33 | 34 | var hourComponent = Calendar.current.dateComponents([.hour], from: today) 35 | if allowDayChange || !isNextHourChangingDay { 36 | hourComponent.hour! += 1 37 | } 38 | 39 | let dateWithoutTime = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: date)! 40 | return Calendar.current.date(byAdding: hourComponent, to: dateWithoutTime)! 41 | } 42 | 43 | static func nextYear(of date: Date = Date()) -> Date { 44 | return Calendar.current.date(byAdding: .year, value: 1, to: date) ?? date 45 | } 46 | 47 | func isSameDay(as otherDate: Date) -> Bool { 48 | return Calendar.current.isDate(self, inSameDayAs: otherDate) 49 | } 50 | 51 | func relativeDateDescription(withTime showTimeDescription: Bool) -> String { 52 | let relativeDateFormatter = DateFormatter() 53 | relativeDateFormatter.timeStyle = showTimeDescription ? .short : .none 54 | relativeDateFormatter.dateStyle = .medium 55 | relativeDateFormatter.locale = rmbCurrentLocale() 56 | relativeDateFormatter.doesRelativeDateFormatting = true 57 | 58 | return relativeDateFormatter.string(from: self) 59 | } 60 | 61 | func dateComponents(withTime: Bool) -> DateComponents { 62 | var components: Set = [.calendar, .era, .year, .month, .day] 63 | if withTime { 64 | components.formUnion([.timeZone, .hour, .minute, .second]) 65 | } 66 | return Calendar.current.dateComponents(components, from: self) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /reminders-menubar/Views/UpcomingRemindersView/UpcomingRemindersTitle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct UpcomingRemindersTitle: View { 4 | @ObservedObject var userPreferences = UserPreferences.shared 5 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 6 | 7 | @State var intervalButtonIsHovered = false 8 | 9 | var body: some View { 10 | HStack(alignment: .center) { 11 | // TODO: Remove the 'scaledToFit' and 'minimumScaleFactor' properties from the title 12 | // and apply it to the Menu. It is expected that the Menu will occupy as little horizontal space as possible 13 | // and be resized if necessary, but the Menu behavior without the 'fixedSize' property is different. 14 | Text(rmbLocalized(.upcomingRemindersTitle)) 15 | .font(.headline) 16 | .foregroundColor(.red) 17 | .padding(.bottom, 5) 18 | .scaledToFit() 19 | .minimumScaleFactor(0.8) 20 | 21 | Spacer() 22 | 23 | Menu { 24 | ForEach(ReminderInterval.allCases, id: \.rawValue) { interval in 25 | Button(action: { userPreferences.upcomingRemindersInterval = interval }) { 26 | let isSelected = interval == userPreferences.upcomingRemindersInterval 27 | SelectableView(title: interval.title, isSelected: isSelected) 28 | } 29 | } 30 | 31 | Divider() 32 | 33 | Button(action: { 34 | userPreferences.filterUpcomingRemindersByCalendar.toggle() 35 | }) { 36 | SelectableView( 37 | title: rmbLocalized(.filterUpcomingRemindersByCalendarOptionButton), 38 | isSelected: userPreferences.filterUpcomingRemindersByCalendar 39 | ) 40 | } 41 | } label: { 42 | Label(userPreferences.upcomingRemindersInterval.title, systemImage: "calendar") 43 | } 44 | .menuStyle(BorderlessButtonMenuStyle()) 45 | .padding(.vertical, 5) 46 | .padding(.horizontal, 10) 47 | .background(intervalButtonIsHovered ? Color.rmbColor(for: .buttonHover, and: colorSchemeContrast) : nil) 48 | .cornerRadius(6) 49 | .onHover { isHovered in 50 | intervalButtonIsHovered = isHovered 51 | } 52 | .padding(.trailing, 1) 53 | .fixedSize(horizontal: true, vertical: true) 54 | .help(rmbLocalized(.upcomingRemindersIntervalSelectionHelp)) 55 | } 56 | } 57 | } 58 | 59 | struct UpcomingRemindersTitle_Previews: PreviewProvider { 60 | static var previews: some View { 61 | UpcomingRemindersTitle() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /reminders-menubar/Views/Windows/AboutView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AboutView: View { 4 | var body: some View { 5 | HStack(alignment: .center) { 6 | Image(nsImage: NSApp.applicationIconImage) 7 | .resizable() 8 | .frame(width: 96, height: 96) 9 | .shadow(radius: 5) 10 | .padding(16) 11 | .padding(.horizontal, 6) 12 | .padding(.bottom, 22) 13 | 14 | VStack(alignment: .leading, spacing: 8) { 15 | VStack(alignment: .leading) { 16 | Text(AppConstants.appName) 17 | .font(Font.title.weight(.thin)) 18 | Text(rmbLocalized(.appVersionDescription, arguments: AppConstants.currentVersion)) 19 | .font(Font.callout.weight(.light)) 20 | } 21 | .padding(.bottom, 4) 22 | 23 | VStack(alignment: .leading, spacing: 14) { 24 | Text(rmbLocalized( 25 | .remindersMenuBarAppAboutDescription, 26 | arguments: AppConstants.appName, 27 | "GNU General Public License v3.0" 28 | )) 29 | Text(rmbLocalized(.remindersMenuBarGitHubAboutDescription)) 30 | } 31 | .font(.system(size: 11)) 32 | .frame(maxHeight: .infinity) 33 | 34 | Button(action: { 35 | if let url = URL(string: GithubConstants.repositoryPage) { 36 | NSWorkspace.shared.open(url) 37 | } 38 | }) { 39 | Text(rmbLocalized(.seeMoreOnGitHubButton)) 40 | } 41 | } 42 | } 43 | .padding(.top, 10) 44 | .padding(.bottom, 24) 45 | .padding(.horizontal, 16) 46 | .frame(width: 525, height: 200) 47 | } 48 | 49 | static func showWindow() { 50 | let viewController = NSHostingController(rootView: AboutView()) 51 | let windowController = NSWindowController(window: NSWindow(contentViewController: viewController)) 52 | 53 | if let window = windowController.window { 54 | window.title = rmbLocalized(.aboutRemindersMenuBarWindowTitle, arguments: AppConstants.appName) 55 | window.titleVisibility = .hidden 56 | window.titlebarAppearsTransparent = true 57 | window.animationBehavior = .alertPanel 58 | window.styleMask = [.titled, .closable] 59 | } 60 | 61 | windowController.showWindow(nil) 62 | NSApp.activate(ignoringOtherApps: true) 63 | } 64 | } 65 | 66 | struct AboutView_Previews: PreviewProvider { 67 | static var previews: some View { 68 | AboutView() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: 2 | - array_init 3 | - async_without_await 4 | - attributes 5 | - closure_end_indentation 6 | - closure_spacing 7 | - collection_alignment 8 | - conditional_returns_on_newline 9 | - contains_over_filter_count 10 | - contains_over_filter_is_empty 11 | - contains_over_first_not_nil 12 | - contains_over_range_nil_comparison 13 | - convenience_type 14 | - direct_return 15 | - discarded_notification_center_observer 16 | - discouraged_none_name 17 | - discouraged_object_literal 18 | - discouraged_optional_boolean 19 | - empty_collection_literal 20 | - empty_count 21 | - empty_string 22 | - explicit_init 23 | - fallthrough 24 | - file_header 25 | - file_name_no_space 26 | - first_where 27 | - force_unwrapping 28 | - function_default_parameter_at_end 29 | - identical_operands 30 | - implicitly_unwrapped_optional 31 | - joined_default_parameter 32 | - last_where 33 | - let_var_whitespace 34 | - literal_expression_end_indentation 35 | - multiline_arguments 36 | - multiline_arguments_brackets 37 | - multiline_function_chains 38 | - multiline_literal_brackets 39 | - multiline_parameters 40 | - multiline_parameters_brackets 41 | - no_extension_access_modifier 42 | - number_separator 43 | - operator_usage_whitespace 44 | - overridden_super_call 45 | - prefer_zero_over_explicit_init 46 | - reduce_into 47 | - redundant_nil_coalescing 48 | - redundant_self_in_closure 49 | - redundant_type_annotation 50 | - return_value_from_void_function 51 | - shorthand_optional_binding 52 | - sorted_first_last 53 | - static_operator 54 | - superfluous_else 55 | - switch_case_on_newline 56 | - toggle_bool 57 | - unneeded_parentheses_in_closure_argument 58 | - vertical_parameter_alignment_on_call 59 | - vertical_whitespace_closing_braces 60 | - vertical_whitespace_opening_braces 61 | - weak_delegate 62 | - yoda_condition 63 | 64 | disabled_rules: 65 | - trailing_whitespace 66 | - multiple_closures_with_trailing_closure 67 | 68 | attributes: 69 | always_on_same_line: 70 | - '@objc' 71 | - '@NSApplicationDelegateAdaptor' 72 | - '@Environment' 73 | 74 | conditional_returns_on_newline: 75 | if_only: true 76 | 77 | identifier_name: 78 | excluded: 79 | - id 80 | 81 | multiline_arguments: 82 | first_argument_location: next_line 83 | only_enforce_after_first_closure_on_first_line: true 84 | 85 | excluded: 86 | - Carthage 87 | - Pods 88 | 89 | custom_rules: 90 | comments_space: 91 | name: "Space after comment" 92 | regex: '(^ *//\w+)' 93 | message: "There should be a space after //" 94 | severity: warning 95 | double_space: 96 | name: "Double space" 97 | regex: '([a-z,A-Z] \s+)' 98 | message: "Don't use double space between keywords" 99 | match_kinds: keyword 100 | severity: warning 101 | empty_commented_line: 102 | name: "Empty commented out line" 103 | regex: '(\t+| +)//\n' 104 | message: "Remove useless comment lines or use /* format */" 105 | severity: warning 106 | -------------------------------------------------------------------------------- /reminders-menubar/Services/CalendarParser.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | class CalendarParser { 4 | struct TextCalendarResult { 5 | private let range: NSRange 6 | let string: String 7 | let calendar: EKCalendar? 8 | 9 | var highlightedText: RmbHighlightedTextField.HighlightedText { 10 | RmbHighlightedTextField.HighlightedText(range: range, color: calendar?.color ?? .white) 11 | } 12 | 13 | init() { 14 | self.range = NSRange() 15 | self.string = "" 16 | self.calendar = nil 17 | } 18 | 19 | init(range: NSRange, string: String, calendar: EKCalendar?) { 20 | self.range = range 21 | self.string = string 22 | self.calendar = calendar 23 | } 24 | } 25 | 26 | private var calendarsByTitle: [String: EKCalendar] = [:] 27 | private var simplifiedCalendarTitles: [String] = [] 28 | 29 | static private let validInitialChars: Set = ["/", "@"] 30 | 31 | static private(set) var shared = CalendarParser() 32 | 33 | private init() { 34 | // This prevents others from using the default '()' initializer for this class. 35 | } 36 | 37 | static func updateShared(with calendars: [EKCalendar]) { 38 | CalendarParser.shared.calendarsByTitle = calendars 39 | .reduce(into: [String: EKCalendar](), { partialResult, calendar in 40 | let simplifiedTitle = calendar.title.lowercased().replacingOccurrences(of: " ", with: "-") 41 | partialResult[simplifiedTitle] = calendar 42 | }) 43 | CalendarParser.shared.simplifiedCalendarTitles = Array(CalendarParser.shared.calendarsByTitle.keys) 44 | } 45 | 46 | static func isInitialCharValid(_ char: String?) -> Bool { 47 | return validInitialChars.contains(char) 48 | } 49 | 50 | static func getCalendar(from textString: String) -> TextCalendarResult? { 51 | let candidates = textString.split(separator: " ").filter({ 52 | CalendarParser.isInitialCharValid(String($0.prefix(1))) 53 | }) 54 | 55 | guard let substringMatch = candidates.first( 56 | where: { 57 | let title = $0.dropFirst().lowercased() 58 | return CalendarParser.shared.calendarsByTitle[title] != nil 59 | } 60 | ) else { 61 | return nil 62 | } 63 | 64 | let range = NSRange(substringMatch.startIndex.. [String] { 70 | let lowercasedTypingWord = typingWord.lowercased() 71 | let maxSuggestions = 3 72 | let matches = CalendarParser.shared.simplifiedCalendarTitles 73 | .filter({ $0.count > lowercasedTypingWord.count && $0.hasPrefix(lowercasedTypingWord) }) 74 | .sorted(by: { $0.count < $1.count }) 75 | .prefix(maxSuggestions) 76 | return matches.map({ typingWord + $0.dropFirst(typingWord.count) }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Reminders MenuBar 6 |

7 | Reminders MenuBar 8 |

9 |

10 | Simple macOS menu bar app to view and interact with reminders. 11 |

12 |

13 | Features • 14 | Installation • 15 | Permission Request • 16 | Contributing • 17 | Languages • 18 | License 19 |

20 |
21 | 22 |
23 | Reminders MenuBar in light mode 29 | Reminders MenuBar in dark mode 35 |
36 | 37 | ## Features 38 | 39 | * All interactions through the macOS menu bar 40 | * Keep everything in sync with Apple Reminders 41 | * Create new reminders in your chosen list 42 | * Set a reminder's due date using natural language 43 | * Mark reminders as completed / uncompleted 44 | * Edit reminders, Remove reminders or Move reminders between lists 45 | * View a list of upcoming reminders 46 | * Filter reminders through lists or through completed status 47 | 48 |
49 | Reminders MenuBar demo 53 |
54 | 55 | ## Installation 56 | 57 | *Reminders MenuBar requires macOS Big Sur 11 or later.* 58 | 59 | ### Homebrew 60 | 61 | Reminders MenuBar can be installed using [Homebrew](http://brew.sh). 62 | 63 | ```bash 64 | brew install --cask reminders-menubar 65 | ``` 66 | 67 | ### Direct Download 68 | 69 | Direct downloads can be found on the [releases page](https://github.com/DamascenoRafael/reminders-menubar/releases). 70 | After downloading and extracting, just drag the *.app* file to the *Applications* folder. 71 | 72 | ## Permission Request 73 | 74 | Reminders MenuBar uses [EKEventStore](https://developer.apple.com/documentation/eventkit/ekeventstore) to access reminders on macOS (which are available in Apple Reminders and can be synced through iCloud). On first use, the app should request permission to access reminders as shown in the image below. Also, in *System Settings > Privacy & Security > Reminders* it is possible to manage this permission. 75 | 76 |
77 | macOS window asking permission for Reminders MenuBar to access reminders 82 |
83 | 84 | ### OpenCore Legacy Patcher 85 | 86 | [▶︎ Click here if you are using *OpenCore Legacy Patcher*](docs/fix-for-opencore-legacy-patcher.md) 87 | 88 | ## Contributing 89 | 90 | Feel free to share, open issues and contribute to this project! :heart: 91 | 92 | ## Languages 93 | 94 | 🇺🇸 English • 🇧🇷 Brazilian Portuguese • 🇨🇳 Chinese (Simplified and Traditional) • 🇨🇿 Czech • 🇳🇱 Dutch • 🇵🇭 Filipino • 🇫🇷 French • 🇩🇪 German • 🇮🇩 Indonesian • 🇮🇹 Italian • 🇯🇵 Japanese • 🇰🇷 Korean • 🇵🇱 Polish • 🇷🇺 Russian • 🇸🇰 Slovak • 🇲🇽 Spanish (Latin America) • 🇹🇷 Turkish • 🇺🇦 Ukrainian • 🇻🇳 Vietnamese 95 | 96 | [▶︎ Click here to learn how to add new languages :globe_with_meridians:](docs/adding-new-languages.md) 97 | 98 | ## License 99 | 100 | This project is licensed under the terms of the GNU General Public License v3.0. 101 | See [LICENSE](LICENSE) for details. 102 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct ContentView: View { 5 | @EnvironmentObject var remindersData: RemindersData 6 | @ObservedObject var userPreferences = UserPreferences.shared 7 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 8 | 9 | var body: some View { 10 | VStack(spacing: 0) { 11 | FormNewReminderView() 12 | 13 | if userPreferences.atLeastOneFilterIsSelected { 14 | List { 15 | if userPreferences.showUpcomingReminders { 16 | Section(header: UpcomingRemindersTitle()) { 17 | UpcomingRemindersContent() 18 | } 19 | .modifier(ListSectionSpacing()) 20 | .modifier(ListRowSeparatorHidden()) 21 | } 22 | ForEach(remindersData.filteredReminderLists) { reminderList in 23 | Section(header: CalendarTitle(calendar: reminderList.calendar)) { 24 | let uncompletedIsEmpty = reminderList.reminders.uncompleted.isEmpty 25 | let completedIsEmpty = reminderList.reminders.completed.isEmpty 26 | let calendarIsEmpty = uncompletedIsEmpty && completedIsEmpty 27 | let isShowingCompleted = !userPreferences.showUncompletedOnly 28 | let viewIsEmpty = isShowingCompleted ? calendarIsEmpty : uncompletedIsEmpty 29 | if viewIsEmpty { 30 | NoReminderItemsView(emptyList: calendarIsEmpty ? .noReminders : .allItemsCompleted) 31 | } 32 | ForEach(reminderList.reminders.uncompleted) { reminderItem in 33 | ReminderItemView(reminderItem: reminderItem, isShowingCompleted: isShowingCompleted) 34 | } 35 | if isShowingCompleted { 36 | ForEach(reminderList.reminders.completed) { reminderItem in 37 | ReminderItemView(reminderItem: reminderItem, isShowingCompleted: isShowingCompleted) 38 | } 39 | } 40 | } 41 | .modifier(ListSectionSpacing()) 42 | .modifier(ListRowSeparatorHidden()) 43 | } 44 | } 45 | .listStyle(.plain) 46 | .animation(.default, value: remindersData.filteredReminderLists) 47 | } else { 48 | VStack(spacing: 4) { 49 | Text(rmbLocalized(.emptyListNoRemindersFilterTitle)) 50 | .multilineTextAlignment(.center) 51 | Text(rmbLocalized(.emptyListNoRemindersFilterMessage)) 52 | .multilineTextAlignment(.center) 53 | } 54 | .frame(maxHeight: .infinity) 55 | } 56 | 57 | SettingsBarView() 58 | } 59 | .background(Color.rmbColor(for: .backgroundTheme, and: colorSchemeContrast).padding(-80)) 60 | .preferredColorScheme(userPreferences.rmbColorScheme.colorScheme) 61 | } 62 | } 63 | 64 | struct ListSectionSpacing: ViewModifier { 65 | func body(content: Content) -> some View { 66 | return content 67 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) 68 | .padding(.horizontal, 8) 69 | } 70 | } 71 | 72 | struct ListRowSeparatorHidden: ViewModifier { 73 | func body(content: Content) -> some View { 74 | if #available(macOS 14.0, *) { 75 | content 76 | .listRowSeparator(.hidden) 77 | } else { 78 | content 79 | } 80 | } 81 | } 82 | 83 | struct ContentView_Previews: PreviewProvider { 84 | static var previews: some View { 85 | ContentView().environmentObject(RemindersData()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /reminders-menubar/Services/DateParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class DateParser { 4 | static let shared = DateParser() 5 | 6 | private let detector: NSDataDetector? 7 | 8 | struct TextDateResult { 9 | private let range: NSRange 10 | let string: String 11 | 12 | var highlightedText: RmbHighlightedTextField.HighlightedText { 13 | RmbHighlightedTextField.HighlightedText(range: range, color: .systemBlue) 14 | } 15 | 16 | init() { 17 | self.range = NSRange() 18 | self.string = "" 19 | } 20 | 21 | init(range: NSRange, string: String) { 22 | self.range = range 23 | self.string = string 24 | } 25 | } 26 | 27 | struct DateParserResult { 28 | let date: Date 29 | let hasTime: Bool 30 | let isTimeOnly: Bool 31 | let textDateResult: TextDateResult 32 | } 33 | 34 | private init() { 35 | // This prevents others from using the default '()' initializer for this class. 36 | let types: NSTextCheckingResult.CheckingType = [.date] 37 | detector = try? NSDataDetector(types: types.rawValue) 38 | } 39 | 40 | private func adjustDateAccordingToNow(_ dateResult: DateParserResult) -> DateParserResult? { 41 | // NOTE: Date will be adjusted only if it is in the past further than the day before yesterday. 42 | guard dateResult.date.isPast 43 | && !dateResult.date.isToday 44 | && !dateResult.date.isYesterday 45 | && !dateResult.date.isDayBeforeYesterday else { 46 | return dateResult 47 | } 48 | 49 | // NOTE: If the date is set to a day in the current year, but it's past that day, then we assume it's next year. 50 | // "Do something on February 2nd" - when it's already March. 51 | if dateResult.date.isThisYear { 52 | return DateParserResult( 53 | date: .nextYear(of: dateResult.date), 54 | hasTime: dateResult.hasTime, 55 | isTimeOnly: dateResult.isTimeOnly, 56 | textDateResult: dateResult.textDateResult 57 | ) 58 | } 59 | 60 | // NOTE: If the date is not adjusted we will return it unchanged. 61 | return dateResult 62 | } 63 | 64 | private func isTimeSignificant(in match: NSTextCheckingResult) -> Bool { 65 | let timeIsSignificantKey = "timeIsSignificant" 66 | if match.responds(to: NSSelectorFromString(timeIsSignificantKey)) { 67 | return match.value(forKey: timeIsSignificantKey) as? Bool ?? false 68 | } 69 | return false 70 | } 71 | 72 | private func isTimeOnlyResult(in match: NSTextCheckingResult) -> Bool { 73 | let underlyingResultKey = "underlyingResult" 74 | if match.responds(to: NSSelectorFromString(underlyingResultKey)) { 75 | let underlyingResult = match.value(forKey: underlyingResultKey) 76 | let description = underlyingResult.debugDescription 77 | return description.contains("Time") && !description.contains("Date") 78 | } 79 | return false 80 | } 81 | 82 | func getDate(from textString: String) -> DateParserResult? { 83 | let range = NSRange(textString.startIndex..., in: textString) 84 | 85 | let matches = detector?.matches(in: textString, options: [], range: range) 86 | guard let match = matches?.first, let date = match.date else { 87 | return nil 88 | } 89 | 90 | let hasTime = isTimeSignificant(in: match) 91 | let isTimeOnly = isTimeOnlyResult(in: match) 92 | let textDateResult = TextDateResult( 93 | range: match.range, 94 | string: textString.substring(in: match.range) 95 | ) 96 | 97 | let dateResult = DateParserResult( 98 | date: date, 99 | hasTime: hasTime, 100 | isTimeOnly: isTimeOnly, 101 | textDateResult: textDateResult 102 | ) 103 | 104 | return adjustDateAccordingToNow(dateResult) 105 | } 106 | 107 | func getTimeOnly(from textString: String, on date: Date) -> DateParserResult? { 108 | guard let dateResult = getDate(from: textString), 109 | dateResult.date.isSameDay(as: date) || dateResult.isTimeOnly, 110 | dateResult.hasTime else { 111 | return nil 112 | } 113 | 114 | return dateResult 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /reminders-menubar/Views/FormNewReminderView/NewReminderInfoOptionsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct NewReminderInfoOptionsView: View { 5 | @Binding var date: Date 6 | @Binding var hasDueDate: Bool 7 | @Binding var hasTime: Bool 8 | @Binding var priority: EKReminderPriority 9 | 10 | enum InfoOptionType { 11 | case date 12 | case time 13 | case priority 14 | } 15 | 16 | var body: some View { 17 | let infoOptions: [InfoOptionType] = [ 18 | .date, 19 | hasDueDate ? .time : nil, 20 | .priority 21 | ].compactMap { $0 } 22 | 23 | let columns = 2 24 | let infoOptionsHStacked: [[InfoOptionType]] = stride(from: 0, to: infoOptions.count, by: columns).map { 25 | Array(infoOptions[$0.. some View { 42 | switch option { 43 | case .date: 44 | reminderRemindDateTimeOptionView(date: $date, components: .date, hasComponent: $hasDueDate) 45 | case .time: 46 | reminderRemindDateTimeOptionView(date: $date, components: .time, hasComponent: $hasTime) 47 | case .priority: 48 | reminderPriorityOptionView(priority: $priority) 49 | } 50 | } 51 | } 52 | 53 | @ViewBuilder 54 | func reminderRemindDateTimeOptionView( 55 | date: Binding, 56 | components: RmbDatePicker.DatePickerComponents, 57 | hasComponent: Binding 58 | ) -> some View { 59 | let pickerIcon = components == .time ? "clock" : "calendar" 60 | 61 | let addTimeButtonText = rmbLocalized(.newReminderAddTimeButton) 62 | let addDateButtonText = rmbLocalized(.newReminderAddDateButton) 63 | let pickerAddComponentText = components == .time ? addTimeButtonText : addDateButtonText 64 | 65 | if hasComponent.wrappedValue { 66 | HStack { 67 | Image(systemName: pickerIcon) 68 | .font(.system(size: 12)) 69 | RmbDatePicker(selection: date, components: components) 70 | .font(.systemFont(ofSize: 12, weight: .light)) 71 | .fixedSize(horizontal: true, vertical: true) 72 | .padding(.top, 2) 73 | Button { 74 | hasComponent.wrappedValue = false 75 | } label: { 76 | Image(systemName: "xmark") 77 | .font(.system(size: 12)) 78 | } 79 | .buttonStyle(.borderless) 80 | .frame(width: 5, height: 5, alignment: .center) 81 | } 82 | } else { 83 | Button { 84 | hasComponent.wrappedValue = true 85 | } label: { 86 | Label(pickerAddComponentText, systemImage: pickerIcon) 87 | .font(.system(size: 12)) 88 | } 89 | .buttonStyle(.borderless) 90 | } 91 | } 92 | 93 | private func priorityLabel(_ priority: EKReminderPriority) -> RemindersMenuBarLocalizedKeys { 94 | switch priority { 95 | case .low: 96 | return .editReminderPriorityLowOption 97 | case .medium: 98 | return .editReminderPriorityMediumOption 99 | case .high: 100 | return .editReminderPriorityHighOption 101 | default: 102 | return .changeReminderPriorityMenuOption 103 | } 104 | } 105 | 106 | @ViewBuilder 107 | func reminderPriorityOptionView(priority: Binding) -> some View { 108 | let pickerIcon = priority.wrappedValue.systemImage ?? "exclamationmark.circle" 109 | 110 | Button { 111 | priority.wrappedValue = priority.wrappedValue.nextPriority 112 | } label: { 113 | Label(rmbLocalized(priorityLabel(priority.wrappedValue)), systemImage: pickerIcon) 114 | .font(.system(size: 12)) 115 | } 116 | .buttonStyle(.borderless) 117 | } 118 | 119 | struct ReminderInfoCapsule: ViewModifier { 120 | func body(content: Content) -> some View { 121 | return content 122 | .frame(height: 20) 123 | .padding(.horizontal, 8) 124 | .background(Color.secondary.opacity(0.2)) 125 | .clipShape(Capsule()) 126 | .fixedSize() 127 | } 128 | } 129 | 130 | #Preview { 131 | NewReminderInfoOptionsView( 132 | date: .constant(Date()), 133 | hasDueDate: .constant(true), 134 | hasTime: .constant(true), 135 | priority: .constant(.medium) 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderEllipsisMenuView/ReminderChangeDueDateOptionMenu.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct ReminderChangeDueDateOptionMenu: View { 5 | var reminder: EKReminder 6 | 7 | enum ScheduleOption: CaseIterable { 8 | case today 9 | case tomorrow 10 | case thisWeekend 11 | case nextWeek 12 | 13 | var title: String { 14 | switch self { 15 | case .today: 16 | return rmbLocalized(.editReminderDueDateTodayOption) 17 | case .tomorrow: 18 | return rmbLocalized(.editReminderDueDateTomorrowOption) 19 | case .thisWeekend: 20 | return rmbLocalized(.editReminderDueDateThisWeekendOption) 21 | case .nextWeek: 22 | return rmbLocalized(.editReminderDueDateNextWeekOption) 23 | } 24 | } 25 | 26 | func newDate(for initialDate: Date) -> Date { 27 | let today = Date() 28 | let daysToAddForToday = Calendar.current.daysBetween(initialDate, and: today) 29 | 30 | var addingDays: Int { 31 | switch self { 32 | case .today: 33 | return daysToAddForToday 34 | case .tomorrow: 35 | return daysToAddForToday + 1 36 | case .thisWeekend: 37 | let nextWeekend = Calendar.current.nextWeekend(startingAfter: today)?.start ?? today 38 | return Calendar.current.daysBetween(initialDate, and: nextWeekend) 39 | case .nextWeek: 40 | let isWeekend = Calendar.current.isDateInWeekend(today) 41 | if isWeekend { 42 | let todayWeekday = Calendar.current.component(.weekday, from: today) 43 | // Monday is represented by Weekday = 2 44 | let daysFromTodayToNextMonday = (9 - todayWeekday) % 7 45 | return daysToAddForToday + daysFromTodayToNextMonday 46 | } 47 | return daysToAddForToday + 7 48 | } 49 | } 50 | 51 | return Calendar.current.date(byAdding: .day, value: addingDays, to: initialDate) ?? initialDate 52 | } 53 | 54 | func isSelected(for date: Date?) -> Bool { 55 | guard let date else { 56 | return false 57 | } 58 | 59 | return date.isSameDay(as: newDate(for: date)) 60 | } 61 | } 62 | 63 | var body: some View { 64 | let reminderDate = reminder.dueDateComponents?.date 65 | let scheduleOptionSelected = ScheduleOption.allCases.first(where: { $0.isSelected(for: reminderDate) }) 66 | let isAnyOptionSelected = !reminder.hasDueDate || (scheduleOptionSelected != nil) 67 | Menu { 68 | ForEach(ScheduleOption.allCases, id: \.self) { option in 69 | Button(action: { 70 | let date = option.newDate(for: reminderDate ?? Date()) 71 | let hasTime = reminder.hasTime 72 | reminder.removeDueDateAndAlarms() 73 | reminder.addDueDateAndAlarm(for: date, withTime: hasTime) 74 | RemindersService.shared.save(reminder: reminder) 75 | }) { 76 | SelectableView( 77 | title: option.title, 78 | isSelected: option == scheduleOptionSelected, 79 | withPadding: isAnyOptionSelected 80 | ) 81 | } 82 | } 83 | 84 | Divider() 85 | 86 | Button(action: { 87 | reminder.removeDueDateAndAlarms() 88 | reminder.removeAllRecurrenceRules() 89 | RemindersService.shared.save(reminder: reminder) 90 | }) { 91 | SelectableView( 92 | title: rmbLocalized(.editReminderDueDateNoneOption), 93 | isSelected: !reminder.hasDueDate, 94 | withPadding: isAnyOptionSelected 95 | ) 96 | } 97 | } label: { 98 | HStack { 99 | Image(systemName: "calendar") 100 | Text(rmbLocalized(.changeReminderDueDateMenuOption)) 101 | } 102 | } 103 | } 104 | } 105 | 106 | #Preview { 107 | var reminder: EKReminder { 108 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 109 | calendar.color = .systemTeal 110 | 111 | let reminder = EKReminder(eventStore: .init()) 112 | reminder.title = "Look for awesome projects on GitHub" 113 | reminder.isCompleted = false 114 | reminder.calendar = calendar 115 | reminder.dueDateComponents = Date().dateComponents(withTime: true) 116 | reminder.ekPriority = .high 117 | 118 | return reminder 119 | } 120 | 121 | ReminderChangeDueDateOptionMenu(reminder: reminder) 122 | } 123 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/remindersLocalized.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum RemindersMenuBarLocalizedKeys: String { 4 | case newReminderTextFielPlaceholder 5 | case newReminderCalendarSelectionToSaveHelp 6 | case selectListForSavingReminderButtonHelp 7 | case newReminderAddDateButton 8 | case newReminderAddTimeButton 9 | case newReminderAutoSuggestTodayOption 10 | case newReminderRemoveParsedDateOption 11 | case remindersOptionsButtonHelp 12 | case editReminderOptionButton 13 | case editReminderTitleTextFieldPlaceholder 14 | case editReminderNotesTextFieldPlaceholder 15 | case editReminderRemindMeSection 16 | case editReminderRemindDateOption 17 | case editReminderRemindTimeOption 18 | case editReminderPrioritySection 19 | case editReminderListSection 20 | case changeReminderListMenuOption 21 | case changeReminderDueDateMenuOption 22 | case editReminderDueDateTodayOption 23 | case editReminderDueDateTomorrowOption 24 | case editReminderDueDateThisWeekendOption 25 | case editReminderDueDateNextWeekOption 26 | case editReminderDueDateNoneOption 27 | case changeReminderPriorityMenuOption 28 | case editReminderPriorityLowOption 29 | case editReminderPriorityMediumOption 30 | case editReminderPriorityHighOption 31 | case editReminderPriorityNoneOption 32 | case removeReminderOptionButton 33 | case removeReminderAlertTitle 34 | case removeReminderAlertMessage 35 | case removeReminderAlertConfirmButton 36 | case removeReminderAlertCancelButton 37 | case emptyListNoRemindersMessage 38 | case emptyListNoRemindersFilterTitle 39 | case emptyListNoRemindersFilterMessage 40 | case emptyListAllItemsCompletedMessage 41 | case emptyListNoUpcomingRemindersMessage 42 | case upcomingRemindersTitle 43 | case remindersFilterSelectionHelp 44 | case showCompletedRemindersToggleButtonHelp 45 | case updateAvailableNoticeButton 46 | case launchAtLoginOptionButton 47 | case appAppearanceMenu 48 | case appAppearanceMoreOpaqueOptionButton 49 | case appAppearanceMoreTransparentOptionButton 50 | case appAppearanceColorSystemModeOptionButton 51 | case appAppearanceColorLightModeOptionButton 52 | case appAppearanceColorDarkModeOptionButton 53 | case menuBarIconSettingsMenu 54 | case menuBarCounterSettingsMenu 55 | case filterMenuBarCountByCalendarOptionButton 56 | case showMenuBarDueCountOptionButton 57 | case showMenuBarTodayCountOptionButton 58 | case showMenuBarAllRemindersCountOptionButton 59 | case showMenuBarNoCountOptionButton 60 | case keyboardShortcutOptionButton 61 | case reloadRemindersDataButton 62 | case appAboutButton 63 | case appQuitButton 64 | case settingsButtonHelp 65 | case aboutRemindersMenuBarWindowTitle 66 | case appVersionDescription 67 | case remindersMenuBarAppAboutDescription 68 | case remindersMenuBarGitHubAboutDescription 69 | case seeMoreOnGitHubButton 70 | case keyboardShortcutWindowTitle 71 | case keyboardShortcutEnableOpenShortcutOption 72 | case keyboardShortcutRestoreDefaultButton 73 | case upcomingRemindersIntervalSelectionHelp 74 | case upcomingRemindersDueTitle 75 | case upcomingRemindersTodayTitle 76 | case upcomingRemindersInAWeekTitle 77 | case upcomingRemindersInAMonthTitle 78 | case upcomingRemindersAllTitle 79 | case filterUpcomingRemindersByCalendarOptionButton // swiftlint:disable:this identifier_name 80 | case appNoRemindersAccessAlertMessage 81 | case appNoRemindersAccessAlertReasonDescription // swiftlint:disable:this identifier_name 82 | case appNoRemindersAccessAlertActionDescription // swiftlint:disable:this identifier_name 83 | case openSystemPreferencesButton 84 | case okButton 85 | case preferredLanguageMenu 86 | case preferredLanguageSystemOptionButton 87 | case reminderRecurrenceDailyLabel 88 | case reminderRecurrenceWeeklyLabel 89 | case reminderRecurrenceMonthlyLabel 90 | case reminderRecurrenceYearlyLabel 91 | } 92 | 93 | struct ReminderMenuBarLocale { 94 | let identifier: String 95 | let name: String 96 | } 97 | 98 | func rmbLocalized(_ key: RemindersMenuBarLocalizedKeys, arguments: CVarArg...) -> String { 99 | let preferredLanguage = rmbCurrentLocale().identifier 100 | let localePath = Bundle.main.path(forResource: preferredLanguage, ofType: "lproj") ?? "" 101 | let localeBundle = Bundle(path: localePath) ?? Bundle.main 102 | 103 | let fallbackString = Bundle.main.localizedString(forKey: key.rawValue, value: nil, table: nil) 104 | let localizedString = localeBundle.localizedString(forKey: key.rawValue, value: fallbackString, table: nil) 105 | return String(format: localizedString, arguments: arguments) 106 | } 107 | 108 | func rmbAvailableLocales() -> [ReminderMenuBarLocale] { 109 | let currentLocale = rmbCurrentLocale() 110 | 111 | let locales = Bundle.main.localizations.compactMap { identifier -> ReminderMenuBarLocale? in 112 | guard let name = currentLocale.localizedString(forIdentifier: identifier) else { 113 | return nil 114 | } 115 | return ReminderMenuBarLocale(identifier: identifier, name: name.capitalized) 116 | } 117 | 118 | return locales.sorted(by: { $0.name < $1.name }) 119 | } 120 | 121 | func rmbCurrentLocale() -> Locale { 122 | var currentLocale = Locale.current 123 | if let preferredLanguage = UserPreferences.shared.preferredLanguage { 124 | currentLocale = Locale(identifier: preferredLanguage) 125 | } 126 | 127 | return currentLocale 128 | } 129 | -------------------------------------------------------------------------------- /reminders-menubar/Services/RemindersService.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | @MainActor 4 | class RemindersService { 5 | static let shared = RemindersService() 6 | 7 | private init() { 8 | // This prevents others from using the default '()' initializer for this class. 9 | } 10 | 11 | private let eventStore = EKEventStore() 12 | 13 | func authorizationStatus() -> EKAuthorizationStatus { 14 | return EKEventStore.authorizationStatus(for: .reminder) 15 | } 16 | 17 | func requestAccess(completion: @escaping (Bool, String?) -> Void) { 18 | if #available(macOS 14.0, *) { 19 | eventStore.requestFullAccessToReminders { granted, error in 20 | completion(granted, error?.localizedDescription) 21 | } 22 | } else { 23 | eventStore.requestAccess(to: .reminder) { granted, error in 24 | completion(granted, error?.localizedDescription) 25 | } 26 | } 27 | } 28 | 29 | func getCalendar(withIdentifier calendarIdentifier: String) -> EKCalendar? { 30 | return eventStore.calendar(withIdentifier: calendarIdentifier) 31 | } 32 | 33 | func getCalendars() -> [EKCalendar] { 34 | return eventStore.calendars(for: .reminder) 35 | } 36 | 37 | func getDefaultCalendar() -> EKCalendar? { 38 | return eventStore.defaultCalendarForNewReminders() ?? eventStore.calendars(for: .reminder).first 39 | } 40 | 41 | private func fetchReminders(matching predicate: NSPredicate) async -> [EKReminder] { 42 | await withCheckedContinuation { continuation in 43 | eventStore.fetchReminders(matching: predicate) { allReminders in 44 | guard let allReminders else { 45 | continuation.resume(returning: []) 46 | return 47 | } 48 | continuation.resume(returning: allReminders) 49 | } 50 | } 51 | } 52 | 53 | private func createReminderItems(for calendarReminders: [EKReminder]) -> [ReminderItem] { 54 | var reminderListItems: [ReminderItem] = [] 55 | 56 | let noParentKey = "noParentKey" 57 | let remindersByParentId = Dictionary(grouping: calendarReminders, by: { $0.parentId ?? noParentKey }) 58 | let parentReminders = remindersByParentId[noParentKey, default: []] 59 | 60 | parentReminders.forEach { parentReminder in 61 | let parentId = parentReminder.calendarItemIdentifier 62 | let children = remindersByParentId[parentId, default: []].map({ ReminderItem(for: $0, isChild: true) }) 63 | reminderListItems.append(ReminderItem(for: parentReminder, withChildren: children)) 64 | } 65 | return reminderListItems 66 | } 67 | 68 | func getReminders(of calendarIdentifiers: [String]) async -> [ReminderList] { 69 | let calendars = getCalendars().filter({ calendarIdentifiers.contains($0.calendarIdentifier) }) 70 | let predicate = eventStore.predicateForReminders(in: calendars) 71 | let remindersByCalendar = Dictionary( 72 | grouping: await fetchReminders(matching: predicate), 73 | by: { $0.calendar.calendarIdentifier } 74 | ) 75 | 76 | var reminderLists: [ReminderList] = [] 77 | for calendar in calendars { 78 | let calendarReminders = remindersByCalendar[calendar.calendarIdentifier, default: []] 79 | let reminderListItems = createReminderItems(for: calendarReminders) 80 | reminderLists.append(ReminderList(for: calendar, with: reminderListItems)) 81 | } 82 | 83 | return reminderLists 84 | } 85 | 86 | func getUpcomingReminders( 87 | _ interval: ReminderInterval, 88 | for calendarIdentifiers: [String]? = nil 89 | ) async -> [ReminderItem] { 90 | var calendars: [EKCalendar]? 91 | if let calendarIdentifiers { 92 | if calendarIdentifiers.isEmpty { 93 | // If the filter does not have any calendar selected, return empty 94 | return [] 95 | } 96 | calendars = getCalendars().filter({ calendarIdentifiers.contains($0.calendarIdentifier) }) 97 | } 98 | let predicate = eventStore.predicateForIncompleteReminders( 99 | withDueDateStarting: nil, 100 | ending: interval.endingDate, 101 | calendars: calendars 102 | ) 103 | var reminders = await fetchReminders(matching: predicate).map({ ReminderItem(for: $0) }) 104 | if interval == .due { 105 | // For the 'due' interval, we should filter reminders for today with no time. 106 | // These will only be considered due/expired on the following day. 107 | reminders = reminders.filter { $0.reminder.isExpired } 108 | } 109 | return reminders.sortedReminders 110 | } 111 | 112 | func save(reminder: EKReminder) { 113 | do { 114 | try eventStore.save(reminder, commit: true) 115 | } catch { 116 | print("Error saving reminder:", error.localizedDescription) 117 | } 118 | } 119 | 120 | func createNew(with rmbReminder: RmbReminder, in calendar: EKCalendar) { 121 | let newReminder = EKReminder(eventStore: eventStore) 122 | newReminder.update(with: rmbReminder) 123 | newReminder.calendar = calendar 124 | save(reminder: newReminder) 125 | } 126 | 127 | func remove(reminder: EKReminder) { 128 | do { 129 | try eventStore.remove(reminder, commit: true) 130 | } catch { 131 | print("Error removing reminder:", error.localizedDescription) 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | @MainActor 5 | struct ReminderItemView: View { 6 | var reminderItem: ReminderItem 7 | var isShowingCompleted: Bool 8 | var showCalendarTitleOnDueDate = false 9 | @State var reminderItemIsHovered = false 10 | 11 | @State private var showingEditPopover = false 12 | @State private var isEditingTitle = false 13 | 14 | @State private var showingRemoveAlert = false 15 | 16 | var body: some View { 17 | if reminderItem.reminder.calendar == nil { 18 | // On macOS 12 the calendar may be nil during delete operation. 19 | // Returning Empty to avoid issues since calendar is a force unwrap. 20 | EmptyView() 21 | } else { 22 | mainReminderItemView() 23 | } 24 | } 25 | 26 | @ViewBuilder 27 | func mainReminderItemView() -> some View { 28 | HStack(alignment: .top) { 29 | ReminderCompleteButton(reminderItem: reminderItem) 30 | 31 | VStack(spacing: 6) { 32 | HStack(spacing: 4) { 33 | if let prioritySystemImage = reminderItem.reminder.ekPriority.systemImage { 34 | Image(systemName: prioritySystemImage) 35 | .foregroundColor(Color(reminderItem.reminder.calendar.color)) 36 | } 37 | Text(LocalizedStringKey(reminderItem.reminder.title.toDetectedLinkAttributedString())) 38 | .fixedSize(horizontal: false, vertical: true) 39 | .onTapGesture { 40 | isEditingTitle = true 41 | showingEditPopover = true 42 | } 43 | 44 | Spacer() 45 | 46 | // TODO: remove the `.id` modifier while keeping properties updated (such as selected priority) 47 | ReminderEllipsisMenuView( 48 | showingEditPopover: $showingEditPopover, 49 | showingRemoveAlert: $showingRemoveAlert, 50 | reminder: reminderItem.reminder, 51 | reminderHasChildren: reminderItem.hasChildren 52 | ) 53 | .id(UUID()) 54 | .opacity(shouldShowEllipsisButton() ? 1 : 0) 55 | .popover(isPresented: $showingEditPopover, arrowEdge: .trailing) { 56 | ReminderEditPopover( 57 | isPresented: $showingEditPopover, 58 | focusOnTitle: $isEditingTitle, 59 | reminder: reminderItem.reminder, 60 | reminderHasChildren: reminderItem.hasChildren 61 | ) 62 | } 63 | } 64 | .alert(isPresented: $showingRemoveAlert) { 65 | removeReminderAlert() 66 | } 67 | 68 | if let dateDescription = reminderItem.reminder.relativeDateDescription { 69 | ReminderDateDescriptionView( 70 | dateDescription: dateDescription, 71 | isExpired: reminderItem.reminder.isExpired, 72 | hasRecurrenceRules: reminderItem.reminder.hasRecurrenceRules, 73 | recurrenceRules: reminderItem.reminder.recurrenceRules, 74 | calendarTitle: reminderItem.reminder.calendar.title, 75 | showCalendarTitleOnDueDate: showCalendarTitleOnDueDate 76 | ) 77 | } 78 | 79 | if reminderItem.reminder.attachedUrl != nil || reminderItem.reminder.mailUrl != nil { 80 | ReminderExternalLinksView( 81 | attachedUrl: reminderItem.reminder.attachedUrl, 82 | mailUrl: reminderItem.reminder.mailUrl 83 | ) 84 | } 85 | 86 | Divider() 87 | } 88 | } 89 | .onHover { isHovered in 90 | reminderItemIsHovered = isHovered 91 | } 92 | .padding(.leading, reminderItem.isChild ? 24 : 0) 93 | 94 | ForEach(reminderItem.childReminders.uncompleted) { reminderItem in 95 | ReminderItemView(reminderItem: reminderItem, isShowingCompleted: isShowingCompleted) 96 | } 97 | 98 | if isShowingCompleted { 99 | ForEach(reminderItem.childReminders.completed) { reminderItem in 100 | ReminderItemView(reminderItem: reminderItem, isShowingCompleted: isShowingCompleted) 101 | } 102 | } 103 | } 104 | 105 | func shouldShowEllipsisButton() -> Bool { 106 | return reminderItemIsHovered || showingEditPopover 107 | } 108 | 109 | func removeReminderAlert() -> Alert { 110 | Alert( 111 | title: Text(rmbLocalized(.removeReminderAlertTitle)), 112 | message: Text(rmbLocalized(.removeReminderAlertMessage, arguments: reminderItem.reminder.title)), 113 | primaryButton: .destructive(Text(rmbLocalized(.removeReminderAlertConfirmButton)), action: { 114 | RemindersService.shared.remove(reminder: reminderItem.reminder) 115 | }), 116 | secondaryButton: .cancel(Text(rmbLocalized(.removeReminderAlertCancelButton))) 117 | ) 118 | } 119 | } 120 | 121 | #Preview { 122 | var reminder: EKReminder { 123 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 124 | calendar.color = .systemTeal 125 | 126 | let reminder = EKReminder(eventStore: .init()) 127 | reminder.title = "Look for awesome projects on GitHub" 128 | reminder.isCompleted = false 129 | reminder.calendar = calendar 130 | 131 | return reminder 132 | } 133 | let reminderItem = ReminderItem(for: reminder) 134 | 135 | ReminderItemView(reminderItem: reminderItem, isShowingCompleted: false) 136 | } 137 | -------------------------------------------------------------------------------- /reminders-menubar/Resources/InfoPlist.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "CFBundleName" : { 5 | "comment" : "Bundle name", 6 | "extractionState" : "extracted_with_value", 7 | "localizations" : { 8 | "en" : { 9 | "stringUnit" : { 10 | "state" : "new", 11 | "value" : "Reminders MenuBar" 12 | } 13 | } 14 | }, 15 | "shouldTranslate" : false 16 | }, 17 | "NSHumanReadableCopyright" : { 18 | "comment" : "Copyright (human-readable)", 19 | "extractionState" : "extracted_with_value", 20 | "localizations" : { 21 | "en" : { 22 | "stringUnit" : { 23 | "state" : "new", 24 | "value" : "Copyright © Rafael Damasceno, GNU General Public License v3." 25 | } 26 | } 27 | }, 28 | "shouldTranslate" : false 29 | }, 30 | "NSRemindersUsageDescription" : { 31 | "comment" : "Privacy - Reminders Usage Description", 32 | "extractionState" : "extracted_with_value", 33 | "localizations" : { 34 | "de" : { 35 | "stringUnit" : { 36 | "state" : "translated", 37 | "value" : "Die App verwendet Apple Erinnerungen als Quelle für Listen und Aufgaben. Zum Anzeigen und Bearbeiten von Erinnerungen ist ein Zugang erforderlich." 38 | } 39 | }, 40 | "en" : { 41 | "stringUnit" : { 42 | "state" : "new", 43 | "value" : "The App uses Apple Reminders as a source for lists and tasks. Access is required to view and edit reminders." 44 | } 45 | }, 46 | "es-419" : { 47 | "stringUnit" : { 48 | "state" : "translated", 49 | "value" : "La aplicación utiliza Recordatorios de Apple como fuente de listas y tareas. Se requiere acceso para ver y editar recordatorios." 50 | } 51 | }, 52 | "fil-PH" : { 53 | "stringUnit" : { 54 | "state" : "translated", 55 | "value" : "Gumagamit ang App ng Apple Reminders bilang pinagmulan ng mga listahan at gawain. Kailangan ang access para makita at i-edit ang mga paalala." 56 | } 57 | }, 58 | "fr" : { 59 | "stringUnit" : { 60 | "state" : "translated", 61 | "value" : "Cette application utilise les Rappels Apple comme source de listes et de tâches. L'accès aux Rappels est nécessaire pour les afficher et les modifier." 62 | } 63 | }, 64 | "id" : { 65 | "stringUnit" : { 66 | "state" : "translated", 67 | "value" : "Aplikasi ini menggunakan Pengingat Apple sebagai sumber untuk daftar dan tugas. Akses diperlukan untuk melihat dan mengedit pengingat." 68 | } 69 | }, 70 | "it" : { 71 | "stringUnit" : { 72 | "state" : "translated", 73 | "value" : "L'app utilizza Promemoria Apple come fonte per elenchi e attività. L'accesso è necessario per visualizzare e modificare i promemoria." 74 | } 75 | }, 76 | "ja" : { 77 | "stringUnit" : { 78 | "state" : "translated", 79 | "value" : "このアプリはリマインダーをリストやタスクのソースとして使うので、リマインダーの表示や編集にはアクセスを許可する必要があります。" 80 | } 81 | }, 82 | "ko" : { 83 | "stringUnit" : { 84 | "state" : "translated", 85 | "value" : "이 프로그램은 미리 알림의 항목을 사용합니다. 미리 알림을 보거나 편집하려면 접근 권한이 필요합니다." 86 | } 87 | }, 88 | "nl" : { 89 | "stringUnit" : { 90 | "state" : "translated", 91 | "value" : "De app gebruikt Apple Reminders als bron voor lijsten en taken. Toegang is vereist om herinneringen te bekijken en te bewerken." 92 | } 93 | }, 94 | "pl" : { 95 | "stringUnit" : { 96 | "state" : "translated", 97 | "value" : "Aplikacja wykorzystuje Przypomnienia Apple jako źródło list i zadań. Dostęp jest wymagany do przeglądania i edytowania przypomnień." 98 | } 99 | }, 100 | "pt-BR" : { 101 | "stringUnit" : { 102 | "state" : "translated", 103 | "value" : "O App usa o Lembretes da Apple como fonte para as listas e tarefas. O acesso é necessário para visualizar e editar lembretes." 104 | } 105 | }, 106 | "ru" : { 107 | "stringUnit" : { 108 | "state" : "translated", 109 | "value" : "Приложение использует Напоминания в качестве источника списков напоминаний. Доступ необходим для просмотра и редактирования напоминаний." 110 | } 111 | }, 112 | "sk" : { 113 | "stringUnit" : { 114 | "state" : "translated", 115 | "value" : "Aplikácia používa Apple Pripomienky ako zdroj zoznamov a úloh. Na zobrazenie a úpravu pripomienok je potrebný prístup." 116 | } 117 | }, 118 | "tr" : { 119 | "stringUnit" : { 120 | "state" : "translated", 121 | "value" : "Uygulama, listeler ve görevler için Apple Hatırlatıcılarını kullanır. Hatırlatıcıları görüntülemek ve düzenlemek için erişim gereklidir." 122 | } 123 | }, 124 | "uk" : { 125 | "stringUnit" : { 126 | "state" : "translated", 127 | "value" : "Програма використовує Apple Reminders як джерело списків і нагадувань. Для перегляду та редагування нагадувань потрібен доступ." 128 | } 129 | }, 130 | "vi" : { 131 | "stringUnit" : { 132 | "state" : "translated", 133 | "value" : "Ứng dụng này sử dụng Apple lời nhắc cho các danh sách và các công việc. Cần có quyền truy cập để xem và chỉnh sửa lời nhắc." 134 | } 135 | }, 136 | "zh-Hans" : { 137 | "stringUnit" : { 138 | "state" : "translated", 139 | "value" : "本程序使用苹果提醒事项作为列表和任务的来源。 需要授权以查看及更改提醒事项。" 140 | } 141 | }, 142 | "zh-Hant" : { 143 | "stringUnit" : { 144 | "state" : "translated", 145 | "value" : "此應用程式使用 Apple 提醒事項作為列表和任務的資料來源。需要存取權限以查看與編輯提醒事項。" 146 | } 147 | } 148 | } 149 | } 150 | }, 151 | "version" : "1.0" 152 | } -------------------------------------------------------------------------------- /reminders-menubar/Models/RmbReminder.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | struct RmbReminder { 4 | private var originalReminder: EKReminder? 5 | private var isPreparingToSave = false 6 | private var isParsingEnabled = false 7 | private var isAutoSuggestingTodayForCreation = false 8 | 9 | var hasDateChanges: Bool { 10 | guard let originalReminder else { 11 | return true 12 | } 13 | 14 | return 15 | hasDueDate != originalReminder.hasDueDate || 16 | hasTime != originalReminder.hasTime || 17 | date != originalReminder.dueDateComponents?.date 18 | } 19 | 20 | var title: String { 21 | willSet { 22 | guard !isPreparingToSave && isParsingEnabled else { 23 | return 24 | } 25 | updateTextDateResult(with: newValue) 26 | updateTextCalendarResult(with: newValue) 27 | updateTextPriorityResult(with: newValue) 28 | } 29 | } 30 | 31 | var notes: String? 32 | var date: Date { 33 | didSet { 34 | // NOTE: When the date is changed, we assume that it was done by the user. 35 | // If it was changed by DateParser it is necessary to add textDateResult after changing the date. 36 | textDateResult = DateParser.TextDateResult() 37 | isAutoSuggestingTodayForCreation = false 38 | } 39 | } 40 | var hasDueDate: Bool { 41 | didSet { 42 | // NOTE: When the hasDueDate option is disabled, it must disable hasTime 43 | // so that, if enabled again, it does not have "remind me at a time" enabled 44 | if !hasDueDate { 45 | hasTime = false 46 | } 47 | } 48 | } 49 | var hasTime: Bool { 50 | didSet { 51 | // NOTE: When enabling the option to add a time the suggestion will be the next hour of the current moment 52 | date = .nextExactHour(of: date) 53 | } 54 | } 55 | var priority: EKReminderPriority 56 | var calendar: EKCalendar? 57 | 58 | var textDateResult = DateParser.TextDateResult() 59 | var textCalendarResult = CalendarParser.TextCalendarResult() 60 | var textPriorityResult = PriorityParser.PriorityParserResult() 61 | 62 | var highlightedTexts: [RmbHighlightedTextField.HighlightedText] { 63 | [textDateResult.highlightedText, textCalendarResult.highlightedText, textPriorityResult.highlightedText] 64 | } 65 | 66 | init() { 67 | title = "" 68 | date = .nextExactHour() 69 | hasDueDate = false 70 | hasTime = false 71 | priority = .none 72 | isParsingEnabled = true 73 | } 74 | 75 | init(reminder: EKReminder) { 76 | originalReminder = reminder 77 | title = reminder.title 78 | notes = reminder.notes 79 | date = reminder.dueDateComponents?.date ?? .nextExactHour() 80 | hasDueDate = reminder.hasDueDate 81 | hasTime = reminder.hasTime 82 | priority = reminder.ekPriority 83 | calendar = reminder.calendar 84 | } 85 | 86 | mutating func setIsAutoSuggestingTodayForCreation() { 87 | guard !hasDueDate else { 88 | return 89 | } 90 | self.hasDueDate = true 91 | self.isAutoSuggestingTodayForCreation = true 92 | } 93 | 94 | mutating func updateSuggestedDate() { 95 | date = .nextExactHour() 96 | } 97 | 98 | mutating func prepareToSave() { 99 | isPreparingToSave = true 100 | } 101 | 102 | private mutating func updateTextDateResult(with newTitle: String) { 103 | if isAutoSuggestingTodayForCreation { 104 | updateTextDateResultTimeOnly(with: newTitle, isAutoSuggestingToday: true) 105 | return 106 | } 107 | 108 | // NOTE: If a date was defined by the user then the DateParser should not be applied. 109 | if hasDueDate && textDateResult.string.isEmpty { 110 | return 111 | } 112 | 113 | guard let dateResult = DateParser.shared.getDate(from: newTitle) else { 114 | hasDueDate = false 115 | hasTime = false 116 | date = .nextExactHour() 117 | textDateResult = DateParser.TextDateResult() 118 | return 119 | } 120 | 121 | hasDueDate = true 122 | hasTime = dateResult.hasTime 123 | date = dateResult.date 124 | textDateResult = dateResult.textDateResult 125 | } 126 | 127 | private mutating func updateTextDateResultTimeOnly(with newTitle: String, isAutoSuggestingToday: Bool) { 128 | // NOTE: If a time was defined by the user then the DateParser should not be applied. 129 | if hasTime && textDateResult.string.isEmpty { 130 | return 131 | } 132 | 133 | guard let dateResult = DateParser.shared.getTimeOnly(from: newTitle, on: date) else { 134 | hasTime = false 135 | textDateResult = DateParser.TextDateResult() 136 | isAutoSuggestingTodayForCreation = isAutoSuggestingToday 137 | return 138 | } 139 | 140 | hasTime = true 141 | date = dateResult.date 142 | textDateResult = dateResult.textDateResult 143 | isAutoSuggestingTodayForCreation = isAutoSuggestingToday 144 | } 145 | 146 | private mutating func updateTextCalendarResult(with newTitle: String) { 147 | guard let calendarResult = CalendarParser.getCalendar(from: newTitle) else { 148 | textCalendarResult = CalendarParser.TextCalendarResult() 149 | return 150 | } 151 | 152 | textCalendarResult = calendarResult 153 | } 154 | 155 | private mutating func updateTextPriorityResult(with newTitle: String) { 156 | // NOTE: If a priority was defined by the user then the PriorityParser should not be applied. 157 | if priority != .none && textPriorityResult.string.isEmpty { 158 | return 159 | } 160 | 161 | guard let priorityResult = PriorityParser.getPriority(from: newTitle) else { 162 | textPriorityResult = PriorityParser.PriorityParserResult() 163 | priority = .none 164 | return 165 | } 166 | 167 | priority = priorityResult.priority 168 | textPriorityResult = priorityResult 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /reminders-menubar/Views/ReminderItemView/ReminderEditPopover.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct ReminderEditPopover: View { 5 | @EnvironmentObject var remindersData: RemindersData 6 | 7 | @Binding var isPresented: Bool 8 | @Binding var focusOnTitle: Bool 9 | 10 | @State var rmbReminder: RmbReminder 11 | var ekReminder: EKReminder 12 | var reminderHasChildren: Bool 13 | 14 | @State var titleTextFieldFocusTrigger = UUID() 15 | @State var titleTextFieldDynamicHeight: CGFloat = 0 16 | @State var notesTextFieldDynamicHeight: CGFloat = 0 17 | 18 | init(isPresented: Binding, focusOnTitle: Binding, reminder: EKReminder, reminderHasChildren: Bool) { 19 | _isPresented = isPresented 20 | _focusOnTitle = focusOnTitle 21 | self.ekReminder = reminder 22 | self.reminderHasChildren = reminderHasChildren 23 | _rmbReminder = State(initialValue: RmbReminder(reminder: reminder)) 24 | } 25 | 26 | var body: some View { 27 | VStack(alignment: .leading) { 28 | RmbHighlightedTextField( 29 | placeholder: rmbLocalized(.editReminderTitleTextFieldPlaceholder), 30 | text: $rmbReminder.title, 31 | textContainerDynamicHeight: $titleTextFieldDynamicHeight, 32 | focusTrigger: focusOnTitle ? $titleTextFieldFocusTrigger : nil 33 | ) 34 | .onSubmit { 35 | isPresented = false 36 | } 37 | .fontStyle(.title3) 38 | .frame(height: titleTextFieldDynamicHeight) 39 | 40 | RmbHighlightedTextField( 41 | placeholder: rmbLocalized(.editReminderNotesTextFieldPlaceholder), 42 | text: Binding($rmbReminder.notes, replacingNilWith: ""), 43 | textContainerDynamicHeight: $notesTextFieldDynamicHeight, 44 | allowNewLineAndTab: true 45 | ) 46 | .frame(height: notesTextFieldDynamicHeight) 47 | 48 | Divider() 49 | 50 | ReminderSection(rmbLocalized(.editReminderRemindMeSection)) { 51 | Toggle(rmbLocalized(.editReminderRemindDateOption), isOn: $rmbReminder.hasDueDate) 52 | 53 | if rmbReminder.hasDueDate { 54 | RmbDatePicker(selection: $rmbReminder.date, components: .date) 55 | .fixedSize(horizontal: true, vertical: false) 56 | .padding(.leading, 16) 57 | 58 | Toggle(rmbLocalized(.editReminderRemindTimeOption), isOn: $rmbReminder.hasTime) 59 | 60 | if rmbReminder.hasTime { 61 | RmbDatePicker(selection: $rmbReminder.date, components: .time) 62 | .fixedSize(horizontal: true, vertical: false) 63 | .padding(.leading, 16) 64 | } 65 | } 66 | } 67 | 68 | Divider() 69 | 70 | ReminderSection(rmbLocalized(.editReminderPrioritySection)) { 71 | Picker(selection: $rmbReminder.priority) { 72 | Text(rmbLocalized(.editReminderPriorityLowOption)).tag(EKReminderPriority.low) 73 | Text(rmbLocalized(.editReminderPriorityMediumOption)).tag(EKReminderPriority.medium) 74 | Text(rmbLocalized(.editReminderPriorityHighOption)).tag(EKReminderPriority.high) 75 | Divider() 76 | Text(rmbLocalized(.editReminderPriorityNoneOption)).tag(EKReminderPriority.none) 77 | } label: { 78 | Text(verbatim: "") 79 | } 80 | .labelsHidden() 81 | .fixedSize(horizontal: true, vertical: false) 82 | } 83 | 84 | if !reminderHasChildren { 85 | ReminderSection(rmbLocalized(.editReminderListSection)) { 86 | Picker(selection: $rmbReminder.calendar) { 87 | ForEach(remindersData.calendars, id: \.calendarIdentifier) { calendar in 88 | SelectableView(title: calendar.title, color: Color(calendar.color)).tag(calendar) 89 | } 90 | } label: { 91 | Text(verbatim: "") 92 | } 93 | .labelsHidden() 94 | .fixedSize(horizontal: true, vertical: false) 95 | } 96 | } 97 | } 98 | .frame(width: 300, alignment: .center) 99 | .padding() 100 | .modifier(OnKeyboardShortcut(shortcut: .defaultAction, action: { 101 | isPresented = false 102 | })) 103 | .modifier(OnKeyboardShortcut(shortcut: .cancelAction, action: { 104 | isPresented = false 105 | })) 106 | .onDisappear { 107 | focusOnTitle = false 108 | ekReminder.update(with: rmbReminder) 109 | if ekReminder.hasChanges { 110 | RemindersService.shared.save(reminder: ekReminder) 111 | } 112 | } 113 | } 114 | } 115 | 116 | struct ReminderSection: View where Content: View { 117 | let sectionName: String 118 | let sectionView: Content 119 | 120 | init(_ sectionName: String, @ViewBuilder sectionView: () -> Content) { 121 | self.sectionName = sectionName 122 | self.sectionView = sectionView() 123 | } 124 | 125 | var body: some View { 126 | HStack(alignment: .top) { 127 | Text(sectionName) 128 | .frame(width: 100, alignment: .trailing) 129 | .foregroundColor(.secondary) 130 | 131 | VStack(alignment: .leading) { 132 | sectionView 133 | } 134 | } 135 | } 136 | } 137 | 138 | #Preview { 139 | var reminder: EKReminder { 140 | let calendar = EKCalendar(for: .reminder, eventStore: .init()) 141 | calendar.color = .systemTeal 142 | 143 | let reminder = EKReminder(eventStore: .init()) 144 | reminder.title = "Look for awesome projects on GitHub" 145 | reminder.isCompleted = false 146 | reminder.calendar = calendar 147 | reminder.dueDateComponents = Date().dateComponents(withTime: true) 148 | reminder.ekPriority = .high 149 | 150 | return reminder 151 | } 152 | 153 | ReminderEditPopover( 154 | isPresented: .constant(true), 155 | focusOnTitle: .constant(false), 156 | reminder: reminder, 157 | reminderHasChildren: false 158 | ) 159 | } 160 | -------------------------------------------------------------------------------- /reminders-menubar/Models/RemindersData.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import EventKit 4 | 5 | @MainActor 6 | class RemindersData: ObservableObject { 7 | private var cancellationTokens: [AnyCancellable] = [] 8 | 9 | init() { 10 | addObservers() 11 | Task { 12 | await update() 13 | } 14 | } 15 | 16 | private func addObservers() { 17 | NotificationCenter.default.publisher(for: .EKEventStoreChanged) 18 | .sink { [weak self] _ in 19 | Task { 20 | await self?.update() 21 | } 22 | } 23 | .store(in: &cancellationTokens) 24 | 25 | NotificationCenter.default.publisher(for: .NSCalendarDayChanged) 26 | .sink { [weak self] _ in 27 | Task { 28 | await self?.update() 29 | } 30 | } 31 | .store(in: &cancellationTokens) 32 | 33 | UserPreferences.shared.$menuBarCounterType 34 | .dropFirst() 35 | .sink { [weak self] _ in 36 | Task { 37 | guard let self else { return } 38 | self.updateMenuBarCount(with: await self.getMenuBarCount()) 39 | } 40 | } 41 | .store(in: &cancellationTokens) 42 | 43 | UserPreferences.shared.$filterMenuBarCountByCalendar 44 | .dropFirst() 45 | .sink { [weak self] _ in 46 | Task { 47 | guard let self else { return } 48 | self.updateMenuBarCount(with: await self.getMenuBarCount()) 49 | } 50 | } 51 | .store(in: &cancellationTokens) 52 | 53 | UserPreferences.shared.$upcomingRemindersInterval 54 | .dropFirst() 55 | .sink { [weak self] _ in 56 | Task { 57 | guard let self else { return } 58 | self.upcomingReminders = await self.getUpcomingReminders() 59 | } 60 | } 61 | .store(in: &cancellationTokens) 62 | 63 | UserPreferences.shared.$filterUpcomingRemindersByCalendar 64 | .dropFirst() 65 | .sink { [weak self] _ in 66 | Task { 67 | guard let self else { return } 68 | self.upcomingReminders = await self.getUpcomingReminders() 69 | } 70 | } 71 | .store(in: &cancellationTokens) 72 | 73 | $calendarIdentifiersFilter 74 | .dropFirst() 75 | .sink { [weak self] calendarIdentifiersFilter in 76 | Task { 77 | guard let self else { return } 78 | self.filteredReminderLists = await RemindersService.shared.getReminders( 79 | of: calendarIdentifiersFilter 80 | ) 81 | 82 | self.upcomingReminders = await self.getUpcomingReminders() 83 | self.updateMenuBarCount(with: await self.getMenuBarCount()) 84 | } 85 | } 86 | .store(in: &cancellationTokens) 87 | } 88 | 89 | @Published var calendars: [EKCalendar] = [] 90 | 91 | @Published var upcomingReminders: [ReminderItem] = [] 92 | 93 | @Published var filteredReminderLists: [ReminderList] = [] 94 | 95 | @Published var calendarIdentifiersFilter: [String] = { 96 | guard let identifiers = UserPreferences.shared.preferredCalendarIdentifiersFilter else { 97 | // NOTE: On first use it will load all reminder lists. 98 | let allCalendars = RemindersService.shared.getCalendars() 99 | return allCalendars.map({ $0.calendarIdentifier }) 100 | } 101 | 102 | return identifiers 103 | }() { 104 | didSet { 105 | UserPreferences.shared.preferredCalendarIdentifiersFilter = calendarIdentifiersFilter 106 | } 107 | } 108 | 109 | @Published var calendarForSaving: EKCalendar? = { 110 | guard RemindersService.shared.authorizationStatus() == .authorized else { 111 | return nil 112 | } 113 | 114 | guard let identifier = UserPreferences.shared.preferredCalendarIdentifierForSaving, 115 | let calendar = RemindersService.shared.getCalendar(withIdentifier: identifier) else { 116 | return RemindersService.shared.getDefaultCalendar() 117 | } 118 | 119 | return calendar 120 | }() { 121 | didSet { 122 | let identifier = calendarForSaving?.calendarIdentifier 123 | UserPreferences.shared.preferredCalendarIdentifierForSaving = identifier 124 | } 125 | } 126 | 127 | func update() async { 128 | let calendars = RemindersService.shared.getCalendars() 129 | 130 | let calendarsSet = Set(calendars.map({ $0.calendarIdentifier })) 131 | let calendarIdentifiersFilter = self.calendarIdentifiersFilter.filter({ 132 | // NOTE: Checking if calendar in filter still exist 133 | calendarsSet.contains($0) 134 | }) 135 | 136 | self.calendars = calendars 137 | self.calendarIdentifiersFilter = calendarIdentifiersFilter 138 | self.upcomingReminders = await getUpcomingReminders() 139 | self.updateMenuBarCount(with: await getMenuBarCount()) 140 | } 141 | 142 | private func getUpcomingReminders() async -> [ReminderItem] { 143 | let calendarFilter = UserPreferences.shared.filterUpcomingRemindersByCalendar 144 | ? self.calendarIdentifiersFilter 145 | : nil 146 | 147 | return await RemindersService.shared.getUpcomingReminders( 148 | UserPreferences.shared.upcomingRemindersInterval, 149 | for: calendarFilter 150 | ) 151 | } 152 | 153 | private func getMenuBarCount() async -> Int { 154 | let calendarFilter = UserPreferences.shared.filterMenuBarCountByCalendar 155 | ? self.calendarIdentifiersFilter 156 | : nil 157 | 158 | switch UserPreferences.shared.menuBarCounterType { 159 | case .due: 160 | return await RemindersService.shared.getUpcomingReminders(.due, for: calendarFilter).count 161 | case .today: 162 | return await RemindersService.shared.getUpcomingReminders(.today, for: calendarFilter).count 163 | case .allReminders: 164 | return await RemindersService.shared.getUpcomingReminders(.all, for: calendarFilter).count 165 | case .disabled: 166 | return -1 167 | } 168 | } 169 | 170 | private func updateMenuBarCount(with count: Int) { 171 | AppDelegate.shared.updateMenuBarTodayCount(to: count) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /reminders-menubar/Extensions/EKReminder+Extensions.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKReminder { 4 | private var maxDueDate: Date? { 5 | guard let date = dueDateComponents?.date else { 6 | return nil 7 | } 8 | 9 | if hasTime { 10 | // if the reminder has a time then it expires after its own date. 11 | return date 12 | } 13 | 14 | // if the reminder doesn’t have a time then it expires the next day. 15 | return Calendar.current.date(byAdding: .day, value: 1, to: date) 16 | } 17 | 18 | var hasDueDate: Bool { 19 | return dueDateComponents != nil 20 | } 21 | 22 | var hasTime: Bool { 23 | return dueDateComponents?.hour != nil 24 | } 25 | 26 | var ekPriority: EKReminderPriority { 27 | get { 28 | return EKReminderPriority(rawValue: UInt(self.priority)) ?? .none 29 | } 30 | set { 31 | self.priority = Int(newValue.rawValue) 32 | } 33 | } 34 | 35 | var isExpired: Bool { 36 | maxDueDate?.isPast ?? false 37 | } 38 | 39 | var relativeDateDescription: String? { 40 | guard let date = dueDateComponents?.date else { 41 | return nil 42 | } 43 | 44 | return date.relativeDateDescription(withTime: hasTime) 45 | } 46 | 47 | private var reminderBackingObject: AnyObject? { 48 | let backingObjectSelector = NSSelectorFromString("backingObject") 49 | let reminderSelector = NSSelectorFromString("_reminder") 50 | 51 | guard let unmanagedBackingObject = self.perform(backingObjectSelector), 52 | let unmanagedReminder = unmanagedBackingObject.takeUnretainedValue().perform(reminderSelector) else { 53 | return nil 54 | } 55 | 56 | return unmanagedReminder.takeUnretainedValue() 57 | } 58 | 59 | // NOTE: This is a workaround to access the URL saved in a reminder. 60 | // This property is not accessible through the conventional API. 61 | var attachedUrl: URL? { 62 | let attachmentsSelector = NSSelectorFromString("attachments") 63 | 64 | guard let unmanagedAttachments = reminderBackingObject?.perform(attachmentsSelector), 65 | let attachments = unmanagedAttachments.takeUnretainedValue() as? [AnyObject] else { 66 | return nil 67 | } 68 | 69 | for item in attachments { 70 | // NOTE: Attachments can be of type REMURLAttachment or REMImageAttachment. 71 | let attachmentType = type(of: item).description() 72 | guard attachmentType == "REMURLAttachment" else { 73 | continue 74 | } 75 | 76 | guard let unmanagedUrl = item.perform(NSSelectorFromString("url")), 77 | let url = unmanagedUrl.takeUnretainedValue() as? URL else { 78 | continue 79 | } 80 | 81 | return url 82 | } 83 | 84 | return nil 85 | } 86 | 87 | // NOTE: This is a workaround to access the mail linked to a reminder. 88 | // This property is not accessible through the conventional API. 89 | var mailUrl: URL? { 90 | let userActivitySelector = NSSelectorFromString("userActivity") 91 | let storageSelector = NSSelectorFromString("storage") 92 | 93 | guard let unmanagedUserActivity = reminderBackingObject?.perform(userActivitySelector), 94 | let unmanagedUserActivityStorage = unmanagedUserActivity.takeUnretainedValue().perform(storageSelector), 95 | let userActivityStorageData = unmanagedUserActivityStorage.takeUnretainedValue() as? Data else { 96 | return nil 97 | } 98 | 99 | // NOTE: UserActivity type is UniversalLink, so in theory it could be targeting apps other than Mail. 100 | // If it starts with "message:" then it is related to Mail. 101 | guard let userActivityStorageString = String(bytes: userActivityStorageData, encoding: .utf8), 102 | userActivityStorageString.starts(with: "message:") else { 103 | return nil 104 | } 105 | 106 | return URL(string: userActivityStorageString) 107 | } 108 | 109 | // NOTE: This is a workaround to access the parent reminder id of a reminder. 110 | // This property is not accessible through the conventional API. 111 | var parentId: String? { 112 | let parentReminderSelector = NSSelectorFromString("parentReminderID") 113 | let uuidSelector = NSSelectorFromString("uuid") 114 | 115 | guard let unmanagedParentReminder = reminderBackingObject?.perform(parentReminderSelector), 116 | let unmanagedParentReminderId = unmanagedParentReminder.takeUnretainedValue().perform(uuidSelector), 117 | let parentReminderId = unmanagedParentReminderId.takeUnretainedValue() as? UUID else { 118 | return nil 119 | } 120 | 121 | return parentReminderId.uuidString 122 | } 123 | 124 | func update(with rmbReminder: RmbReminder) { 125 | let trimmedTitle = rmbReminder.title.trimmingCharacters(in: .whitespaces) 126 | if !trimmedTitle.isEmpty { 127 | title = trimmedTitle 128 | } 129 | 130 | notes = rmbReminder.notes 131 | 132 | // NOTE: Preventing unnecessary reminder dueDate/EKAlarm overwriting. 133 | if rmbReminder.hasDateChanges { 134 | removeDueDateAndAlarms() 135 | if rmbReminder.hasDueDate { 136 | addDueDateAndAlarm(for: rmbReminder.date, withTime: rmbReminder.hasTime) 137 | } else { 138 | // NOTE: A reminder that has no due date cannot be a repeating reminder 139 | removeAllRecurrenceRules() 140 | } 141 | } 142 | 143 | ekPriority = rmbReminder.priority 144 | calendar = rmbReminder.calendar 145 | } 146 | 147 | func removeDueDateAndAlarms() { 148 | dueDateComponents = nil 149 | alarms?.forEach { alarm in 150 | removeAlarm(alarm) 151 | } 152 | } 153 | 154 | func removeAllRecurrenceRules() { 155 | recurrenceRules?.forEach { rule in 156 | removeRecurrenceRule(rule) 157 | } 158 | } 159 | 160 | func addDueDateAndAlarm(for date: Date, withTime hasTime: Bool) { 161 | let dateComponents = date.dateComponents(withTime: hasTime) 162 | dueDateComponents = dateComponents 163 | 164 | // NOTE: In Apple Reminders only reminders with time have an alarm. 165 | if hasTime { 166 | let ekAlarm = EKAlarm(absoluteDate: dateComponents.date!) 167 | addAlarm(ekAlarm) 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /reminders-menubar/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import SwiftUI 3 | import Combine 4 | 5 | @main 6 | struct RemindersMenuBar: App { 7 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 8 | 9 | var body: some Scene { 10 | Settings { 11 | EmptyView() 12 | } 13 | .commands { 14 | AppCommands() 15 | } 16 | } 17 | } 18 | 19 | @MainActor 20 | class AppDelegate: NSObject, NSApplicationDelegate { 21 | static private(set) var shared: AppDelegate! 22 | 23 | private var didCloseCancellationToken: AnyCancellable? 24 | private var didCloseEventDate = Date.distantPast 25 | 26 | private var sharedAuthorizationErrorMessage: String? 27 | 28 | let popover = NSPopover() 29 | lazy var statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 30 | 31 | var contentViewController: NSViewController { 32 | let contentView = ContentView() 33 | let remindersData = RemindersData() 34 | return NSHostingController(rootView: contentView.environmentObject(remindersData)) 35 | } 36 | 37 | func applicationDidFinishLaunching(_ aNotification: Notification) { 38 | AppDelegate.shared = self 39 | 40 | AppUpdateCheckHelper.shared.startBackgroundActivity() 41 | 42 | changeBehaviorToDismissIfNeeded() 43 | configurePopover() 44 | configureMenuBarButton() 45 | configureKeyboardShortcut() 46 | configureDidCloseNotification() 47 | } 48 | 49 | private func configurePopover() { 50 | popover.contentSize = NSSize(width: 340, height: 460) 51 | popover.animates = false 52 | 53 | if RemindersService.shared.authorizationStatus() == .authorized { 54 | popover.contentViewController = contentViewController 55 | } 56 | } 57 | 58 | func updateMenuBarTodayCount(to todayCount: Int) { 59 | let buttonTitle = todayCount > 0 ? String(todayCount) : "" 60 | statusBarItem.button?.title = buttonTitle 61 | } 62 | 63 | func loadMenuBarIcon() { 64 | let menuBarIcon = UserPreferences.shared.reminderMenuBarIcon 65 | statusBarItem.button?.image = menuBarIcon.image 66 | } 67 | 68 | private func configureMenuBarButton() { 69 | loadMenuBarIcon() 70 | statusBarItem.button?.imagePosition = .imageLeading 71 | statusBarItem.button?.action = #selector(togglePopover) 72 | } 73 | 74 | private func configureKeyboardShortcut() { 75 | KeyboardShortcutService.shared.action(for: .openRemindersMenuBar) { [weak self] in 76 | self?.togglePopover() 77 | } 78 | } 79 | 80 | private func configureDidCloseNotification() { 81 | // NOTE: There is an issue where if the menu bar button is clicked on its top part to close the popover 82 | // there will be a didClose event and then togglePopover will be called (reopening the popover). 83 | // didCloseEventDate is saved to figure out if the event is recent and the popover should not be reopened. 84 | didCloseCancellationToken = NotificationCenter.default 85 | .publisher(for: NSPopover.didCloseNotification, object: popover) 86 | .sink { [weak self] _ in 87 | self?.didCloseEventDate = Date() 88 | } 89 | } 90 | 91 | private func changeBehaviorToDismissIfNeeded() { 92 | popover.behavior = .transient 93 | } 94 | 95 | @objc private func togglePopover() { 96 | guard RemindersService.shared.authorizationStatus() == .authorized else { 97 | requestAuthorization() 98 | return 99 | } 100 | 101 | guard let button = statusBarItem.button else { 102 | return 103 | } 104 | 105 | if popover.contentViewController == nil { 106 | popover.contentViewController = contentViewController 107 | } 108 | 109 | if popover.isShown || didCloseEventDate.elapsedTimeInterval < 0.01 { 110 | didCloseEventDate = .distantPast 111 | popover.performClose(button) 112 | } else { 113 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) 114 | UserPreferences.shared.remindersMenuBarOpeningEvent.toggle() 115 | } 116 | } 117 | } 118 | 119 | // - MARK: Authorization functions 120 | 121 | extension AppDelegate: NSAlertDelegate { 122 | private func requestAuthorization() { 123 | RemindersService.shared.requestAccess { [weak self] granted, errorMessage in 124 | if granted { 125 | return 126 | } 127 | 128 | print("Access to reminders not granted:", errorMessage ?? "no error description") 129 | DispatchQueue.main.async { 130 | self?.sharedAuthorizationErrorMessage = errorMessage 131 | self?.presentNoAuthorizationAlert() 132 | } 133 | } 134 | } 135 | 136 | private func presentNoAuthorizationAlert() { 137 | let alert = NSAlert() 138 | alert.messageText = rmbLocalized(.appNoRemindersAccessAlertMessage, arguments: AppConstants.appName) 139 | let reasonDescription = rmbLocalized( 140 | .appNoRemindersAccessAlertReasonDescription, 141 | arguments: AppConstants.appName 142 | ) 143 | let actionDescription = rmbLocalized( 144 | .appNoRemindersAccessAlertActionDescription, 145 | arguments: AppConstants.appName 146 | ) 147 | alert.informativeText = "\(reasonDescription)\n\(actionDescription)" 148 | if sharedAuthorizationErrorMessage != nil { 149 | alert.delegate = self 150 | alert.showsHelp = true 151 | } 152 | 153 | alert.addButton(withTitle: rmbLocalized(.okButton)) 154 | alert.addButton(withTitle: rmbLocalized(.openSystemPreferencesButton)) 155 | alert.addButton(withTitle: rmbLocalized(.appQuitButton)).hasDestructiveAction = true 156 | 157 | NSApp.activate(ignoringOtherApps: true) 158 | let modalResponse = alert.runModal() 159 | switch modalResponse { 160 | case .alertSecondButtonReturn: 161 | if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders") { 162 | NSWorkspace.shared.open(url) 163 | } 164 | case .alertThirdButtonReturn: 165 | NSApp.terminate(self) 166 | default: 167 | sharedAuthorizationErrorMessage = nil 168 | } 169 | } 170 | 171 | internal func alertShowHelp(_ alert: NSAlert) -> Bool { 172 | let helpAlert = NSAlert() 173 | let errorDescription = sharedAuthorizationErrorMessage ?? "no error description" 174 | helpAlert.icon = NSImage(systemSymbolName: "calendar.badge.exclamationmark", accessibilityDescription: nil) 175 | helpAlert.messageText = rmbLocalized(.appNoRemindersAccessAlertMessage, arguments: AppConstants.appName) 176 | helpAlert.informativeText = "Authorization error: \(errorDescription)" 177 | helpAlert.runModal() 178 | 179 | return true 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /reminders-menubar/Services/UserPreferences.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ServiceManagement 3 | 4 | private enum PreferencesKeys { 5 | static let reminderMenuBarIcon = "reminderMenuBarIcon" 6 | static let calendarIdentifiersFilter = "calendarIdentifiersFilter" 7 | static let calendarIdentifierForSaving = "calendarIdentifierForSaving" 8 | static let autoSuggestTodayForNewReminders = "autoSuggestTodayForNewReminders" 9 | static let removeParsedDateFromTitle = "removeParsedDateFromTitle" 10 | static let showUncompletedOnly = "showUncompletedOnly" 11 | static let rmbColorScheme = "rmbColorScheme" 12 | static let backgroundIsTransparent = "backgroundIsTransparent" 13 | static let showUpcomingReminders = "showUpcomingReminders" 14 | static let upcomingRemindersInterval = "upcomingRemindersInterval" 15 | static let filterUpcomingRemindersByCalendar = "filterUpcomingRemindersByCalendar" 16 | static let menuBarCounterType = "menuBarCounterType" 17 | static let filterMenuBarCountByCalendar = "filterMenuBarCountByCalendar" 18 | static let preferredLanguage = "preferredLanguage" 19 | } 20 | 21 | class UserPreferences: ObservableObject { 22 | static private(set) var shared = UserPreferences() 23 | 24 | private init() { 25 | // This prevents others from using the default '()' initializer for this class. 26 | } 27 | 28 | private static let defaults = UserDefaults.standard 29 | 30 | @Published var remindersMenuBarOpeningEvent = false 31 | 32 | @Published var reminderMenuBarIcon: RmbIcon = { 33 | guard let menuBarIconString = defaults.string(forKey: PreferencesKeys.reminderMenuBarIcon) else { 34 | return RmbIcon.defaultIcon 35 | } 36 | return RmbIcon(rawValue: menuBarIconString) ?? RmbIcon.defaultIcon 37 | }() { 38 | didSet { 39 | UserPreferences.defaults.set(reminderMenuBarIcon.rawValue, forKey: PreferencesKeys.reminderMenuBarIcon) 40 | } 41 | } 42 | 43 | var preferredCalendarIdentifiersFilter: [String]? { 44 | get { 45 | return UserPreferences.defaults.stringArray(forKey: PreferencesKeys.calendarIdentifiersFilter) 46 | } 47 | set { 48 | UserPreferences.defaults.set(newValue, forKey: PreferencesKeys.calendarIdentifiersFilter) 49 | } 50 | } 51 | 52 | var preferredCalendarIdentifierForSaving: String? { 53 | get { 54 | return UserPreferences.defaults.string(forKey: PreferencesKeys.calendarIdentifierForSaving) 55 | } 56 | set { 57 | UserPreferences.defaults.set(newValue, forKey: PreferencesKeys.calendarIdentifierForSaving) 58 | } 59 | } 60 | 61 | @Published var autoSuggestToday: Bool = { 62 | return defaults.bool(forKey: PreferencesKeys.autoSuggestTodayForNewReminders) 63 | }() { 64 | didSet { 65 | UserPreferences.defaults.set(autoSuggestToday, forKey: PreferencesKeys.autoSuggestTodayForNewReminders) 66 | } 67 | } 68 | 69 | @Published var removeParsedDateFromTitle: Bool = { 70 | return defaults.boolWithDefaultValueTrue(forKey: PreferencesKeys.removeParsedDateFromTitle) 71 | }() { 72 | didSet { 73 | UserPreferences.defaults.set(removeParsedDateFromTitle, forKey: PreferencesKeys.removeParsedDateFromTitle) 74 | } 75 | } 76 | 77 | @Published var showUncompletedOnly: Bool = { 78 | return defaults.boolWithDefaultValueTrue(forKey: PreferencesKeys.showUncompletedOnly) 79 | }() { 80 | didSet { 81 | UserPreferences.defaults.set(showUncompletedOnly, forKey: PreferencesKeys.showUncompletedOnly) 82 | } 83 | } 84 | 85 | @Published var upcomingRemindersInterval: ReminderInterval = { 86 | guard let intervalData = defaults.data(forKey: PreferencesKeys.upcomingRemindersInterval), 87 | let interval = try? JSONDecoder().decode(ReminderInterval.self, from: intervalData) else { 88 | return .today 89 | } 90 | return interval 91 | }() { 92 | didSet { 93 | let intervalData = try? JSONEncoder().encode(upcomingRemindersInterval) 94 | UserPreferences.defaults.set(intervalData, forKey: PreferencesKeys.upcomingRemindersInterval) 95 | } 96 | } 97 | 98 | @Published var filterUpcomingRemindersByCalendar: Bool = { 99 | return defaults.bool(forKey: PreferencesKeys.filterUpcomingRemindersByCalendar) 100 | }() { 101 | didSet { 102 | UserPreferences.defaults.set( 103 | filterUpcomingRemindersByCalendar, 104 | forKey: PreferencesKeys.filterUpcomingRemindersByCalendar 105 | ) 106 | } 107 | } 108 | 109 | @Published var showUpcomingReminders: Bool = { 110 | return defaults.boolWithDefaultValueTrue(forKey: PreferencesKeys.showUpcomingReminders) 111 | }() { 112 | didSet { 113 | UserPreferences.defaults.set(showUpcomingReminders, forKey: PreferencesKeys.showUpcomingReminders) 114 | } 115 | } 116 | 117 | var atLeastOneFilterIsSelected: Bool { 118 | return 119 | showUpcomingReminders || 120 | preferredCalendarIdentifiersFilter == nil || 121 | !(preferredCalendarIdentifiersFilter ?? []).isEmpty 122 | } 123 | 124 | var launchAtLoginIsEnabled: Bool { 125 | get { 126 | let allJobs = SMCopyAllJobDictionaries(kSMDomainUserLaunchd).takeRetainedValue() as? [[String: AnyObject]] 127 | let launcherJob = allJobs?.first { $0["Label"] as? String == AppConstants.launcherBundleId } 128 | return launcherJob?["OnDemand"] as? Bool ?? false 129 | } 130 | 131 | set { 132 | SMLoginItemSetEnabled(AppConstants.launcherBundleId as CFString, newValue) 133 | } 134 | } 135 | 136 | @Published var rmbColorScheme: RmbColorScheme = { 137 | guard let rmbColorSchemeString = defaults.string(forKey: PreferencesKeys.rmbColorScheme) else { 138 | return .system 139 | } 140 | return RmbColorScheme(rawValue: rmbColorSchemeString) ?? .system 141 | }() { 142 | didSet { 143 | UserPreferences.defaults.set(rmbColorScheme.rawValue, forKey: PreferencesKeys.rmbColorScheme) 144 | } 145 | } 146 | 147 | @Published var backgroundIsTransparent: Bool = { 148 | return defaults.boolWithDefaultValueTrue(forKey: PreferencesKeys.backgroundIsTransparent) 149 | }() { 150 | didSet { 151 | UserPreferences.defaults.set(backgroundIsTransparent, forKey: PreferencesKeys.backgroundIsTransparent) 152 | } 153 | } 154 | 155 | @Published var menuBarCounterType: RmbMenuBarCounterType = { 156 | guard let counterTypeData = defaults.data(forKey: PreferencesKeys.menuBarCounterType), 157 | let counterType = try? JSONDecoder().decode(RmbMenuBarCounterType.self, from: counterTypeData) else { 158 | return .today 159 | } 160 | return counterType 161 | }() { 162 | didSet { 163 | let counterTypeData = try? JSONEncoder().encode(menuBarCounterType) 164 | UserPreferences.defaults.set(counterTypeData, forKey: PreferencesKeys.menuBarCounterType) 165 | } 166 | } 167 | 168 | @Published var filterMenuBarCountByCalendar: Bool = { 169 | return defaults.bool(forKey: PreferencesKeys.filterMenuBarCountByCalendar) 170 | }() { 171 | didSet { 172 | UserPreferences.defaults.set( 173 | filterMenuBarCountByCalendar, 174 | forKey: PreferencesKeys.filterMenuBarCountByCalendar 175 | ) 176 | } 177 | } 178 | 179 | @Published var preferredLanguage: String? = { 180 | return defaults.string(forKey: PreferencesKeys.preferredLanguage) 181 | }() { 182 | didSet { 183 | UserPreferences.defaults.set(preferredLanguage, forKey: PreferencesKeys.preferredLanguage) 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /reminders-menubar/Views/SettingsBarView/SettingsBarGearMenu.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsBarGearMenu: View { 4 | @EnvironmentObject var remindersData: RemindersData 5 | @ObservedObject var userPreferences = UserPreferences.shared 6 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 7 | 8 | @State var gearIsHovered = false 9 | 10 | @ObservedObject var appUpdateCheckHelper = AppUpdateCheckHelper.shared 11 | @ObservedObject var keyboardShortcutService = KeyboardShortcutService.shared 12 | 13 | var body: some View { 14 | Menu { 15 | VStack { 16 | if appUpdateCheckHelper.isOutdated { 17 | Button(action: { 18 | if let url = URL(string: GithubConstants.latestReleasePage) { 19 | NSWorkspace.shared.open(url) 20 | } 21 | }) { 22 | Image(systemName: "exclamationmark.circle") 23 | Text(rmbLocalized(.updateAvailableNoticeButton)) 24 | } 25 | 26 | Divider() 27 | } 28 | 29 | Button(action: { 30 | userPreferences.launchAtLoginIsEnabled.toggle() 31 | }) { 32 | let isSelected = userPreferences.launchAtLoginIsEnabled 33 | SelectableView( 34 | title: rmbLocalized(.launchAtLoginOptionButton), 35 | isSelected: isSelected, 36 | withPadding: false 37 | ) 38 | } 39 | 40 | visualCustomizationOptions() 41 | 42 | Button { 43 | KeyboardShortcutView.showWindow() 44 | } label: { 45 | let activeShortcut = keyboardShortcutService.activeShortcut(for: .openRemindersMenuBar) 46 | let activeShortcutText = Text(verbatim: " \(activeShortcut)").foregroundColor(.gray) 47 | Text(rmbLocalized(.keyboardShortcutOptionButton)) + activeShortcutText 48 | } 49 | 50 | Divider() 51 | 52 | Button(action: { 53 | Task { 54 | await remindersData.update() 55 | } 56 | }) { 57 | Text(rmbLocalized(.reloadRemindersDataButton)) 58 | } 59 | 60 | Divider() 61 | 62 | Button(action: { 63 | AboutView.showWindow() 64 | }) { 65 | Text(rmbLocalized(.appAboutButton)) 66 | } 67 | 68 | Button(action: { 69 | NSApplication.shared.terminate(self) 70 | }) { 71 | Text(rmbLocalized(.appQuitButton)) 72 | } 73 | } 74 | } label: { 75 | Image(systemName: appUpdateCheckHelper.isOutdated ? "exclamationmark.circle" : "gear") 76 | } 77 | .menuStyle(BorderlessButtonMenuStyle()) 78 | .frame(width: 32, height: 16) 79 | .padding(3) 80 | .background(gearIsHovered ? Color.rmbColor(for: .buttonHover, and: colorSchemeContrast) : nil) 81 | .cornerRadius(4) 82 | .onHover { isHovered in 83 | gearIsHovered = isHovered 84 | } 85 | .help(rmbLocalized(.settingsButtonHelp)) 86 | } 87 | 88 | @ViewBuilder 89 | func visualCustomizationOptions() -> some View { 90 | Divider() 91 | 92 | appAppearanceMenu() 93 | 94 | menuBarIconMenu() 95 | 96 | menuBarCounterMenu() 97 | 98 | preferredLanguageMenu() 99 | 100 | Divider() 101 | } 102 | 103 | func appAppearanceMenu() -> some View { 104 | Menu { 105 | ForEach(RmbColorScheme.allCases, id: \.rawValue) { colorScheme in 106 | Button(action: { userPreferences.rmbColorScheme = colorScheme }) { 107 | let isSelected = colorScheme == userPreferences.rmbColorScheme 108 | SelectableView(title: colorScheme.title, isSelected: isSelected) 109 | } 110 | } 111 | 112 | Divider() 113 | 114 | let isIncreasedContrastEnabled = colorSchemeContrast == .increased 115 | let isTransparencyEnabled = userPreferences.backgroundIsTransparent && !isIncreasedContrastEnabled 116 | 117 | Button(action: { 118 | userPreferences.backgroundIsTransparent = false 119 | }) { 120 | let isSelected = !isTransparencyEnabled 121 | SelectableView( 122 | title: rmbLocalized(.appAppearanceMoreOpaqueOptionButton), 123 | isSelected: isSelected 124 | ) 125 | } 126 | .disabled(isIncreasedContrastEnabled) 127 | 128 | Button(action: { 129 | userPreferences.backgroundIsTransparent = true 130 | }) { 131 | let isSelected = isTransparencyEnabled 132 | SelectableView( 133 | title: rmbLocalized(.appAppearanceMoreTransparentOptionButton), 134 | isSelected: isSelected 135 | ) 136 | } 137 | .disabled(isIncreasedContrastEnabled) 138 | } label: { 139 | Text(rmbLocalized(.appAppearanceMenu)) 140 | } 141 | } 142 | 143 | func menuBarIconMenu() -> some View { 144 | Menu { 145 | ForEach(RmbIcon.allCases, id: \.self) { icon in 146 | Button(action: { 147 | userPreferences.reminderMenuBarIcon = icon 148 | AppDelegate.shared.loadMenuBarIcon() 149 | }) { 150 | Image(nsImage: icon.image) 151 | Text(icon.name) 152 | } 153 | } 154 | } label: { 155 | Text(rmbLocalized(.menuBarIconSettingsMenu)) 156 | } 157 | } 158 | 159 | func menuBarCounterMenu() -> some View { 160 | Menu { 161 | ForEach(RmbMenuBarCounterType.allCases, id: \.rawValue) { counterType in 162 | Button(action: { userPreferences.menuBarCounterType = counterType }) { 163 | let isSelected = counterType == userPreferences.menuBarCounterType 164 | SelectableView(title: counterType.title, isSelected: isSelected) 165 | } 166 | } 167 | 168 | Divider() 169 | 170 | Button(action: { 171 | userPreferences.filterMenuBarCountByCalendar.toggle() 172 | }) { 173 | SelectableView( 174 | title: rmbLocalized(.filterMenuBarCountByCalendarOptionButton), 175 | isSelected: userPreferences.filterMenuBarCountByCalendar 176 | ) 177 | } 178 | } label: { 179 | Text(rmbLocalized(.menuBarCounterSettingsMenu)) 180 | } 181 | } 182 | 183 | func preferredLanguageMenu() -> some View { 184 | Menu { 185 | Button(action: { 186 | userPreferences.preferredLanguage = nil 187 | }) { 188 | let isSelected = userPreferences.preferredLanguage == nil 189 | SelectableView( 190 | title: rmbLocalized(.preferredLanguageSystemOptionButton), 191 | isSelected: isSelected 192 | ) 193 | } 194 | 195 | Divider() 196 | 197 | ForEach(rmbAvailableLocales(), id: \.identifier) { locale in 198 | let localeIdentifier = locale.identifier 199 | Button(action: { 200 | userPreferences.preferredLanguage = localeIdentifier 201 | }) { 202 | let isSelected = userPreferences.preferredLanguage == localeIdentifier 203 | SelectableView(title: locale.name, isSelected: isSelected) 204 | } 205 | } 206 | } label: { 207 | Text(rmbLocalized(.preferredLanguageMenu)) 208 | } 209 | } 210 | } 211 | 212 | struct SettingsBarGearMenu_Previews: PreviewProvider { 213 | static var previews: some View { 214 | SettingsBarGearMenu() 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /reminders-menubar/Views/FormNewReminderView/FormNewReminderView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import EventKit 3 | 4 | struct FormNewReminderView: View { 5 | @EnvironmentObject var remindersData: RemindersData 6 | @ObservedObject var userPreferences = UserPreferences.shared 7 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 8 | 9 | @State var rmbReminder = RmbReminder() 10 | @State var isShowingInfoOptions = false 11 | 12 | @State var textFieldFocusTrigger = UUID() 13 | @State var textFieldDynamicHeight: CGFloat = 0 14 | 15 | var body: some View { 16 | let calendarForSaving = getCalendarForSaving() 17 | // swiftlint:disable:next redundant_discardable_let 18 | let _ = CalendarParser.updateShared(with: remindersData.calendars) 19 | 20 | Form { 21 | HStack(alignment: .top) { 22 | newReminderTextFieldView() 23 | .padding(.vertical, 8) 24 | .padding(.horizontal, 8) 25 | .padding(.leading, 22) 26 | .background(Color.rmbColor(for: .textFieldBackground, and: colorSchemeContrast)) 27 | .cornerRadius(8) 28 | .textFieldStyle(PlainTextFieldStyle()) 29 | .modifier(ContrastBorderOverlay()) 30 | .overlay( 31 | Image(systemName: "plus.circle.fill") 32 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 33 | .foregroundColor(.gray) 34 | .padding([.top, .leading], 8) 35 | ) 36 | 37 | Menu { 38 | ForEach(remindersData.calendars, id: \.calendarIdentifier) { calendar in 39 | Button(action: { 40 | remindersData.calendarForSaving = calendar 41 | let rmbCalendarIdentifier = rmbReminder.textCalendarResult.calendar?.calendarIdentifier 42 | if calendar.calendarIdentifier != rmbCalendarIdentifier { 43 | // NOTE: Clear textCalendarResult because user overwrote the calendar for saving. 44 | rmbReminder.textCalendarResult = CalendarParser.TextCalendarResult() 45 | } 46 | }) { 47 | let isSelected = calendarForSaving?.calendarIdentifier == calendar.calendarIdentifier 48 | SelectableView(title: calendar.title, isSelected: isSelected, color: Color(calendar.color)) 49 | } 50 | } 51 | 52 | Divider() 53 | 54 | Button(action: { 55 | userPreferences.autoSuggestToday.toggle() 56 | if rmbReminder.title.isEmpty { 57 | rmbReminder = newRmbReminder() 58 | } 59 | }) { 60 | let isSelected = userPreferences.autoSuggestToday 61 | SelectableView( 62 | title: rmbLocalized(.newReminderAutoSuggestTodayOption), 63 | isSelected: isSelected 64 | ) 65 | } 66 | 67 | Button(action: { userPreferences.removeParsedDateFromTitle.toggle() }) { 68 | let isSelected = userPreferences.removeParsedDateFromTitle 69 | SelectableView( 70 | title: rmbLocalized(.newReminderRemoveParsedDateOption), 71 | isSelected: isSelected 72 | ) 73 | } 74 | } label: { 75 | } 76 | .menuStyle(BorderlessButtonMenuStyle()) 77 | .frame(width: 14, height: 16) 78 | .modifier(CenteredMenuPadding()) 79 | .background(Color(calendarForSaving?.color ?? .white)) 80 | .cornerRadius(8) 81 | .modifier(ContrastBorderOverlay()) 82 | .help(rmbLocalized(.newReminderCalendarSelectionToSaveHelp)) 83 | } 84 | } 85 | .padding(10) 86 | .onChange(of: rmbReminder.title) { [oldValue = rmbReminder.title] newValue in 87 | withAnimation(.easeOut(duration: 0.3)) { 88 | isShowingInfoOptions = !newValue.isEmpty 89 | } 90 | 91 | // NOTE: When user starts to enter a title we update the suggested date to ensure it is as expected. 92 | if oldValue.isEmpty { 93 | rmbReminder.updateSuggestedDate() 94 | } 95 | } 96 | .onAppear { 97 | rmbReminder = newRmbReminder() 98 | } 99 | } 100 | 101 | @ViewBuilder 102 | func newReminderTextFieldView() -> some View { 103 | VStack(alignment: .leading) { 104 | RmbHighlightedTextField( 105 | placeholder: rmbLocalized(.newReminderTextFielPlaceholder), 106 | text: $rmbReminder.title, 107 | highlightedTexts: rmbReminder.highlightedTexts, 108 | textContainerDynamicHeight: $textFieldDynamicHeight, 109 | focusTrigger: $textFieldFocusTrigger, 110 | ) 111 | .onSubmit { 112 | createNewReminder() 113 | } 114 | .autoComplete( 115 | isInitialCharValid: CalendarParser.isInitialCharValid(_:), 116 | suggestions: CalendarParser.autoCompleteSuggestions(_:) 117 | ) 118 | .onChange(of: userPreferences.remindersMenuBarOpeningEvent) { _ in 119 | textFieldFocusTrigger = UUID() 120 | } 121 | .frame(height: textFieldDynamicHeight) 122 | 123 | if isShowingInfoOptions { 124 | NewReminderInfoOptionsView( 125 | date: $rmbReminder.date, 126 | hasDueDate: $rmbReminder.hasDueDate, 127 | hasTime: $rmbReminder.hasTime, 128 | priority: $rmbReminder.priority 129 | ) 130 | } 131 | } 132 | } 133 | 134 | private func newRmbReminder() -> RmbReminder { 135 | var rmbReminder = RmbReminder() 136 | if userPreferences.autoSuggestToday { 137 | rmbReminder.setIsAutoSuggestingTodayForCreation() 138 | } 139 | return rmbReminder 140 | } 141 | 142 | private func getCalendarForSaving() -> EKCalendar? { 143 | return rmbReminder.textCalendarResult.calendar ?? remindersData.calendarForSaving 144 | } 145 | 146 | private func createNewReminder() { 147 | let newReminderTitle = finalNewReminderTitle() 148 | guard !newReminderTitle.isEmpty, 149 | let calendarForSaving = getCalendarForSaving() else { 150 | return 151 | } 152 | 153 | rmbReminder.prepareToSave() 154 | rmbReminder.title = newReminderTitle 155 | 156 | RemindersService.shared.createNew(with: rmbReminder, in: calendarForSaving) 157 | rmbReminder = newRmbReminder() 158 | } 159 | 160 | private func finalNewReminderTitle() -> String { 161 | var title = rmbReminder.title 162 | if let parsedPriorityRange = Range(rmbReminder.textPriorityResult.highlightedText.range, in: title) { 163 | // Removing priorityText first using the detected Range 164 | // since there may be different exclamation marks in the title. 165 | title.replaceSubrange(parsedPriorityRange, with: "") 166 | } 167 | if userPreferences.removeParsedDateFromTitle { 168 | title = title.replacingOccurrences(of: rmbReminder.textDateResult.string, with: "") 169 | } 170 | title = title.replacingOccurrences(of: rmbReminder.textCalendarResult.string, with: "") 171 | 172 | return title.trimmingCharacters(in: .whitespaces) 173 | } 174 | } 175 | 176 | struct CenteredMenuPadding: ViewModifier { 177 | func body(content: Content) -> some View { 178 | if #available(macOS 14.0, *) { 179 | content 180 | .padding(.vertical, 8) 181 | .padding(.leading, 11) 182 | .padding(.trailing, 6) 183 | } else { 184 | content 185 | .padding(8) 186 | .padding(.trailing, 2) 187 | } 188 | } 189 | } 190 | 191 | struct ContrastBorderOverlay: ViewModifier { 192 | @Environment(\.colorSchemeContrast) private var colorSchemeContrast 193 | private var isEnabled: Bool { colorSchemeContrast == .increased } 194 | 195 | func body(content: Content) -> some View { 196 | return content 197 | .overlay( 198 | isEnabled 199 | ? RoundedRectangle(cornerRadius: 8) 200 | .strokeBorder(style: StrokeStyle(lineWidth: 1)) 201 | .foregroundColor(Color.rmbColor(for: .borderContrast, and: colorSchemeContrast)) 202 | : nil 203 | ) 204 | } 205 | } 206 | 207 | #Preview { 208 | FormNewReminderView() 209 | .environmentObject(RemindersData()) 210 | } 211 | --------------------------------------------------------------------------------