Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0257e62
Auto-link conversations to Google Calendar events
Mar 25, 2026
d75c546
fix: address Greptile code review issues on calendar PR
Apr 30, 2026
802dbef
fix(app): fix build errors in calendar conversation linking
krushnarout Apr 30, 2026
8c8f4ed
fix: resolve P0 compile error and P1 title override from Greptile
Apr 30, 2026
ca72988
fix: P0 build error and P1 title override (deduplicated)
Apr 30, 2026
098437b
fix: P1 folderId/visibility dropped in local cache + P2 cleanup
Apr 30, 2026
76d499b
fix: P1 mounted guard + P2 auth string + P2 overlap logic
Apr 30, 2026
4f40292
fix(backend): add missing calendar_onboarding router stub
krushnarout Apr 30, 2026
ecf9795
fix: P0 missing calendar_onboarding module + P1 datetime serialisation
Apr 30, 2026
ae0c573
fix(backend): await async calls in google_calendar router
krushnarout Apr 30, 2026
c639f2d
Merge remote-tracking branch 'origin/atlas/calendar-conversation-link…
krushnarout Apr 30, 2026
489f953
fix(backend): await async calendar calls in conversations router
krushnarout Apr 30, 2026
f8caafa
fix(app): remove redundant CalendarIntegrationsPage, reuse Integratio…
krushnarout Apr 30, 2026
a8ae257
Fix P1 issues: all-day event dates, Firestore serialization, skip per…
Apr 30, 2026
3cc920b
fix(backend): await async calendar calls in auto-link — was silently …
krushnarout Apr 30, 2026
3abf1ac
fix(app): resolve name collision between provider methods and API fun…
krushnarout Apr 30, 2026
7ecd167
fix: lower auto-link overlap threshold + fix calendar connection chec…
krushnarout Apr 30, 2026
88b81f1
fix(backend): skip auto-link if calendar event already manually linked
krushnarout Apr 30, 2026
deffecf
Merge remote-tracking branch 'origin/main' into atlas/calendar-conver…
krushnarout May 6, 2026
fd6c377
fix(app): add unlink, add summary, and share with attendees to calend…
krushnarout May 6, 2026
201f0aa
format
krushnarout May 8, 2026
744ee3f
format
krushnarout May 8, 2026
30d4745
fix(app): hide add summary button after success, show checkmark
krushnarout May 9, 2026
a6bfbe5
Merge remote-tracking branch 'origin/main' into atlas/calendar-conver…
krushnarout May 9, 2026
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
113 changes: 107 additions & 6 deletions app/lib/backend/http/api/conversations.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:omi/backend/http/shared.dart';
import 'package:omi/backend/schema/schema.dart';
import 'package:omi/env/env.dart';
Expand Down Expand Up @@ -61,9 +62,8 @@ Future<List<ServerConversation>> getConversations({
if (response.statusCode == 200) {
// decode body bytes to utf8 string and then parse json so as to avoid utf8 char issues
var body = utf8.decode(response.bodyBytes);
var memories = (jsonDecode(body) as List<dynamic>)
.map((conversation) => ServerConversation.fromJson(conversation))
.toList();
var memories =
(jsonDecode(body) as List<dynamic>).map((conversation) => ServerConversation.fromJson(conversation)).toList();
Logger.debug('getConversations length: ${memories.length}');
return memories;
} else {
Expand Down Expand Up @@ -99,6 +99,108 @@ Future<bool> deleteConversationServer(String conversationId) async {
return response.statusCode == 204;
}

Future<bool> unlinkCalendarEvent(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId/calendar-event',
headers: {},
method: 'DELETE',
body: '',
);
if (response == null) return false;
return response.statusCode == 200;
}

/// Add conversation summary to the linked calendar event description.
/// Returns the htmlLink to open the event if successful, null otherwise.
Future<String?> addSummaryToCalendarEvent(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId/calendar-event/add-summary',
headers: {},
method: 'POST',
body: '',
);
if (response == null) return null;
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['html_link'] as String?;
}
return null;
}

/// Link a specific Google Calendar event to a conversation.
/// Returns the linked CalendarEventLink if successful, null otherwise.
Future<CalendarEventLink?> linkCalendarEvent(String conversationId, String eventId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId/calendar-event',
headers: {},
method: 'POST',
body: jsonEncode({'event_id': eventId}),
);
if (response == null) return null;
if (response.statusCode == 200) {
return CalendarEventLink.fromJson(jsonDecode(response.body));
}
debugPrint('linkCalendarEvent error: ${response.statusCode} - ${response.body}');
return null;
}

/// Auto-link a conversation to the best overlapping Google Calendar event.
/// Returns the linked CalendarEventLink if found, null otherwise.
Future<CalendarEventLink?> autoLinkCalendarEvent(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId/calendar-event/auto-link',
headers: {},
method: 'POST',
body: '',
);
if (response == null) return null;
if (response.statusCode == 200) {
return CalendarEventLink.fromJson(jsonDecode(response.body));
}
// 404 means no overlapping event found - not an error, just no match
if (response.statusCode == 404) {
debugPrint('autoLinkCalendarEvent: No overlapping calendar event found');
return null;
}
debugPrint('autoLinkCalendarEvent error: ${response.statusCode} - ${response.body}');
return null;
}

/// List Google Calendar events within a time range for the event picker.
/// Returns a list of CalendarEventLink objects, or empty list on error.
Future<List<CalendarEventLink>> listGoogleCalendarEvents({
DateTime? timeMin,
DateTime? timeMax,
String? query,
int maxResults = 20,
}) async {
String url = '${Env.apiBaseUrl}v1/calendar/google/events?max_results=$maxResults';

if (timeMin != null) {
url += '&time_min=${timeMin.toUtc().toIso8601String()}';
}
if (timeMax != null) {
url += '&time_max=${timeMax.toUtc().toIso8601String()}';
}
if (query != null && query.isNotEmpty) {
url += '&q=${Uri.encodeComponent(query)}';
}

var response = await makeApiCall(
url: url,
headers: {},
method: 'GET',
body: '',
);
if (response == null) return [];
if (response.statusCode == 200) {
var body = utf8.decode(response.bodyBytes);
return (jsonDecode(body) as List<dynamic>).map((event) => CalendarEventLink.fromJson(event)).toList();
}
debugPrint('listGoogleCalendarEvents error: ${response.statusCode} - ${response.body}');
return [];
}

Future<ServerConversation?> getConversationById(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId',
Expand Down Expand Up @@ -168,9 +270,8 @@ class TranscriptsResponse {
deepgram: (json['deepgram'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
soniox: (json['soniox'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
whisperx: (json['whisperx'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
speechmatics: (json['speechmatics'] as List<dynamic>)
.map((segment) => TranscriptSegment.fromJson(segment))
.toList(),
speechmatics:
(json['speechmatics'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
);
}
}
Expand Down
98 changes: 71 additions & 27 deletions app/lib/backend/schema/conversation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,9 @@ class ConversationPostProcessing {

factory ConversationPostProcessing.fromJson(Map<String, dynamic> json) {
return ConversationPostProcessing(
status:
ConversationPostProcessingStatus.values.asNameMap()[json['status']] ??
status: ConversationPostProcessingStatus.values.asNameMap()[json['status']] ??
ConversationPostProcessingStatus.in_progress,
model:
ConversationPostProcessingModel.values.asNameMap()[json['model']] ??
model: ConversationPostProcessingModel.values.asNameMap()[json['model']] ??
ConversationPostProcessingModel.fal_whisperx,
failReason: json['fail_reason'],
);
Expand Down Expand Up @@ -142,12 +140,55 @@ class ConversationPhoto {
}

Map<String, dynamic> toJson() => {
'id': id,
'base64': base64,
'description': description,
'created_at': createdAt.toUtc().toIso8601String(),
'discarded': discarded,
};
'id': id,
'base64': base64,
'description': description,
'created_at': createdAt.toUtc().toIso8601String(),
'discarded': discarded,
};
}

/// Links a conversation to a Google Calendar event.
class CalendarEventLink {
final String eventId;
final String title;
final List<String> attendees;
final List<String> attendeeEmails;
final DateTime startTime;
final DateTime endTime;
final String? htmlLink;

CalendarEventLink({
required this.eventId,
required this.title,
this.attendees = const [],
this.attendeeEmails = const [],
required this.startTime,
required this.endTime,
this.htmlLink,
});

factory CalendarEventLink.fromJson(Map<String, dynamic> json) {
return CalendarEventLink(
eventId: json['event_id'] ?? '',
title: json['title'] ?? '',
attendees: ((json['attendees'] ?? []) as List<dynamic>).map((e) => e.toString()).toList(),
attendeeEmails: ((json['attendee_emails'] ?? []) as List<dynamic>).map((e) => e.toString()).toList(),
startTime: json['start_time'] != null ? DateTime.parse(json['start_time']).toLocal() : DateTime.now(),
endTime: json['end_time'] != null ? DateTime.parse(json['end_time']).toLocal() : DateTime.now(),
htmlLink: json['html_link'],
);
}

Map<String, dynamic> toJson() => {
'event_id': eventId,
'title': title,
'attendees': attendees,
'attendee_emails': attendeeEmails,
'start_time': startTime.toUtc().toIso8601String(),
'end_time': endTime.toUtc().toIso8601String(),
'html_link': htmlLink,
};
}

class AudioFile {
Expand Down Expand Up @@ -182,14 +223,14 @@ class AudioFile {
}

Map<String, dynamic> toJson() => {
'id': id,
'uid': uid,
'conversation_id': conversationId,
'chunk_timestamps': chunkTimestamps,
'provider': provider,
'started_at': startedAt?.toUtc().toIso8601String(),
'duration': duration,
};
'id': id,
'uid': uid,
'conversation_id': conversationId,
'chunk_timestamps': chunkTimestamps,
'provider': provider,
'started_at': startedAt?.toUtc().toIso8601String(),
'duration': duration,
};
}

class ServerConversation {
Expand All @@ -211,6 +252,9 @@ class ServerConversation {

final ConversationExternalData? externalIntegration;

/// Calendar event link - set when conversation overlaps with a Google Calendar event
final CalendarEventLink? calendarEvent;

ConversationStatus status;
bool discarded;
final bool deleted;
Expand Down Expand Up @@ -239,6 +283,7 @@ class ServerConversation {
this.source,
this.language,
this.externalIntegration,
this.calendarEvent,
this.status = ConversationStatus.completed,
this.isLocked = false,
this.starred = false,
Expand All @@ -256,12 +301,10 @@ class ServerConversation {
transcriptSegments: ((json['transcript_segments'] ?? []) as List<dynamic>)
.map((segment) => TranscriptSegment.fromJson(segment))
.toList(),
appResults: ((json['apps_results'] ?? []) as List<dynamic>)
.map((result) => AppResponse.fromJson(result))
.toList(),
suggestedSummarizationApps: ((json['suggested_summarization_apps'] ?? []) as List<dynamic>)
.map((appId) => appId.toString())
.toList(),
appResults:
((json['apps_results'] ?? []) as List<dynamic>).map((result) => AppResponse.fromJson(result)).toList(),
suggestedSummarizationApps:
((json['suggested_summarization_apps'] ?? []) as List<dynamic>).map((appId) => appId.toString()).toList(),
geolocation: json['geolocation'] != null ? Geolocation.fromJson(json['geolocation']) : null,
photos: json['photos'] != null
? ((json['photos'] ?? []) as List<dynamic>).map((photo) => ConversationPhoto.fromJson(photo)).toList()
Expand All @@ -271,9 +314,9 @@ class ServerConversation {
source: json['source'] != null ? ConversationSource.values.asNameMap()[json['source']] : ConversationSource.omi,
language: json['language'],
deleted: json['deleted'] ?? false,
externalIntegration: json['external_data'] != null
? ConversationExternalData.fromJson(json['external_data'])
: null,
externalIntegration:
json['external_data'] != null ? ConversationExternalData.fromJson(json['external_data']) : null,
calendarEvent: json['calendar_event'] != null ? CalendarEventLink.fromJson(json['calendar_event']) : null,
status: json['status'] != null
? ConversationStatus.values.asNameMap()[json['status']] ?? ConversationStatus.completed
: ConversationStatus.completed,
Expand Down Expand Up @@ -301,6 +344,7 @@ class ServerConversation {
'source': source?.toString(),
'language': language,
'external_data': externalIntegration?.toJson(),
'calendar_event': calendarEvent?.toJson(),
'status': status.toString().split('.').last,
'is_locked': isLocked,
'starred': starred,
Expand Down
Loading
Loading