mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge branch 'pr-213' into feature/calendar
this is a test merge
This commit is contained in:
commit
fd6e8705b1
4 changed files with 177 additions and 23 deletions
|
|
@ -10809,6 +10809,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Join the meeting" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Keyboard Backlight HUD" : {
|
"Keyboard Backlight HUD" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -12429,6 +12432,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Meeting starts at %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Memory Usage" : {
|
"Memory Usage" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
|
||||||
|
|
@ -39,27 +39,19 @@ class CalendarService: CalendarServiceProviding {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performAccessRequest(for type: EKEntityType) async throws -> Bool {
|
private func performAccessRequest(for type: EKEntityType) async throws -> Bool {
|
||||||
if #available(macOS 14.0, *) {
|
switch type {
|
||||||
switch type {
|
case .event:
|
||||||
case .event:
|
return try await store.requestFullAccessToEvents()
|
||||||
return try await store.requestFullAccessToEvents()
|
case .reminder:
|
||||||
case .reminder:
|
return try await store.requestFullAccessToReminders()
|
||||||
return try await store.requestFullAccessToReminders()
|
@unknown default:
|
||||||
@unknown default:
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return try await store.requestAccess(to: type)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hasAccess(to entityType: EKEntityType) -> Bool {
|
private func hasAccess(to entityType: EKEntityType) -> Bool {
|
||||||
let status = EKEventStore.authorizationStatus(for: entityType)
|
let status = EKEventStore.authorizationStatus(for: entityType)
|
||||||
if #available(macOS 14.0, *) {
|
return status == .fullAccess
|
||||||
return status == .fullAccess
|
|
||||||
} else {
|
|
||||||
return status == .authorized
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func calendars() async -> [CalendarModel] {
|
func calendars() async -> [CalendarModel] {
|
||||||
|
|
@ -168,7 +160,8 @@ extension EventModel {
|
||||||
participants: .init(from: event),
|
participants: .init(from: event),
|
||||||
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : event.timeZone,
|
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : event.timeZone,
|
||||||
hasRecurrenceRules: event.hasRecurrenceRules || event.isDetached,
|
hasRecurrenceRules: event.hasRecurrenceRules || event.isDetached,
|
||||||
priority: nil
|
priority: nil,
|
||||||
|
conferenceURL: event.extractConferenceURL()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +185,8 @@ extension EventModel {
|
||||||
participants: [],
|
participants: [],
|
||||||
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : reminder.timeZone,
|
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : reminder.timeZone,
|
||||||
hasRecurrenceRules: reminder.hasRecurrenceRules,
|
hasRecurrenceRules: reminder.hasRecurrenceRules,
|
||||||
priority: .init(from: reminder.priority)
|
priority: .init(from: reminder.priority),
|
||||||
|
conferenceURL: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -271,11 +265,7 @@ private extension EKCalendar {
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDelegate: Bool {
|
var isDelegate: Bool {
|
||||||
if #available(macOS 13.0, *) {
|
return source.isDelegate
|
||||||
return source.isDelegate
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,6 +281,60 @@ private extension EKEvent {
|
||||||
let endOfDay = calendar.dateInterval(of: .day, for: endDate)?.end
|
let endOfDay = calendar.dateInterval(of: .day, for: endDate)?.end
|
||||||
return startDate == startOfDay && endDate == endOfDay
|
return startDate == startOfDay && endDate == endOfDay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract conference call URL from various sources
|
||||||
|
func extractConferenceURL() -> URL? {
|
||||||
|
// First try the URL field if it's a conference URL
|
||||||
|
if let eventURL = url, isConferenceURL(eventURL) {
|
||||||
|
return eventURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try to extract from location field
|
||||||
|
if let location = location, let conferenceURL = extractURLFromText(location) {
|
||||||
|
return conferenceURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally try to extract from notes
|
||||||
|
if let notes = notes, let conferenceURL = extractURLFromText(notes) {
|
||||||
|
return conferenceURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a URL is likely a conference URL
|
||||||
|
private func isConferenceURL(_ url: URL) -> Bool {
|
||||||
|
let host = url.host?.lowercased() ?? ""
|
||||||
|
let conferenceHosts = [
|
||||||
|
"zoom.us",
|
||||||
|
"teams.microsoft.com",
|
||||||
|
"meet.google.com",
|
||||||
|
"webex.com",
|
||||||
|
"gotomeeting.com",
|
||||||
|
"bluejeans.com",
|
||||||
|
"whereby.com",
|
||||||
|
"meet.jit.si",
|
||||||
|
"discord.gg",
|
||||||
|
"discord.com",
|
||||||
|
"facetime.apple.com"
|
||||||
|
]
|
||||||
|
|
||||||
|
return conferenceHosts.contains { host.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract first valid conference URL from text
|
||||||
|
private func extractURLFromText(_ text: String) -> URL? {
|
||||||
|
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
|
let matches = detector?.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text))
|
||||||
|
|
||||||
|
for match in matches ?? [] {
|
||||||
|
if let url = match.url, isConferenceURL(url) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Calendar {
|
private extension Calendar {
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,11 @@ struct EventListView: View {
|
||||||
.foregroundColor(Color(white: 0.65))
|
.foregroundColor(Color(white: 0.65))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show Join button if conference URL is available
|
||||||
|
if let conferenceURL = event.conferenceURL {
|
||||||
|
ConferenceJoinButton(url: conferenceURL, event: event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
|
@ -484,6 +489,104 @@ struct ReminderToggle: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Conference Provider
|
||||||
|
|
||||||
|
enum ConferenceProvider: CaseIterable {
|
||||||
|
case zoom, teams, meet, webex, facetime, gotomeeting, bluejeans, whereby, jitsi, discord, generic
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .zoom: return "Zoom"
|
||||||
|
case .teams: return "Teams"
|
||||||
|
case .meet: return "Meet"
|
||||||
|
case .webex: return "Webex"
|
||||||
|
case .facetime: return "FaceTime"
|
||||||
|
case .gotomeeting: return "GoToMeeting"
|
||||||
|
case .bluejeans: return "BlueJeans"
|
||||||
|
case .whereby: return "Whereby"
|
||||||
|
case .jitsi: return "Jitsi"
|
||||||
|
case .discord: return "Discord"
|
||||||
|
case .generic: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .zoom: return Color(red: 0.16, green: 0.52, blue: 0.95)
|
||||||
|
case .teams: return Color(red: 0.36, green: 0.42, blue: 0.89)
|
||||||
|
case .meet: return Color(red: 0.0, green: 0.65, blue: 0.42)
|
||||||
|
case .webex: return Color(red: 0.0, green: 0.71, blue: 0.84)
|
||||||
|
case .facetime: return Color(red: 0.2, green: 0.78, blue: 0.35)
|
||||||
|
case .gotomeeting: return Color(red: 0.95, green: 0.5, blue: 0.13)
|
||||||
|
case .bluejeans: return Color(red: 0.0, green: 0.48, blue: 0.87)
|
||||||
|
case .whereby: return Color(red: 0.27, green: 0.51, blue: 0.96)
|
||||||
|
case .jitsi: return Color(red: 0.16, green: 0.68, blue: 0.95)
|
||||||
|
case .discord: return Color(red: 0.35, green: 0.39, blue: 0.98)
|
||||||
|
case .generic: return Color.accentColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hostIdentifiers: [String] {
|
||||||
|
switch self {
|
||||||
|
case .zoom: return ["zoom.us"]
|
||||||
|
case .teams: return ["teams.microsoft.com"]
|
||||||
|
case .meet: return ["meet.google.com"]
|
||||||
|
case .webex: return ["webex.com"]
|
||||||
|
case .facetime: return ["facetime.apple.com"]
|
||||||
|
case .gotomeeting: return ["gotomeeting.com"]
|
||||||
|
case .bluejeans: return ["bluejeans.com"]
|
||||||
|
case .whereby: return ["whereby.com"]
|
||||||
|
case .jitsi: return ["meet.jit.si", "jitsi"]
|
||||||
|
case .discord: return ["discord.gg", "discord.com"]
|
||||||
|
case .generic: return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func detect(from url: URL) -> ConferenceProvider {
|
||||||
|
let host = url.host?.lowercased() ?? ""
|
||||||
|
return allCases.first { provider in
|
||||||
|
provider.hostIdentifiers.contains { host.contains($0) }
|
||||||
|
} ?? .generic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conference Join Button
|
||||||
|
|
||||||
|
struct ConferenceJoinButton: View {
|
||||||
|
let url: URL
|
||||||
|
let event: EventModel
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
private var provider: ConferenceProvider { .detect(from: url) }
|
||||||
|
private var isJoinable: Bool {
|
||||||
|
event.start.addingTimeInterval(-15 * 60) <= Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonText: String {
|
||||||
|
event.eventStatus == .inProgress ? "Rejoin" : "Join"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: { openURL(url) }) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "video.fill")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
Text(provider.name.isEmpty ? buttonText : "\(buttonText) \(provider.name)")
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
.foregroundColor(isJoinable ? .white : Color(white: 0.5))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(isJoinable ? provider.color.opacity(0.85) : Color.gray.opacity(0.3))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.disabled(!isJoinable)
|
||||||
|
.help(isJoinable ? "Join the meeting" : "Meeting starts at \(event.start.formatted(date: .omitted, time: .shortened))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CalendarView()
|
CalendarView()
|
||||||
.frame(width: 250)
|
.frame(width: 250)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ struct EventModel: Equatable, Identifiable {
|
||||||
let timeZone: TimeZone?
|
let timeZone: TimeZone?
|
||||||
let hasRecurrenceRules: Bool
|
let hasRecurrenceRules: Bool
|
||||||
let priority: Priority?
|
let priority: Priority?
|
||||||
|
let conferenceURL: URL?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AttendanceStatus: Comparable {
|
enum AttendanceStatus: Comparable {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue