Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions MeetingBar/Core/Models/MBEvent+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,39 @@ public extension Array where Element == MBEvent {
return result
}

/// When there is an ongoing event and a subsequent event starts within
/// `gapThreshold` seconds of the current event's end, returns both.
/// Returns `(current, upcoming)` — either or both may be nil.
func currentAndUpcomingEvent(gapThreshold: TimeInterval = 900) -> (current: MBEvent?, upcoming: MBEvent?) {
let now = Date()

// Find the ongoing event (started but not ended)
let current = first(where: {
!$0.isAllDay
&& $0.startDate <= now
&& $0.endDate > now
&& $0.participationStatus != .declined
&& $0.status != .canceled
})

guard let current = current else {
return (nil, nil)
}

// Find the next event that starts after now but within
// `gapThreshold` seconds of the current event's end.
let upcoming = first(where: {
!$0.isAllDay
&& $0.id != current.id
&& $0.startDate > now
&& $0.startDate <= current.endDate.addingTimeInterval(gapThreshold)
&& $0.participationStatus != .declined
&& $0.status != .canceled
})

return (current, upcoming)
}

/// From a pre-filtered, sorted array, find the nearest upcoming MBEvent.
func nextEvent(linkRequired: Bool = false) -> MBEvent? {
var nextEvent: MBEvent?
Expand Down
4 changes: 4 additions & 0 deletions MeetingBar/Extensions/DefaultsKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ extension Defaults.Keys {

static let ongoingEventVisibility = Key<OngoingEventVisibility>("ongoingEventVisibility", default: .showTenMinBeforeNext)

// Next Event Flip (alternating current/upcoming in status bar)
static let nextEventFlipMode = Key<NextEventFlipMode>("nextEventFlipMode", default: .showAfterTenMin)
static let nextEventFlipIntervalSeconds = Key<Int>("nextEventFlipIntervalSeconds", default: 5)

// Menu Appearance
static let showTimelineInMenu = Key<Bool>("showTimelineInMenu", default: true)
// if the event title in the menu should be shortened or not -> the length will be stored in field menuEventTitleLength
Expand Down
292 changes: 214 additions & 78 deletions MeetingBar/UI/StatusBar/StatusBarItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@

private var cancellables = Set<AnyCancellable>()

// MARK: - Flip timer for current/upcoming event alternation

/// When true, the status bar shows the upcoming event; otherwise the current event.
private var showingUpcomingEvent = false

/// Task that drives the flip between current and upcoming event.
private var flipTask: Task<Void, Never>?

init() {
statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.variableLength
Expand Down Expand Up @@ -166,6 +174,211 @@
}

func updateTitle() {
let flipMode = Defaults[.nextEventFlipMode]

// Flip feature requires ongoing event visibility to be "hide 10 min before next"
guard flipMode != .disabled,
Defaults[.ongoingEventVisibility] == .showTenMinBeforeNext else {
stopFlipTimer()
renderNormalTitle()
return
}

let pair = events.currentAndUpcomingEvent()
if let current = pair.current, let upcoming = pair.upcoming {
let now = Date()
let minutesIntoCurrent = now.timeIntervalSince(current.startDate) / 60

let minMinutesIn: Double = (flipMode == .showAfterTenMin) ? 10.0 : 0.0

// Only start flipping when the current meeting is far enough in.
guard minutesIntoCurrent >= minMinutesIn else {
stopFlipTimer()
renderNormalTitle()
return
}

// We have both a current and upcoming event — start the flip timer
// and render whichever side we're currently showing.
startFlipTimer()
if showingUpcomingEvent {
renderUpcomingEvent(upcoming, whileCurrent: current)
} else {
renderEvent(current)
}
return
}

// No flip scenario — stop the timer and render normally
stopFlipTimer()
renderNormalTitle()
}

// MARK: - Flip timer management

private func startFlipTimer() {
// Don't start a new timer if one is already running
guard flipTask == nil else { return }
flipTask = Task { [weak self] in
while let self, !Task.isCancelled {
let interval = TimeInterval(Defaults[.nextEventFlipIntervalSeconds])
try? await Task.sleep(nanoseconds: UInt64(interval * Double(NSEC_PER_SEC)))
guard !Task.isCancelled else { break }
await MainActor.run {
self.showingUpcomingEvent.toggle()
self.updateTitle()
}
}
}
}

private func stopFlipTimer() {
flipTask?.cancel()
flipTask = nil
showingUpcomingEvent = false
}

// MARK: - Render the "upcoming event" side of the flip

private func renderUpcomingEvent(_ upcoming: MBEvent, whileCurrent current: MBEvent) {
guard let button = statusItem.button else { return }
button.image = nil
button.title = ""

let (upcomingTitle, upcomingTime) = createEventStatusString(
title: upcoming.title,
startDate: upcoming.startDate,
endDate: upcoming.endDate
)

// Set the icon
if Defaults[.eventTitleIconFormat] != .none {
let image: NSImage
if Defaults[.eventTitleIconFormat] == .eventtype {
image = getIconForMeetingService(upcoming.meetingLink?.service)
} else {
image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)!
}
button.image = image
button.image?.size = MenuStyleConstants.iconSize
}

if button.image?.name() == "no_online_session" {
button.imagePosition = .noImage
} else {
button.imagePosition = .imageLeft
}

let menuTitle = NSMutableAttributedString()

// Bold "Next: " prefix
let boldFont = NSFont.boldSystemFont(ofSize: MenuStyleConstants.defaultFontSize)
let regularFont = NSFont.systemFont(ofSize: MenuStyleConstants.defaultFontSize)

menuTitle.append(NSAttributedString(
string: "Next: ",
attributes: [.font: boldFont]
))

var eventText = upcomingTitle
if Defaults[.eventTimeFormat] == .show {
eventText += " " + upcomingTime
}

menuTitle.append(NSAttributedString(
string: eventText,
attributes: [.font: regularFont]
))

button.attributedTitle = menuTitle
button.toolTip = "Now: \(current.title)\nNext: \(upcoming.title)"
Comment on lines +278 to +294
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded UI strings should be localized.

The "Next: " prefix (line 279) and tooltip format "Now: ... / Next: ..." (line 294) are hardcoded strings that won't be translated for non-English users.

🌐 Example fix
         menuTitle.append(NSAttributedString(
-            string: "Next: ",
+            string: "status_bar_flip_next_prefix".loco(),
             attributes: [.font: boldFont]
         ))
         ...
-        button.toolTip = "Now: \(current.title)\nNext: \(upcoming.title)"
+        button.toolTip = "status_bar_flip_tooltip".loco(current.title, upcoming.title)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/UI/StatusBar/StatusBarItemController.swift` around lines 278 -
294, The hardcoded UI strings ("Next: " and the tooltip "Now: ...\nNext: ...")
must be localized: replace the literal "Next: " used when appending to menuTitle
with a localized string (e.g. NSLocalizedString key like "StatusBar.NextPrefix")
and build eventText as before; and replace the literal tooltip construction with
a localized format string (e.g. NSLocalizedString key like
"StatusBar.Tooltip.NowNext" = "Now: %@\nNext: %@") and call
String(format:localizedTooltip, current.title, upcoming.title). Update usages
around menuTitle, button.attributedTitle and button.toolTip (and keep
Default[.eventTimeFormat] logic and attributes) so all user-facing text is
loaded via NSLocalizedString keys with appropriate placeholders for
upcomingTitle/upcomingTime/current.title/upcoming.title.

}

// MARK: - Normal title rendering (original logic)

private func renderEvent(_ event: MBEvent) {
guard let button = statusItem.button else { return }
button.image = nil
button.title = ""
button.toolTip = nil

let (title, time) = createEventStatusString(
title: event.title,
startDate: event.startDate,
endDate: event.endDate
)

if Defaults[.eventTitleIconFormat] != .none {
let image: NSImage
if Defaults[.eventTitleIconFormat] == .eventtype {
image = getIconForMeetingService(event.meetingLink?.service)
} else {
image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)!
}
button.image = image
button.image?.size = MenuStyleConstants.iconSize
}

if button.image?.name() == "no_online_session" {
button.imagePosition = .noImage
} else {
button.imagePosition = .imageLeft
}

let menuTitle = NSMutableAttributedString()

if Defaults[.eventTimeFormat] != .show_under_title || Defaults[.eventTitleFormat] == .none {
var eventTitle = title
if Defaults[.eventTimeFormat] == .show {
eventTitle += " " + time
}

var styles = [NSAttributedString.Key: Any]()
styles[.font] = NSFont.systemFont(ofSize: MenuStyleConstants.defaultFontSize)

if event.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined {
styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}
if event.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined {
styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}

menuTitle.append(NSAttributedString(string: eventTitle, attributes: styles))
} else {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = 0.7
paragraphStyle.alignment = .center

var styles = [NSAttributedString.Key: Any]()
styles[.font] = NSFont.systemFont(ofSize: 12)
styles[.baselineOffset] = -3

if event.participationStatus == .pending, Defaults[.showPendingEvents] == .show_inactive {
styles[.foregroundColor] = NSColor.disabledControlTextColor
} else if event.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined {
styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}
if event.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_inactive {
styles[.foregroundColor] = NSColor.disabledControlTextColor
} else if event.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined {
styles[.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}

menuTitle.append(NSAttributedString(string: title, attributes: styles))

let timeAttributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 9),
.foregroundColor: NSColor.lightGray
]
menuTitle.append(NSAttributedString(string: "\n" + time, attributes: timeAttributes))
menuTitle.addAttributes([.paragraphStyle: paragraphStyle], range: NSRange(location: 0, length: menuTitle.length))
}

button.attributedTitle = menuTitle
button.toolTip = event.title
}

private func renderNormalTitle() {

Check warning

Code scanning / Swiftlint (reported by Codacy)

Function should have complexity 15 or less: currently complexity equals 18 Warning

Function should have complexity 15 or less: currently complexity equals 18
var title = "MeetingBar"
var time = ""
var nextEvent: MBEvent!
Expand All @@ -179,8 +392,6 @@
guard Defaults[.showEventMaxTimeUntilEventEnabled] else {
return .nextEvent(nextEvent)
}
// Positive, if in the future. Negative, if already started.
// Current or past events therefore don't get ignored.
let timeUntilStart = nextEvent.startDate.timeIntervalSinceNow
let thresholdInSeconds = TimeInterval(Defaults[.showEventMaxTimeUntilEventThreshold] * 60)
return timeUntilStart < thresholdInSeconds ? .nextEvent(nextEvent) : .afterThreshold(nextEvent)
Expand All @@ -200,7 +411,6 @@
}
}
case let .afterThreshold(event):
// Not sure, what the title should be in this case.
title = "⏰"
if Defaults[.joinEventNotification] {
Task {
Expand Down Expand Up @@ -236,81 +446,7 @@
}

if button.image == nil {
if Defaults[.eventTitleIconFormat] != .none {
let image: NSImage
if Defaults[.eventTitleIconFormat] == .eventtype {
image = getIconForMeetingService(nextEvent.meetingLink?.service)
} else {
image = NSImage(named: Defaults[.eventTitleIconFormat].rawValue)!
}

button.image = image
button.image?.size = MenuStyleConstants.iconSize
}

if button.image?.name() == "no_online_session" {
button.imagePosition = .noImage
} else {
button.imagePosition = .imageLeft
}

// create an NSMutableAttributedString that we'll append everything to
let menuTitle = NSMutableAttributedString()

if Defaults[.eventTimeFormat] != .show_under_title || Defaults[.eventTitleFormat] == .none {
var eventTitle = title
if Defaults[.eventTimeFormat] == .show {
eventTitle += " " + time
}

var styles = [NSAttributedString.Key: Any]()
styles[NSAttributedString.Key.font] = NSFont.systemFont(ofSize: MenuStyleConstants.defaultFontSize)

if nextEvent.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined {
styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}

if nextEvent.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined {
styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}

menuTitle.append(NSAttributedString(string: eventTitle, attributes: styles))
} else {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = 0.7
paragraphStyle.alignment = .center

var styles = [NSAttributedString.Key: Any]()
styles[NSAttributedString.Key.font] = NSFont.systemFont(ofSize: 12)
styles[NSAttributedString.Key.baselineOffset] = -3

if nextEvent.participationStatus == .pending, Defaults[.showPendingEvents] == .show_inactive {
styles[NSAttributedString.Key.foregroundColor] = NSColor.disabledControlTextColor
} else if nextEvent.participationStatus == .pending, Defaults[.showPendingEvents] == .show_underlined {
styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}

if nextEvent.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_inactive {
styles[NSAttributedString.Key.foregroundColor] = NSColor.disabledControlTextColor
} else if nextEvent.participationStatus == .tentative, Defaults[.showTentativeEvents] == .show_underlined {
styles[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue | NSUnderlineStyle.patternDot.rawValue | NSUnderlineStyle.byWord.rawValue
}

menuTitle.append(NSAttributedString(string: title, attributes: styles))

let timeAttributes = [
NSAttributedString.Key.font: NSFont.systemFont(ofSize: 9),
NSAttributedString.Key.foregroundColor: NSColor.lightGray
]
menuTitle.append(NSAttributedString(string: "\n" + time, attributes: timeAttributes))

menuTitle.addAttributes([NSAttributedString.Key.paragraphStyle: paragraphStyle], range: NSRange(location: 0, length: menuTitle.length))
}

button.attributedTitle = menuTitle
if nextEvent != nil {
button.toolTip = nextEvent.title
}
renderEvent(nextEvent)
}
}
}
Expand Down
Loading
Loading