diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 986f0d6..8ae9948 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + diff --git a/android/app/src/main/java/com/chronomind/app/calendar/CalendarSyncManager.kt b/android/app/src/main/java/com/chronomind/app/calendar/CalendarSyncManager.kt new file mode 100644 index 0000000..0f9d251 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/calendar/CalendarSyncManager.kt @@ -0,0 +1,166 @@ +package com.chronomind.app.calendar + +import android.Manifest +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.database.Cursor +import android.net.Uri +import android.provider.CalendarContract +import androidx.core.content.ContextCompat +import com.chronomind.app.engine.* +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +// ── Calendar Event ──────────────────────────────────────────── + +data class CalendarEvent( + val eventId: Long, + val title: String, + val description: String?, + val dtStart: Long, + val dtEnd: Long, + val calendarDisplayName: String?, + val calendarColor: Int?, + val allDay: Boolean +) + +// ── Calendar Sync Manager ───────────────────────────────────── + +@Singleton +class CalendarSyncManager @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private const val SYNC_PREFIX = "cal-sync-" + } + + fun hasCalendarPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_CALENDAR + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Fetch calendar events for the next [days] days from the device calendar. + */ + fun fetchEvents(days: Int = 7): List { + if (!hasCalendarPermission()) return emptyList() + + val now = System.currentTimeMillis() + val endTime = now + days * 24 * 60 * 60 * 1000L + + val projection = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DESCRIPTION, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.CALENDAR_DISPLAY_NAME, + CalendarContract.Events.CALENDAR_COLOR, + CalendarContract.Events.ALL_DAY + ) + + val selection = "(${CalendarContract.Events.DTSTART} >= ?) AND (${CalendarContract.Events.DTSTART} <= ?)" + val selectionArgs = arrayOf(now.toString(), endTime.toString()) + val sortOrder = "${CalendarContract.Events.DTSTART} ASC" + + val events = mutableListOf() + val resolver: ContentResolver = context.contentResolver + + var cursor: Cursor? = null + try { + cursor = resolver.query( + CalendarContract.Events.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + ) + + cursor?.let { + while (it.moveToNext()) { + val eventId = it.getLong(0) + val title = it.getString(1) ?: "Untitled" + val description = it.getString(2) + val dtStart = it.getLong(3) + val dtEnd = it.getLong(4) + val calName = it.getString(5) + val calColor = if (!it.isNull(6)) it.getInt(6) else null + val allDay = it.getInt(7) == 1 + + if (!allDay && title.isNotBlank()) { + events.add( + CalendarEvent( + eventId = eventId, + title = title, + description = description, + dtStart = dtStart, + dtEnd = dtEnd, + calendarDisplayName = calName, + calendarColor = calColor, + allDay = allDay + ) + ) + } + } + } + } finally { + cursor?.close() + } + + return events + } + + /** + * Convert calendar events to CMTimers with deterministic IDs. + * Uses SYNC_PREFIX + eventId for stable identity across syncs. + */ + fun convertToTimers(events: List): List { + return events.mapNotNull { event -> + val targetTime = Date(event.dtStart) + if (targetTime.before(Date())) return@mapNotNull null + + val durationSeconds = (event.dtEnd - event.dtStart) / 1000.0 + + CMTimer( + id = "$SYNC_PREFIX${event.eventId}", + label = event.title, + description = event.description, + type = CMTimerType.ALARM, + state = CMTimerState.ACTIVE, + urgency = UrgencyLevel.STANDARD, + duration = durationSeconds, + targetTime = targetTime, + createdAt = Date(), + startedAt = Date(), + cascade = CascadeConfig(CascadePreset.STANDARD, emptyList()), + warnings = calculateCascadeWarnings( + targetTime, + CascadePreset.STANDARD.defaultIntervals, + Date() + ).toMutableList(), + isCalendarSync = true, + calendarEventId = event.eventId.toString(), + category = event.calendarDisplayName + ) + } + } + + /** + * Perform a full sync: fetch events and convert to timers. + */ + fun sync(days: Int = 7): List { + val events = fetchEvents(days) + return convertToTimers(events) + } + + /** + * Check if a timer ID is a calendar-synced timer. + */ + fun isCalendarSyncTimer(timerId: String): Boolean { + return timerId.startsWith(SYNC_PREFIX) + } +}