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
668 changes: 461 additions & 207 deletions MeetingBar/Core/EventStores/GCEventStore.swift

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions MeetingBar/Core/Models/GoogleAccount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// GoogleAccount.swift
// MeetingBar
//
// Created for multi-account Google Calendar support.
// Copyright © 2026 Andrii Leitsius. All rights reserved.
//

import Defaults
import Foundation

public struct GoogleAccount: Identifiable, Codable, Hashable, Sendable, Defaults.Serializable {
public let id: String
public let email: String

public init(id: String, email: String) {
self.id = id
self.email = email
}
}
2 changes: 2 additions & 0 deletions MeetingBar/Extensions/DefaultsKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ extension Defaults.Keys {
static let selectedCalendarIDs = Key<[String]>("selectedCalendarIDs", default: [])
static let eventStoreProvider = Key<EventStoreProvider>("eventStoreProvider", default: .macOSEventKit)

static let googleAccounts = Key<[GoogleAccount]>("googleAccounts", default: [])

static let onboardingCompleted = Key<Bool>("onboardingCompleted", default: false)

static let showEventsForPeriod = Key<ShowEventsForPeriod>("showEventsForPeriod", default: .today)
Expand Down
26 changes: 26 additions & 0 deletions MeetingBar/Resources /Localization /en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,29 @@

"link_url_cant_open_title" = "Oops! Unable to open the link in %@";
"link_url_cant_open_message" = "Ensure you have %@ installed, or open such links in a web browser from the preferences instead.";

// MARK: - Multi-account Google Calendar

"preferences_calendars_google_accounts_title" = "Google Accounts";
"preferences_calendars_no_accounts_connected" = "No accounts connected. Add a Google account to get started.";
"preferences_calendars_no_calendars_available" = "No calendars available. Try refreshing or check account access.";
"preferences_calendars_refresh" = "Refresh";
"preferences_calendars_no_google_accounts_connected" = "No Google accounts connected";
"preferences_calendars_account_connected" = "Connected";
"preferences_calendars_remove_account_help" = "Remove %@";
"preferences_calendars_remove_account_label" = "Remove Google account %@";
"preferences_calendars_add_google_account" = "Add Google Account";
"preferences_calendars_remove_account_dialog_title" = "Remove Account";
"preferences_calendars_remove_account_action" = "Remove %@";
"preferences_calendars_cancel" = "Cancel";
"preferences_calendars_remove_account_message" = "This will remove %@ and all its calendars from MeetingBar. You can add it back later.";
"preferences_calendars_add_google_account_sheet_title" = "Add Google Account";
"preferences_calendars_add_google_account_sheet_description" = "You'll be redirected to Google to sign in and grant calendar access.";
"preferences_calendars_waiting_for_authentication" = "Waiting for authentication...";
"preferences_calendars_sign_in_with_google" = "Sign in with Google";

// MARK: - Google Account Notifications

"notifications_google_account_connected_title" = "Google Account connected";
"notifications_google_account_connected_body" = "%@ is connected";
"notifications_google_account_refreshed_body" = "%@ calendars refreshed";
181 changes: 161 additions & 20 deletions MeetingBar/UI/Views/Preferences/CalendarsTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import EventKit
import SwiftUI

import Defaults

struct CalendarsTab: View {
Expand All @@ -20,13 +19,29 @@ struct CalendarsTab: View {
ProviderPicker(eventManager: eventManager)
}
.padding(.bottom, 5)
Label("preferences_calendars_select_calendars_title".loco(), systemImage: "calendar").padding(5)

if Defaults[.eventStoreProvider] == .googleCalendar {
GroupBox(label: Label("preferences_calendars_google_accounts_title", systemImage: "person.3")) {
GoogleAccountsSection(eventManager: eventManager)
}
.padding(.bottom, 5)
}
Comment on lines +23 to +28
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

New UI strings in this file are hard-coded (e.g., "Google Accounts", "No accounts connected…", "Add Google Account", "Connected", "Remove Account", etc.) while the surrounding Preferences UI uses localized strings via .loco(). Please switch these new user-facing strings to the localization system so they participate in translations.

Copilot uses AI. Check for mistakes.

Label("preferences_calendars_select_calendars_title", systemImage: "calendar").padding(5)
List {
if eventManager.calendars.isEmpty {
if Defaults[.eventStoreProvider] == .macOSEventKit {
AccessDeniedBanner()
} else if Defaults[.googleAccounts].isEmpty {
Text("preferences_calendars_no_accounts_connected")
.foregroundColor(.secondary)
.padding()
} else {
Text("preferences_calendars_no_calendars_available")
.foregroundColor(.secondary)
.padding()
Comment on lines +39 to +42
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The empty-state message shown when eventManager.calendars.isEmpty currently assumes no Google accounts are connected. calendars can also be empty due to fetch errors, permissions issues, or accounts that exist but have no calendars, so this text can be misleading. Consider basing the message on Defaults[.googleAccounts].isEmpty (and/or surfacing an error/loading state) rather than calendars.isEmpty alone.

Suggested change
} else {
Text("No accounts connected. Add a Google account to get started.")
.foregroundColor(.secondary)
.padding()
} else if Defaults[.googleAccounts].isEmpty {
Text("No accounts connected. Add a Google account to get started.")
.foregroundColor(.secondary)
.padding()
} else {
Text("No calendars available. Try refreshing or check account access.")
.foregroundColor(.secondary)
.padding()

Copilot uses AI. Check for mistakes.
}
Comment on lines +35 to 43
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

"No calendars available" may be misleading for failed auth states.

When Defaults[.googleAccounts] is non-empty but calendars is empty, this could mean:

  1. The account genuinely has no calendars
  2. Auth state restoration failed (phantom account)
  3. Network error during fetch

The message "no calendars available" doesn't guide users to re-authenticate if their session is invalid. Consider showing account-specific status or prompting to remove and re-add accounts that fail to load.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift` around lines 35 - 43,
When Defaults[.googleAccounts] is non-empty but the calendars list is empty,
change the UI branch to distinguish between "no calendars" and load/auth
failures: detect per-account fetch/auth state (e.g. an
account.calendarFetchError or account.authRestored flag provided by your
calendar loading logic) and, for accounts with auth or network failures, show an
account-specific message like "Couldn't load calendars for {account.email} —
re-authenticate or remove account" plus Buttons that call existing or new
handlers such as reauthenticateAccount(account) and
removeGoogleAccount(account); only fall back to the generic "no calendars
available" message when the account is healthy and truly has no calendars.

Button("Refresh") {
Button("preferences_calendars_refresh") {
Task { try await eventManager.refreshSources() }
}

Expand All @@ -39,10 +54,146 @@ struct CalendarsTab: View {
}
}

struct GoogleAccountsSection: View {
@ObservedObject var eventManager: EventManager
@Default(.googleAccounts) private var accounts
@State private var showingAddAccount = false
@State private var accountToRemove: GoogleAccount?
@State private var showingRemoveConfirmation = false

var body: some View {
VStack(alignment: .leading, spacing: 8) {
if accounts.isEmpty {
Text("preferences_calendars_no_google_accounts_connected")
.foregroundColor(.secondary)
.font(.caption)
}

ForEach(accounts) { account in
HStack {
Image(systemName: "person.circle.fill")
.foregroundColor(.blue)
VStack(alignment: .leading) {
Text(account.email)
.font(.system(size: 13))
Text("preferences_calendars_account_connected")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
accountToRemove = account
showingRemoveConfirmation = true
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
.buttonStyle(.plain)
.help("preferences_calendars_remove_account_help")
.accessibilityLabel("preferences_calendars_remove_account_label")
}
.padding(.vertical, 2)
}

Divider()

Button(action: {
showingAddAccount = true
}) {
Label("preferences_calendars_add_google_account", systemImage: "plus.circle.fill")
}
.sheet(isPresented: $showingAddAccount) {
AddAccountSheet(onAccountAdded: {
Task {
try? await eventManager.refreshSources()
}
})
}
}
.padding(5)
.confirmationDialog(
"preferences_calendars_remove_account_dialog_title",
isPresented: $showingRemoveConfirmation,
titleVisibility: .visible,
presenting: accountToRemove
) { account in
Button("preferences_calendars_remove_account_action".loco(account.email), role: .destructive) {
Task {
await GCEventStore.shared.removeAccount(account)
try? await eventManager.refreshSources()
}
}
Button("preferences_calendars_cancel", role: .cancel) {}
} message: { account in
Text("preferences_calendars_remove_account_message".loco(account.email))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

struct AddAccountSheet: View {
@Environment(\.presentationMode) var presentationMode
@State private var errorMessage: String?
@State private var isAdding = false
var onAccountAdded: () async -> Void

var body: some View {
VStack(spacing: 16) {
Text("preferences_calendars_add_google_account_sheet_title")
.font(.headline)

Text("preferences_calendars_add_google_account_sheet_description")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)

if let error = errorMessage {
Text(error)
.foregroundColor(.red)
.font(.caption)
}

if isAdding {
ProgressView("preferences_calendars_waiting_for_authentication")
.frame(maxWidth: .infinity, alignment: .center)
}

HStack(spacing: 12) {
Button("preferences_calendars_cancel") {
presentationMode.wrappedValue.dismiss()
}
.keyboardShortcut(.escape, modifiers: [])

Button(action: {
Task {
isAdding = true
do {
_ = try await GCEventStore.shared.addAccount()
await onAccountAdded()
presentationMode.wrappedValue.dismiss()
} catch {
errorMessage = error.localizedDescription
isAdding = false
}
}
}) {
if isAdding {
ProgressView()
} else {
Text("preferences_calendars_sign_in_with_google")
}
}
.buttonStyle(.borderedProminent)
.disabled(isAdding)
}
}
.padding()
.frame(width: 320, height: 180)
}
}

struct CalendarSectionsView: View {
let calendars: [MBCalendar]

// 1. Compute once, with explicit types
private var grouped: [String: [MBCalendar]] {
Dictionary(grouping: calendars, by: \.source)
}
Expand All @@ -69,32 +220,24 @@ struct ProviderPicker: View {
var body: some View {
HStack {
Picker("", selection: $picker) {
Text("access_screen_provider_macos_title".loco()).tag(EventStoreProvider.macOSEventKit)
Text("access_screen_provider_macos_title").tag(EventStoreProvider.macOSEventKit)
Text("Google Calendar API").tag(EventStoreProvider.googleCalendar)
}
.onChange(of: picker) { provider in
Task { await eventManager.changeEventStoreProvider(provider) }
}

if Defaults[.eventStoreProvider] == .googleCalendar {
Button("preferences_calendars_provider_gcalendar_change_account".loco()) {
Task {
await eventManager.changeEventStoreProvider(.googleCalendar, withSignOut: true)
}
}
}
}
}
}

struct AccessDeniedBanner: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("access_screen_access_screen_access_denied_go_to_title".loco())
Button("access_screen_access_denied_system_preferences_button".loco()) {
Text("access_screen_access_screen_access_denied_go_to_title")
Button("access_screen_access_denied_system_preferences_button") {
NSWorkspace.shared.open(Links.calendarPreferences)
}
Text("access_screen_access_denied_relaunch_title".loco())
Text("access_screen_access_denied_relaunch_title")
}
.padding(.top, 8)
}
Expand All @@ -111,8 +254,7 @@ struct CalendarRow: View {

var body: some View {
Toggle(isOn: $isSelected) {
HStack {
Text("")
HStack(spacing: 6) {
Circle().fill(Color(calendar.color)).frame(width: 10, height: 10)
Text(calendar.title)
}
Expand All @@ -126,15 +268,14 @@ struct CalendarRow: View {
} else {
Defaults[.selectedCalendarIDs].removeAll { $0 == calendar.id }
}
Defaults[.selectedCalendarIDs] = Array(Set(Defaults[.selectedCalendarIDs])) // Deduplication
Defaults[.selectedCalendarIDs] = Array(Set(Defaults[.selectedCalendarIDs]))
}
}
}

#Preview {
List {
CalendarSectionsView(calendars: [MBCalendar(title: "Calendar #1", id: "1", source: "Source #1", email: nil, color: .brown)])

CalendarSectionsView(calendars: [MBCalendar(title: "Calendar #2", id: "2", source: "Source #2", email: nil, color: .blue)])
}.listStyle(.sidebar)
.frame(width: 300, height: 200)
Expand Down
9 changes: 9 additions & 0 deletions MeetingBar/Utilities/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,12 @@ extension NSImage {
return copy
}
}

extension DateFormatter {
static let yyyyMMdd: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
}
Loading
Loading