mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* feat(companion): add upcoming bookings widget for iOS and Android - Add iOS widget using @bacons/apple-targets with SwiftUI - Add Android widget using react-native-android-widget - Configure App Groups for iOS data sharing between app and widget - Create widget sync hook to update widget data when bookings change - Widget displays up to 5 upcoming bookings with title, time, and attendee - Widget refreshes every 15 minutes and when app goes to background Co-Authored-By: [email protected] <[email protected]> * fix(companion): refactor useWidgetSync to avoid React Compiler limitation Move conditional logic outside of try/catch block to fix React Compiler error: 'Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement' Co-Authored-By: [email protected] <[email protected]> * fix(companion): fix widget data sync issues - Fix Swift widget to read data as JSON string (format used by react-native-shared-group-preferences) - Add fallback to read as raw Data for compatibility - Fix query key mismatch by checking multiple common filter combinations - Ensure widget finds cached bookings regardless of which filter the app used Co-Authored-By: [email protected] <[email protected]> * fix(companion): use ExtensionStorage from @bacons/apple-targets for widget data - Replace react-native-shared-group-preferences with ExtensionStorage - ExtensionStorage properly stores data in iOS App Groups - Add ExtensionStorage.reloadWidget() call to trigger widget refresh - This ensures the widget picks up new data immediately Co-Authored-By: [email protected] <[email protected]> * feat(companion): add upcoming bookings widget for ios - all shapes (#27564) * ios working widget * fix(companion): remove sensitive data logging from widget storage Remove console.log statements that were logging full widgetData payload and formatted widget bookings containing attendee names and booking details. This addresses security concerns identified by Cubic AI review (confidence 9/10). Co-Authored-By: unknown <> * dark mode * new look * fix deeplink * fix biome lint * fix(companion): remove booking titles from console logs to avoid PII exposure Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * lock the versions and dev mode logging * remove dynamic imports * feat(companion): upcoming bookings widget android (#27689) * companion-upcoming-bookings-widget-android * fix(companion): remove stale relative countdowns and fix past-time clamping in Android widget - Remove relative countdown text (In 15m, In 1h, Now) that becomes stale due to Android widget 30+ min update interval (Cubic violation 2) - Remove Math.max(0, minutes) clamping that caused ended meetings to display as 'Now' indefinitely (Cubic violation 1) - Simplify accent color logic to use startTimeISO directly - Keep absolute time display (date + startTime) which remains accurate Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Dhairyashil <[email protected]> Co-authored-by: Dhairyashil Shinde <[email protected]> Co-authored-by: Volnei Munhoz <[email protected]>
432 lines
17 KiB
Swift
432 lines
17 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
|
|
// MARK: - Data Models
|
|
|
|
struct BookingData: Codable, Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let date: String
|
|
let startTime: String
|
|
let endTime: String
|
|
let startTimeISO: String? // For countdown calculation
|
|
let attendeeName: String?
|
|
let hostName: String?
|
|
let location: String?
|
|
let hasVideoCall: Bool?
|
|
|
|
// Calculate minutes until meeting starts
|
|
var minutesUntilStart: Int? {
|
|
guard let isoString = startTimeISO else { return nil }
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
|
// Try with fractional seconds first, then without
|
|
var startDate = formatter.date(from: isoString)
|
|
if startDate == nil {
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
startDate = formatter.date(from: isoString)
|
|
}
|
|
|
|
guard let start = startDate else { return nil }
|
|
let minutes = Int(start.timeIntervalSinceNow / 60)
|
|
return max(0, minutes)
|
|
}
|
|
|
|
// Check if meeting is happening now
|
|
var isHappeningNow: Bool {
|
|
guard let minutes = minutesUntilStart else { return false }
|
|
return minutes <= 0
|
|
}
|
|
|
|
// Check if meeting is starting soon (within 30 min)
|
|
var isStartingSoon: Bool {
|
|
guard let minutes = minutesUntilStart else { return false }
|
|
return minutes > 0 && minutes <= 30
|
|
}
|
|
}
|
|
|
|
struct WidgetData: Codable {
|
|
let bookings: [BookingData]
|
|
let lastUpdated: String?
|
|
}
|
|
|
|
// MARK: - Timeline Provider
|
|
|
|
struct Provider: TimelineProvider {
|
|
func placeholder(in context: Context) -> BookingEntry {
|
|
BookingEntry(date: Date(), bookings: [
|
|
BookingData(id: "1", title: "Team Meeting", date: "Mon, Feb 3", startTime: "10:00 AM", endTime: "11:00 AM", startTimeISO: nil, attendeeName: "John Doe", hostName: "You", location: nil, hasVideoCall: true)
|
|
])
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (BookingEntry) -> Void) {
|
|
let entry = BookingEntry(date: Date(), bookings: loadBookings())
|
|
completion(entry)
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<BookingEntry>) -> Void) {
|
|
let bookings = loadBookings()
|
|
let entry = BookingEntry(date: Date(), bookings: bookings)
|
|
// Refresh every 5 minutes for more accurate countdowns
|
|
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 5, to: Date())!
|
|
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
|
completion(timeline)
|
|
}
|
|
|
|
private func loadBookings() -> [BookingData] {
|
|
guard let userDefaults = UserDefaults(suiteName: "group.com.cal.companion") else {
|
|
return []
|
|
}
|
|
|
|
if let jsonString = userDefaults.string(forKey: "widgetBookings"),
|
|
let data = jsonString.data(using: .utf8),
|
|
let widgetData = try? JSONDecoder().decode(WidgetData.self, from: data) {
|
|
return widgetData.bookings
|
|
}
|
|
|
|
if let data = userDefaults.data(forKey: "widgetBookings"),
|
|
let widgetData = try? JSONDecoder().decode(WidgetData.self, from: data) {
|
|
return widgetData.bookings
|
|
}
|
|
|
|
return []
|
|
}
|
|
}
|
|
|
|
struct BookingEntry: TimelineEntry {
|
|
let date: Date
|
|
let bookings: [BookingData]
|
|
}
|
|
|
|
// MARK: - Booking Row View
|
|
|
|
struct BookingRowView: View {
|
|
let booking: BookingData
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
// Left accent bar (color based on urgency)
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(accentColor)
|
|
.frame(width: 3)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
// Title row with video icon
|
|
HStack(spacing: 4) {
|
|
Text(booking.title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
|
|
if booking.hasVideoCall == true {
|
|
Image(systemName: "video.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
|
|
// Date and time with countdown
|
|
HStack(spacing: 4) {
|
|
Text("\(booking.date) • \(booking.startTime)")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
|
|
if let countdown = countdownText {
|
|
Text("•")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
Text(countdown)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(countdownColor)
|
|
}
|
|
}
|
|
|
|
// Attendee info
|
|
if let attendee = booking.attendeeName {
|
|
HStack(spacing: 4) {
|
|
// Attendee initial circle
|
|
Circle()
|
|
.fill(Color.accentColor.opacity(0.2))
|
|
.frame(width: 16, height: 16)
|
|
.overlay(
|
|
Text(String(attendee.prefix(1)).uppercased())
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(.accentColor)
|
|
)
|
|
Text(attendee)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
if booking.isHappeningNow {
|
|
return .green
|
|
} else if booking.isStartingSoon {
|
|
return .orange
|
|
}
|
|
return .accentColor
|
|
}
|
|
|
|
private var countdownText: String? {
|
|
guard let minutes = booking.minutesUntilStart else { return nil }
|
|
if minutes <= 0 {
|
|
return "Now"
|
|
} else if minutes < 60 {
|
|
return "In \(minutes)m"
|
|
} else if minutes < 1440 {
|
|
let hours = minutes / 60
|
|
return "In \(hours)h"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var countdownColor: Color {
|
|
guard let minutes = booking.minutesUntilStart else { return .secondary }
|
|
if minutes <= 0 {
|
|
return .green
|
|
} else if minutes <= 30 {
|
|
return .orange
|
|
}
|
|
return .secondary
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget Entry View
|
|
|
|
struct UpcomingBookingsWidgetEntryView: View {
|
|
var entry: Provider.Entry
|
|
@Environment(\.widgetFamily) var family
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Subtle gradient background
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [
|
|
Color.accentColor.opacity(0.05),
|
|
Color.clear
|
|
]),
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header
|
|
HStack {
|
|
Image(systemName: "calendar")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundColor(.accentColor)
|
|
Text("Upcoming")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
Spacer()
|
|
}
|
|
.padding(.bottom, 8)
|
|
|
|
if entry.bookings.isEmpty {
|
|
Spacer()
|
|
VStack(spacing: 4) {
|
|
Image(systemName: "calendar.badge.checkmark")
|
|
.font(.system(size: 24))
|
|
.foregroundColor(.secondary)
|
|
Text("No upcoming bookings")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
Spacer()
|
|
} else {
|
|
if family == .systemMedium {
|
|
// Horizontal layout for medium widget
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ForEach(entry.bookings.prefix(2)) { booking in
|
|
MediumBookingCard(booking: booking)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
} else {
|
|
// Vertical layout for small and large widgets
|
|
let maxBookings = family == .systemSmall ? 2 : 6
|
|
ForEach(entry.bookings.prefix(maxBookings)) { booking in
|
|
Link(destination: URL(string: "calcom://(tabs)/(bookings)/booking-detail?uid=\(booking.id)")!) {
|
|
BookingRowView(booking: booking)
|
|
}
|
|
if booking.id != entry.bookings.prefix(maxBookings).last?.id {
|
|
Divider()
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
.widgetURL(URL(string: "calcom://(tabs)/(bookings)"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Medium Widget Card
|
|
|
|
struct MediumBookingCard: View {
|
|
let booking: BookingData
|
|
|
|
var body: some View {
|
|
Link(destination: URL(string: "calcom://(tabs)/(bookings)/booking-detail?uid=\(booking.id)")!) {
|
|
HStack(spacing: 6) {
|
|
// Left accent bar
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(accentColor)
|
|
.frame(width: 3)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
// Title with video icon
|
|
HStack(spacing: 3) {
|
|
Text(booking.title)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
|
|
if booking.hasVideoCall == true {
|
|
Image(systemName: "video.fill")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
|
|
// Date
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "calendar")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.secondary)
|
|
Text(booking.date)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
// Time with countdown
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "clock")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.secondary)
|
|
Text(booking.startTime)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary)
|
|
|
|
if let countdown = countdownText {
|
|
Text("• \(countdown)")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundColor(countdownColor)
|
|
}
|
|
}
|
|
|
|
// Attendee
|
|
if let attendee = booking.attendeeName {
|
|
HStack(spacing: 3) {
|
|
Circle()
|
|
.fill(Color.accentColor.opacity(0.2))
|
|
.frame(width: 14, height: 14)
|
|
.overlay(
|
|
Text(String(attendee.prefix(1)).uppercased())
|
|
.font(.system(size: 8, weight: .semibold))
|
|
.foregroundColor(.accentColor)
|
|
)
|
|
Text(attendee)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
if booking.isHappeningNow {
|
|
return .green
|
|
} else if booking.isStartingSoon {
|
|
return .orange
|
|
}
|
|
return .accentColor
|
|
}
|
|
|
|
private var countdownText: String? {
|
|
guard let minutes = booking.minutesUntilStart else { return nil }
|
|
if minutes <= 0 {
|
|
return "Now"
|
|
} else if minutes < 60 {
|
|
return "In \(minutes)m"
|
|
} else if minutes < 1440 {
|
|
let hours = minutes / 60
|
|
return "In \(hours)h"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var countdownColor: Color {
|
|
guard let minutes = booking.minutesUntilStart else { return .secondary }
|
|
if minutes <= 0 {
|
|
return .green
|
|
} else if minutes <= 30 {
|
|
return .orange
|
|
}
|
|
return .secondary
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget Configuration
|
|
|
|
struct UpcomingBookingsWidget: Widget {
|
|
let kind: String = "UpcomingBookingsWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
|
UpcomingBookingsWidgetEntryView(entry: entry)
|
|
.containerBackground(.fill.tertiary, for: .widget)
|
|
}
|
|
.configurationDisplayName("Upcoming Bookings")
|
|
.description("View your upcoming Cal.com bookings at a glance.")
|
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
#Preview(as: .systemSmall) {
|
|
UpcomingBookingsWidget()
|
|
} timeline: {
|
|
BookingEntry(date: .now, bookings: [
|
|
BookingData(id: "1", title: "Team Standup", date: "Mon, Feb 3", startTime: "10:00 AM", endTime: "10:30 AM", startTimeISO: ISO8601DateFormatter().string(from: Date().addingTimeInterval(1800)), attendeeName: "John Doe", hostName: "You", location: nil, hasVideoCall: true),
|
|
BookingData(id: "2", title: "Product Review", date: "Mon, Feb 3", startTime: "2:00 PM", endTime: "3:00 PM", startTimeISO: nil, attendeeName: "Jane Smith", hostName: "You", location: nil, hasVideoCall: false)
|
|
])
|
|
BookingEntry(date: .now, bookings: [])
|
|
}
|
|
|
|
#Preview(as: .systemMedium) {
|
|
UpcomingBookingsWidget()
|
|
} timeline: {
|
|
BookingEntry(date: .now, bookings: [
|
|
BookingData(id: "1", title: "Team Standup", date: "Mon, Feb 3", startTime: "10:00 AM", endTime: "10:30 AM", startTimeISO: ISO8601DateFormatter().string(from: Date().addingTimeInterval(600)), attendeeName: "John Doe", hostName: "You", location: nil, hasVideoCall: true),
|
|
BookingData(id: "2", title: "Product Review", date: "Mon, Feb 3", startTime: "2:00 PM", endTime: "3:00 PM", startTimeISO: nil, attendeeName: "Jane Smith", hostName: "You", location: "Zoom", hasVideoCall: true)
|
|
])
|
|
}
|
|
|
|
#Preview(as: .systemLarge) {
|
|
UpcomingBookingsWidget()
|
|
} timeline: {
|
|
BookingEntry(date: .now, bookings: [
|
|
BookingData(id: "1", title: "Team Standup", date: "Mon, Feb 3", startTime: "10:00 AM", endTime: "10:30 AM", startTimeISO: nil, attendeeName: "John Doe", hostName: "You", location: nil, hasVideoCall: true),
|
|
BookingData(id: "2", title: "Product Review", date: "Mon, Feb 3", startTime: "2:00 PM", endTime: "3:00 PM", startTimeISO: nil, attendeeName: "Jane Smith", hostName: "You", location: nil, hasVideoCall: false),
|
|
BookingData(id: "3", title: "Client Call", date: "Tue, Feb 4", startTime: "4:00 PM", endTime: "5:00 PM", startTimeISO: nil, attendeeName: "Bob Wilson", hostName: "You", location: nil, hasVideoCall: true),
|
|
BookingData(id: "4", title: "Interview", date: "Tue, Feb 4", startTime: "5:30 PM", endTime: "6:00 PM", startTimeISO: nil, attendeeName: "Alice Johnson", hostName: "You", location: nil, hasVideoCall: false),
|
|
BookingData(id: "5", title: "Wrap Up", date: "Wed, Feb 5", startTime: "6:00 PM", endTime: "6:15 PM", startTimeISO: nil, attendeeName: "Team", hostName: "You", location: nil, hasVideoCall: false)
|
|
])
|
|
}
|