diff --git a/app/lib/backend/http/api/calendar_meetings.dart b/app/lib/backend/http/api/calendar_meetings.dart deleted file mode 100644 index 1ecc0f292f..0000000000 --- a/app/lib/backend/http/api/calendar_meetings.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; - -import 'package:omi/backend/http/shared.dart'; -import 'package:omi/backend/schema/calendar_meeting_context.dart'; -import 'package:omi/env/env.dart'; -import 'package:omi/utils/logger.dart'; - -/// Response from storing a meeting -class StoreMeetingResponse { - final String meetingId; - final String calendarEventId; - final String message; - - StoreMeetingResponse({required this.meetingId, required this.calendarEventId, required this.message}); - - factory StoreMeetingResponse.fromJson(Map json) { - return StoreMeetingResponse( - meetingId: json['meeting_id'] ?? '', - calendarEventId: json['calendar_event_id'] ?? '', - message: json['message'] ?? 'Meeting stored successfully', - ); - } -} - -/// Store or update a calendar meeting in Firestore via backend API -Future storeMeeting({ - required String calendarEventId, - required String calendarSource, - required String title, - required DateTime startTime, - required DateTime endTime, - String? platform, - String? meetingLink, - List? participants, - String? notes, -}) async { - try { - final requestBody = jsonEncode({ - 'calendar_event_id': calendarEventId, - 'calendar_source': calendarSource, - 'title': title, - 'start_time': startTime.toUtc().toIso8601String(), - 'end_time': endTime.toUtc().toIso8601String(), - 'platform': platform, - 'meeting_link': meetingLink, - 'participants': participants?.map((p) => p.toJson()).toList() ?? [], - 'notes': notes, - }); - - Logger.debug('storeMeeting: Storing meeting $calendarEventId'); - - var response = await makeApiCall( - url: '${Env.apiBaseUrl}v1/calendar/meetings', - headers: {}, - method: 'POST', - body: requestBody, - ); - - if (response == null) { - Logger.debug('storeMeeting: No response from API'); - return null; - } - - if (response.statusCode == 200) { - final result = StoreMeetingResponse.fromJson(jsonDecode(response.body)); - Logger.debug('storeMeeting: Success - meeting_id: ${result.meetingId}'); - return result; - } else { - Logger.debug('storeMeeting: Failed with status ${response.statusCode}: ${response.body}'); - return null; - } - } catch (e, stackTrace) { - Logger.debug('storeMeeting: Exception: $e'); - Logger.debug('storeMeeting: Stack trace: $stackTrace'); - return null; - } -} - -/// Get a calendar meeting by its Firestore document ID -Future getMeeting(String meetingId) async { - try { - var response = await makeApiCall( - url: '${Env.apiBaseUrl}v1/calendar/meetings/$meetingId', - headers: {}, - method: 'GET', - body: '', - ); - - if (response == null) return null; - - if (response.statusCode == 200) { - return CalendarMeetingContext.fromJson(jsonDecode(response.body)); - } else { - Logger.debug('getMeeting: Failed with status ${response.statusCode}'); - return null; - } - } catch (e) { - Logger.debug('getMeeting: Exception: $e'); - return null; - } -} - -/// List calendar meetings within a date range -Future> listMeetings({DateTime? startDate, DateTime? endDate, int limit = 50}) async { - try { - String url = '${Env.apiBaseUrl}v1/calendar/meetings?limit=$limit'; - - if (startDate != null) { - url += '&start_date=${startDate.toUtc().toIso8601String()}'; - } - - if (endDate != null) { - url += '&end_date=${endDate.toUtc().toIso8601String()}'; - } - - var response = await makeApiCall(url: url, headers: {}, method: 'GET', body: ''); - - if (response == null) return []; - - if (response.statusCode == 200) { - final List data = jsonDecode(response.body); - return data.map((json) => CalendarMeetingContext.fromJson(json)).toList(); - } else { - Logger.debug('listMeetings: Failed with status ${response.statusCode}'); - return []; - } - } catch (e) { - Logger.debug('listMeetings: Exception: $e'); - return []; - } -} diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 9984a0ce82..595ef64f05 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -547,31 +547,6 @@ class SharedPreferencesUtil { bool get calendarEnabled => getBool('calendarEnabled'); - set calendarId(String value) => saveString('calendarId', value); - - String get calendarId => getString('calendarId'); - - set calendarType(String value) => saveString('calendarType2', value); // auto, manual (only for now) - - String get calendarType => getString('calendarType2', defaultValue: 'manual'); - - set calendarIntegrationEnabled(bool value) => saveBool('calendarIntegrationEnabled', value); - - bool get calendarIntegrationEnabled => getBool('calendarIntegrationEnabled'); - - // Calendar UI Settings - set showEventsWithNoParticipants(bool value) => saveBool('showEventsWithNoParticipants', value); - - bool get showEventsWithNoParticipants => getBool('showEventsWithNoParticipants'); - - set showMeetingsInMenuBar(bool value) => saveBool('showMeetingsInMenuBar', value); - - bool get showMeetingsInMenuBar => getBool('showMeetingsInMenuBar'); - - set enabledCalendarIds(List value) => saveStringList('enabledCalendarIds', value); - - List get enabledCalendarIds => getStringList('enabledCalendarIds'); - //--------------------------------- Auth ------------------------------------// String get authToken => getString('authToken'); diff --git a/app/lib/backend/schema/calendar_meeting_context.dart b/app/lib/backend/schema/calendar_meeting_context.dart deleted file mode 100644 index ff5f2204e8..0000000000 --- a/app/lib/backend/schema/calendar_meeting_context.dart +++ /dev/null @@ -1,90 +0,0 @@ -class MeetingParticipant { - final String? name; - final String? email; - - MeetingParticipant({this.name, this.email}); - - factory MeetingParticipant.fromJson(Map json) { - return MeetingParticipant(name: json['name'], email: json['email']); - } - - Map toJson() { - return {'name': name, 'email': email}; - } - - String get displayName { - if (name != null && name!.isNotEmpty) return name!; - if (email != null && email!.isNotEmpty) return email!; - return 'Unknown'; - } - - String get fullDisplay { - if (name != null && email != null) { - return '$name <$email>'; - } - return displayName; - } -} - -class CalendarMeetingContext { - final String calendarEventId; - final String title; - final List participants; - final String? platform; - final String? meetingLink; - final DateTime startTime; - final int durationMinutes; - final String? notes; - final String? calendarSource; - - CalendarMeetingContext({ - required this.calendarEventId, - required this.title, - required this.participants, - this.platform, - this.meetingLink, - required this.startTime, - required this.durationMinutes, - this.notes, - this.calendarSource = 'system_calendar', - }); - - factory CalendarMeetingContext.fromJson(Map json) { - return CalendarMeetingContext( - calendarEventId: json['calendar_event_id'] ?? '', - title: json['title'] ?? '', - participants: ((json['participants'] ?? []) as List).map((p) => MeetingParticipant.fromJson(p)).toList(), - platform: json['platform'], - meetingLink: json['meeting_link'], - startTime: DateTime.parse(json['start_time']), - durationMinutes: json['duration_minutes'] ?? 30, - notes: json['notes'], - calendarSource: json['calendar_source'] ?? 'system_calendar', - ); - } - - Map toJson() { - return { - 'calendar_event_id': calendarEventId, - 'title': title, - 'participants': participants.map((p) => p.toJson()).toList(), - 'platform': platform, - 'meeting_link': meetingLink, - 'start_time': startTime.toUtc().toIso8601String(), - 'duration_minutes': durationMinutes, - 'notes': notes, - 'calendar_source': calendarSource, - }; - } - - DateTime get endTime => startTime.add(Duration(minutes: durationMinutes)); - - String get participantNames { - if (participants.isEmpty) return 'No participants'; - return participants.map((p) => p.displayName).join(', '); - } - - List get participantNamesList { - return participants.map((p) => p.displayName).toList(); - } -} diff --git a/app/lib/main.dart b/app/lib/main.dart index dbfd55b5b7..dbff5537d0 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -37,16 +37,13 @@ import 'package:omi/l10n/app_localizations.dart'; import 'package:omi/pages/apps/providers/add_app_provider.dart'; import 'package:omi/pages/conversation_detail/conversation_detail_provider.dart'; import 'package:omi/pages/payments/payment_method_provider.dart'; -import 'package:omi/pages/settings/ai_app_generator_provider.dart'; import 'package:omi/providers/action_items_provider.dart'; import 'package:omi/providers/announcement_provider.dart'; import 'package:omi/providers/app_provider.dart'; import 'package:omi/providers/auth_provider.dart'; -import 'package:omi/providers/calendar_provider.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; -import 'package:omi/providers/developer_mode_provider.dart'; import 'package:omi/providers/device_provider.dart'; import 'package:omi/providers/folder_provider.dart'; import 'package:omi/providers/goals_provider.dart'; @@ -63,7 +60,6 @@ import 'package:omi/providers/sync_provider.dart'; import 'package:omi/providers/task_integration_provider.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/providers/user_provider.dart'; -import 'package:omi/providers/voice_recorder_provider.dart'; import 'package:omi/providers/phone_call_provider.dart'; import 'package:omi/services/auth_service.dart'; import 'package:omi/services/notifications.dart'; @@ -291,13 +287,8 @@ class _MyAppState extends State with WidgetsBindingObserver { update: (BuildContext context, value, MessageProvider? previous) => (previous?..updateAppProvider(value)) ?? MessageProvider(), ), - ChangeNotifierProxyProvider4< - ConversationProvider, - MessageProvider, - PeopleProvider, - UsageProvider, - CaptureProvider - >( + ChangeNotifierProxyProvider4( create: (context) => CaptureProvider(), update: (BuildContext context, conversation, message, people, usage, CaptureProvider? previous) => (previous?..updateProviderInstances(conversation, message, people, usage)) ?? CaptureProvider(), @@ -323,36 +314,28 @@ class _MyAppState extends State with WidgetsBindingObserver { update: (BuildContext context, app, conversation, ConversationDetailProvider? previous) => (previous?..setProviders(app, conversation)) ?? ConversationDetailProvider(), ), - ChangeNotifierProvider(create: (context) => DeveloperModeProvider()..initialize()), - ChangeNotifierProvider(create: (context) => McpProvider()), ChangeNotifierProxyProvider( create: (context) => AddAppProvider(), update: (BuildContext context, value, AddAppProvider? previous) => (previous?..setAppProvider(value)) ?? AddAppProvider(), ), - ChangeNotifierProxyProvider( - create: (context) => AiAppGeneratorProvider(), - update: (BuildContext context, value, AiAppGeneratorProvider? previous) => - (previous?..setAppProvider(value)) ?? AiAppGeneratorProvider(), - ), - ChangeNotifierProvider(create: (context) => PaymentMethodProvider()), ChangeNotifierProxyProvider( create: (context) => MemoriesProvider(), update: (context, connectivity, previous) => (previous?..setConnectivityProvider(connectivity)) ?? MemoriesProvider(), ), ChangeNotifierProvider(create: (context) => UserProvider()), - ChangeNotifierProvider(create: (context) => ActionItemsProvider()), - ChangeNotifierProvider(create: (context) => GoalsProvider()..init()), + ChangeNotifierProvider(lazy: true, create: (context) => ActionItemsProvider()), + ChangeNotifierProvider(lazy: true, create: (context) => GoalsProvider()..init()), ChangeNotifierProvider(create: (context) => SyncProvider()), - ChangeNotifierProvider(create: (context) => TaskIntegrationProvider()), - ChangeNotifierProvider(create: (context) => IntegrationProvider()), - ChangeNotifierProvider(create: (context) => CalendarProvider(), lazy: false), - ChangeNotifierProvider(create: (context) => FolderProvider()), + ChangeNotifierProvider(lazy: true, create: (context) => TaskIntegrationProvider()), + ChangeNotifierProvider(lazy: true, create: (context) => IntegrationProvider()), + ChangeNotifierProvider(lazy: true, create: (context) => FolderProvider()), + ChangeNotifierProvider(lazy: true, create: (context) => McpProvider()), + ChangeNotifierProvider(lazy: true, create: (context) => PaymentMethodProvider()), ChangeNotifierProvider(create: (context) => LocaleProvider()), - ChangeNotifierProvider(create: (context) => VoiceRecorderProvider()..checkPendingRecording()), ChangeNotifierProvider(create: (context) => AnnouncementProvider()), - ChangeNotifierProvider(create: (context) => PhoneCallProvider()), + ChangeNotifierProvider(lazy: true, create: (context) => PhoneCallProvider()), ], builder: (context, child) { return WithForegroundTask( diff --git a/app/lib/pages/apps/add_app.dart b/app/lib/pages/apps/add_app.dart index 14ff11ebb3..adcf523c9c 100644 --- a/app/lib/pages/apps/add_app.dart +++ b/app/lib/pages/apps/add_app.dart @@ -744,8 +744,8 @@ class _AddAppPageState extends State { if (appId != null) { app = await context.read().getAppFromId(appId); } - var paymentProvider = context.read(); - paymentProvider.getPaymentMethodsStatus(); + var paymentProvider = PaymentMethodProvider(); + await paymentProvider.getPaymentMethodsStatus(); if (app != null && mounted && context.mounted) { if (app.isPaid && paymentProvider.activeMethod == null) { diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index 4cff8b775e..be5cf1b678 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -35,17 +35,32 @@ import 'package:omi/utils/other/temp.dart'; import 'package:omi/widgets/dialog.dart'; import 'package:omi/widgets/bottom_nav_bar.dart'; -class ChatPage extends StatefulWidget { +class ChatPage extends StatelessWidget { final bool isPivotBottom; final String? autoMessage; const ChatPage({super.key, this.isPivotBottom = false, this.autoMessage}); @override - State createState() => ChatPageState(); + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => VoiceRecorderProvider()..checkPendingRecording(), + child: _ChatPageView(isPivotBottom: isPivotBottom, autoMessage: autoMessage), + ); + } } -class ChatPageState extends State with AutomaticKeepAliveClientMixin { +class _ChatPageView extends StatefulWidget { + final bool isPivotBottom; + final String? autoMessage; + + const _ChatPageView({this.isPivotBottom = false, this.autoMessage}); + + @override + State<_ChatPageView> createState() => ChatPageState(); +} + +class ChatPageState extends State<_ChatPageView> with AutomaticKeepAliveClientMixin { TextEditingController textController = TextEditingController(); late ScrollController scrollController; late FocusNode textFieldFocusNode; @@ -209,113 +224,115 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { ], ) : provider.isClearingChat - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Colors.white)), - const SizedBox(height: 16), - Text(context.l10n.deletingMessages, style: const TextStyle(color: Colors.white)), - ], - ) - : (provider.messages.isEmpty) - ? Center( - child: Padding( - padding: const EdgeInsets.only(bottom: 32.0), - child: Text( - connectivityProvider.isConnected - ? context.l10n.noMessagesYet - : context.l10n.noInternetConnection, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - ) - : LayoutBuilder( - builder: (context, constraints) { - return Theme( - data: Theme.of(context).copyWith( - textSelectionTheme: TextSelectionThemeData( - selectionColor: Colors.white.withOpacity(0.3), - selectionHandleColor: Colors.blue, - ), - ), - child: ListView.builder( - shrinkWrap: false, - reverse: false, - controller: scrollController, - padding: const EdgeInsets.fromLTRB(18, 16, 18, 10), - itemCount: provider.messages.length, - itemBuilder: (context, chatIndex) { - if (!_hasInitialScrolled && provider.messages.isNotEmpty) { - _hasInitialScrolled = true; - SchedulerBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo(scrollController.position.maxScrollExtent); - } - }); - } - - final message = provider.messages[chatIndex]; - double topPadding = chatIndex == provider.messages.length - 1 ? 8 : 16; - double bottomPadding = chatIndex == 0 ? 16 : 0; - - return Padding( - key: ValueKey(message.id), - padding: EdgeInsets.only(bottom: bottomPadding, top: topPadding), - child: message.sender == MessageSender.ai - ? Builder( - builder: (context) { - final child = AIMessage( - showTypingIndicator: - provider.showTypingIndicator && - chatIndex == provider.messages.length - 1, - showThinkingAfterText: provider.agentThinkingAfterText, - message: message, - sendMessage: _sendMessageUtil, - onAskOmi: (text) { - setState(() { - _selectedContext = text; - }); - textFieldFocusNode.requestFocus(); - }, - displayOptions: provider.messages.length <= 1, - appSender: provider.messageSenderApp(message.appId), - updateConversation: (ServerConversation conversation) { - context.read().updateConversation(conversation); - }, - setMessageNps: (int value, {String? reason}) { - provider.setMessageNps(message, value, reason: reason); - }, - ); + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white)), + const SizedBox(height: 16), + Text(context.l10n.deletingMessages, style: const TextStyle(color: Colors.white)), + ], + ) + : (provider.messages.isEmpty) + ? Center( + child: Padding( + padding: const EdgeInsets.only(bottom: 32.0), + child: Text( + connectivityProvider.isConnected + ? context.l10n.noMessagesYet + : context.l10n.noInternetConnection, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + ) + : LayoutBuilder( + builder: (context, constraints) { + return Theme( + data: Theme.of(context).copyWith( + textSelectionTheme: TextSelectionThemeData( + selectionColor: Colors.white.withOpacity(0.3), + selectionHandleColor: Colors.blue, + ), + ), + child: ListView.builder( + shrinkWrap: false, + reverse: false, + controller: scrollController, + padding: const EdgeInsets.fromLTRB(18, 16, 18, 10), + itemCount: provider.messages.length, + itemBuilder: (context, chatIndex) { + if (!_hasInitialScrolled && provider.messages.isNotEmpty) { + _hasInitialScrolled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + } + }); + } - // Dynamic spacer logic - if (chatIndex == provider.messages.length - 1 && _allowSpacer) { - return Container( - constraints: BoxConstraints( - minHeight: MediaQuery.of(context).size.height * 0.5, + final message = provider.messages[chatIndex]; + double topPadding = chatIndex == provider.messages.length - 1 ? 8 : 16; + double bottomPadding = chatIndex == 0 ? 16 : 0; + + return Padding( + key: ValueKey(message.id), + padding: EdgeInsets.only(bottom: bottomPadding, top: topPadding), + child: message.sender == MessageSender.ai + ? Builder( + builder: (context) { + final child = AIMessage( + showTypingIndicator: provider.showTypingIndicator && + chatIndex == provider.messages.length - 1, + showThinkingAfterText: provider.agentThinkingAfterText, + message: message, + sendMessage: _sendMessageUtil, + onAskOmi: (text) { + setState(() { + _selectedContext = text; + }); + textFieldFocusNode.requestFocus(); + }, + displayOptions: provider.messages.length <= 1, + appSender: provider.messageSenderApp(message.appId), + updateConversation: (ServerConversation conversation) { + context + .read() + .updateConversation(conversation); + }, + setMessageNps: (int value, {String? reason}) { + provider.setMessageNps(message, value, reason: reason); + }, + ); + + // Dynamic spacer logic + if (chatIndex == provider.messages.length - 1 && _allowSpacer) { + return Container( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height * 0.5, + ), + alignment: Alignment.topLeft, + child: child, + ); + } + return child; + }, + ) + : HumanMessage( + message: message, + onAskOmi: (text) { + setState(() { + _selectedContext = text; + }); + textFieldFocusNode.requestFocus(); + }, ), - alignment: Alignment.topLeft, - child: child, - ); - } - return child; - }, - ) - : HumanMessage( - message: message, - onAskOmi: (text) { - setState(() { - _selectedContext = text; - }); - textFieldFocusNode.requestFocus(); - }, - ), - ); - }, - ), - ); - }, - ), + ); + }, + ), + ); + }, + ), ), // Send message area - fixed at bottom Container( @@ -437,9 +454,9 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { bottom: widget.isPivotBottom ? 6 : (textFieldFocusNode.hasFocus && - (textController.text.length > 40 || textController.text.contains('\n')) - ? 0 - : 2), + (textController.text.length > 40 || textController.text.contains('\n')) + ? 0 + : 2), ), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -615,8 +632,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { bool hasText = value.text.trim().isNotEmpty; if (!hasText) return const SizedBox.shrink(); - bool canSend = - hasText && + bool canSend = hasText && !provider.sendingMessage && !provider.isUploadingFiles && connectivityProvider.isConnected; @@ -1192,18 +1208,18 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { child: FaIcon(FontAwesomeIcons.solidCircleCheck, color: Colors.white, size: 18), ) : appId != null && onConfirmDelete != null - ? GestureDetector( - onTap: () { - setState(() { - _pendingDeleteAppId = appId; - }); - }, - child: const Padding( - padding: EdgeInsets.only(left: 2, top: 1), - child: FaIcon(FontAwesomeIcons.solidTrashCan, color: Colors.white38, size: 16), - ), - ) - : null, + ? GestureDetector( + onTap: () { + setState(() { + _pendingDeleteAppId = appId; + }); + }, + child: const Padding( + padding: EdgeInsets.only(left: 2, top: 1), + child: FaIcon(FontAwesomeIcons.solidTrashCan, color: Colors.white38, size: 16), + ), + ) + : null, selected: isSelected, selectedTileColor: Colors.white.withOpacity(0.1), onTap: onTap, diff --git a/app/lib/pages/settings/ai_app_generator_page.dart b/app/lib/pages/settings/ai_app_generator_page.dart index 6c6167cb90..6999a84c5a 100644 --- a/app/lib/pages/settings/ai_app_generator_page.dart +++ b/app/lib/pages/settings/ai_app_generator_page.dart @@ -13,14 +13,26 @@ import 'package:omi/providers/app_provider.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/other/temp.dart'; -class AiAppGeneratorPage extends StatefulWidget { +class AiAppGeneratorPage extends StatelessWidget { const AiAppGeneratorPage({super.key}); @override - State createState() => _AiAppGeneratorPageState(); + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => AiAppGeneratorProvider(), + child: const _AiAppGeneratorPageView(), + ); + } } -class _AiAppGeneratorPageState extends State { +class _AiAppGeneratorPageView extends StatefulWidget { + const _AiAppGeneratorPageView(); + + @override + State<_AiAppGeneratorPageView> createState() => _AiAppGeneratorPageState(); +} + +class _AiAppGeneratorPageState extends State<_AiAppGeneratorPageView> { final TextEditingController _promptController = TextEditingController(); final FocusNode _promptFocusNode = FocusNode(); bool _isDescriptionExpanded = false; @@ -327,27 +339,27 @@ class _AiAppGeneratorPageState extends State { color: isCompleted ? const Color(0xFF6366F1) : isActive - ? const Color(0xFF6366F1).withOpacity(0.2) - : const Color(0xFF2A2A2E), + ? const Color(0xFF6366F1).withOpacity(0.2) + : const Color(0xFF2A2A2E), border: isActive ? Border.all(color: const Color(0xFF6366F1), width: 2) : null, ), child: Center( child: isCompleted ? const FaIcon(FontAwesomeIcons.check, color: Colors.white, size: 12) : isActive - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), - ), - ) - : Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey.shade600), - ), + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), + ), + ) + : Container( + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey.shade600), + ), ), ), const SizedBox(width: 14), diff --git a/app/lib/pages/settings/calendar_settings_page.dart b/app/lib/pages/settings/calendar_settings_page.dart deleted file mode 100644 index 5ec22a7660..0000000000 --- a/app/lib/pages/settings/calendar_settings_page.dart +++ /dev/null @@ -1,556 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; - -import 'package:omi/backend/preferences.dart'; -import 'package:omi/providers/calendar_provider.dart'; -import 'package:omi/services/calendar_service.dart'; -import 'package:omi/utils/l10n_extensions.dart'; -import 'package:omi/utils/responsive/responsive_helper.dart'; - -class CalendarSettingsPage extends StatefulWidget { - const CalendarSettingsPage({super.key}); - - @override - State createState() => _CalendarSettingsPageState(); -} - -class _CalendarSettingsPageState extends State { - bool _showMenuBarMeetings = true; - bool _showEventsWithNoParticipants = false; - Set _enabledCalendarIds = {}; - - @override - void initState() { - super.initState(); - - // Load saved settings - _showMenuBarMeetings = SharedPreferencesUtil().showMeetingsInMenuBar; - _showEventsWithNoParticipants = SharedPreferencesUtil().showEventsWithNoParticipants; - - WidgetsBinding.instance.addPostFrameCallback((_) { - final provider = Provider.of(context, listen: false); - if (provider.isAuthorized) { - provider.fetchSystemCalendars(); - // Enable all calendars by default if none are saved - final savedIds = SharedPreferencesUtil().enabledCalendarIds; - setState(() { - if (savedIds.isEmpty && provider.systemCalendars.isNotEmpty) { - _enabledCalendarIds = provider.systemCalendars.map((c) => c.id).toSet(); - SharedPreferencesUtil().enabledCalendarIds = _enabledCalendarIds.toList(); - } else { - _enabledCalendarIds = savedIds.toSet(); - } - }); - } - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ResponsiveHelper.backgroundPrimary, - appBar: AppBar( - backgroundColor: ResponsiveHelper.backgroundPrimary, - elevation: 0, - title: Text( - context.l10n.calendarSettings, - style: const TextStyle(color: ResponsiveHelper.textPrimary, fontSize: 20, fontWeight: FontWeight.w600), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: ResponsiveHelper.textPrimary), - onPressed: () => Navigator.pop(context), - ), - ), - body: Consumer( - builder: (context, provider, child) { - return ListView( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), - children: [ - // Calendar Providers Section - _buildSectionHeader(context.l10n.calendarProviders), - const SizedBox(height: 12), - _buildCalendarProviderCard( - context, - icon: FontAwesomeIcons.calendar, - iconColor: const Color(0xFF5AC8FA), - title: context.l10n.macOsCalendar, - description: context.l10n.connectMacOsCalendar, - isEnabled: provider.isAuthorized && provider.isMonitoring, - onToggle: (value) async { - if (value) { - // If already authorized, just start monitoring - if (provider.isAuthorized) { - SharedPreferencesUtil().calendarIntegrationEnabled = true; - await provider.startMonitoring(); - } else { - // Otherwise request permission first - await provider.requestPermission(); - } - } else { - await provider.stopMonitoring(); - } - }, - ), - const SizedBox(height: 8), - _buildCalendarProviderCard( - context, - icon: FontAwesomeIcons.google, - iconColor: const Color(0xFF4285F4), - title: context.l10n.googleCalendar, - description: context.l10n.syncGoogleAccount, - isEnabled: false, - onToggle: (value) { - _showComingSoonToast(context); - }, - ), - - const SizedBox(height: 24), - - // Settings Section - _buildSettingsCard(context, [ - _buildSettingItem( - icon: FontAwesomeIcons.clock, - iconColor: ResponsiveHelper.textSecondary, - title: context.l10n.showMeetingsMenuBar, - description: context.l10n.showMeetingsMenuBarDesc, - value: _showMenuBarMeetings, - onChanged: (value) { - setState(() => _showMenuBarMeetings = value); - provider.updateShowMeetingsInMenuBar(value); - }, - ), - const Divider(height: 1, thickness: 1, color: ResponsiveHelper.backgroundTertiary), - _buildSettingItem( - icon: FontAwesomeIcons.calendarDay, - iconColor: ResponsiveHelper.textSecondary, - title: context.l10n.showEventsNoParticipants, - description: context.l10n.showEventsNoParticipantsDesc, - value: _showEventsWithNoParticipants, - onChanged: (value) { - setState(() => _showEventsWithNoParticipants = value); - provider.updateShowEventsWithNoParticipants(value); - }, - ), - ]), - - const SizedBox(height: 24), - - // Your Meetings Section - if (provider.isAuthorized) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildSectionHeader(context.l10n.yourMeetings), - if (provider.upcomingMeetings.isNotEmpty) - TextButton( - onPressed: () => provider.refreshMeetings(), - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - context.l10n.refresh, - style: const TextStyle(color: ResponsiveHelper.purplePrimary, fontSize: 13), - ), - ), - ], - ), - const SizedBox(height: 12), - _buildMeetingsList(provider), - ], - ], - ); - }, - ), - ); - } - - Widget _buildSectionHeader(String title) { - return Text( - title, - style: const TextStyle(color: ResponsiveHelper.textPrimary, fontSize: 16, fontWeight: FontWeight.w600), - ); - } - - Widget _buildCalendarProviderCard( - BuildContext context, { - required IconData icon, - required Color iconColor, - required String title, - required String description, - required bool isEnabled, - required ValueChanged onToggle, - }) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), width: 1), - ), - child: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration(color: iconColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8)), - child: Icon(icon, color: iconColor, size: 16), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: ResponsiveHelper.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text(description, style: const TextStyle(color: ResponsiveHelper.textSecondary, fontSize: 12)), - ], - ), - ), - const SizedBox(width: 10), - Transform.scale( - scale: 0.8, - child: CupertinoSwitch(value: isEnabled, onChanged: onToggle, activeColor: ResponsiveHelper.purplePrimary), - ), - ], - ), - ); - } - - Widget _buildSettingsCard(BuildContext context, List children) { - return Container( - decoration: BoxDecoration( - color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), width: 1), - ), - child: Column(children: children), - ); - } - - Widget _buildSettingItem({ - required IconData icon, - required Color iconColor, - required String title, - required String description, - required bool value, - required ValueChanged onChanged, - }) { - return Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration(color: iconColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8)), - child: Icon(icon, color: iconColor, size: 16), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: ResponsiveHelper.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text(description, style: const TextStyle(color: ResponsiveHelper.textSecondary, fontSize: 12)), - ], - ), - ), - const SizedBox(width: 10), - Transform.scale( - scale: 0.8, - child: CupertinoSwitch(value: value, onChanged: onChanged, activeColor: ResponsiveHelper.purplePrimary), - ), - ], - ), - ); - } - - Widget _buildCalendarsCard(CalendarProvider provider) { - return Container( - decoration: BoxDecoration( - color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), width: 1), - ), - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: provider.systemCalendars.length, - separatorBuilder: (context, index) => - const Divider(height: 1, thickness: 1, color: ResponsiveHelper.backgroundTertiary, indent: 50), - itemBuilder: (context, index) { - final calendar = provider.systemCalendars[index]; - final isEnabled = _enabledCalendarIds.contains(calendar.id); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - children: [ - // Color indicator - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: calendar.color ?? ResponsiveHelper.purplePrimary, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - // Calendar title - Expanded( - child: Text( - calendar.title, - style: const TextStyle( - color: ResponsiveHelper.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: 10), - // Toggle - Transform.scale( - scale: 0.8, - child: CupertinoSwitch( - value: isEnabled, - onChanged: (value) { - setState(() { - if (value) { - _enabledCalendarIds.add(calendar.id); - } else { - _enabledCalendarIds.remove(calendar.id); - } - SharedPreferencesUtil().enabledCalendarIds = _enabledCalendarIds.toList(); - }); - }, - activeColor: ResponsiveHelper.purplePrimary, - ), - ), - ], - ), - ); - }, - ), - ); - } - - Widget _buildMeetingsList(CalendarProvider provider) { - if (provider.upcomingMeetings.isEmpty) { - return Container( - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), width: 1), - ), - child: Column( - children: [ - const Icon(Icons.event_busy, color: ResponsiveHelper.textTertiary, size: 40), - const SizedBox(height: 12), - Text( - context.l10n.noUpcomingMeetings, - style: const TextStyle(color: ResponsiveHelper.textSecondary, fontSize: 14, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 6), - Text( - context.l10n.checkingNextDays, - style: const TextStyle(color: ResponsiveHelper.textTertiary, fontSize: 12), - ), - ], - ), - ); - } - - // Sort meetings by start time - final sortedMeetings = List.from(provider.upcomingMeetings) - ..sort((a, b) => a.startTime.compareTo(b.startTime)); - - // Group meetings by date - final groupedMeetings = >{}; - for (final meeting in sortedMeetings) { - final dateKey = DateTime(meeting.startTime.year, meeting.startTime.month, meeting.startTime.day); - groupedMeetings.putIfAbsent(dateKey, () => []).add(meeting); - } - - // Build list with date headers - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: groupedMeetings.entries.map((entry) { - final date = entry.key; - final meetings = entry.value; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Date header - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8, top: 12), - child: Text( - _formatDateHeader(date), - style: const TextStyle( - color: ResponsiveHelper.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - // Meetings for this date - Container( - decoration: BoxDecoration( - color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), width: 1), - ), - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: meetings.length, - separatorBuilder: (context, index) => const Divider( - height: 1, - thickness: 1, - color: ResponsiveHelper.backgroundTertiary, - indent: 14, - endIndent: 14, - ), - itemBuilder: (context, index) { - return _buildMeetingCard(meetings[index]); - }, - ), - ), - ], - ); - }).toList(), - ); - } - - String _formatDateHeader(DateTime date) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final tomorrow = today.add(const Duration(days: 1)); - - if (date == today) { - return context.l10n.today; - } else if (date == tomorrow) { - return context.l10n.tomorrow; - } else { - // Show full date for other days - return DateFormat('EEEE, MMMM d', Localizations.localeOf(context).languageCode).format(date); - } - } - - Widget _buildMeetingCard(CalendarMeeting meeting) { - final dateFormat = DateFormat('h:mm a', Localizations.localeOf(context).languageCode); - final duration = meeting.endTime.difference(meeting.startTime); - final durationString = '${duration.inMinutes} min'; - - return Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: 3, - height: 36, - decoration: BoxDecoration( - color: _getPlatformColor(meeting.platform), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - meeting.title, - style: const TextStyle( - color: ResponsiveHelper.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Row( - children: [ - Text( - '${dateFormat.format(meeting.startTime)} • $durationString', - style: const TextStyle(color: ResponsiveHelper.textSecondary, fontSize: 12), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _getPlatformColor(meeting.platform).withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - meeting.platform, - style: TextStyle( - color: _getPlatformColor(meeting.platform), - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Color _getPlatformColor(String platform) { - switch (platform.toLowerCase()) { - case 'zoom': - return const Color(0xFF2D8CFF); - case 'google meet': - return const Color(0xFF00AC47); - case 'teams': - return const Color(0xFF6264A7); - case 'slack': - return const Color(0xFF4A154B); - default: - return ResponsiveHelper.purplePrimary; - } - } - - void _showComingSoonToast(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.googleCalendarComingSoon, - style: const TextStyle(color: ResponsiveHelper.textPrimary), - ), - backgroundColor: ResponsiveHelper.backgroundTertiary, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - duration: const Duration(seconds: 2), - ), - ); - } -} diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index 3cebc7053c..a023e91826 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -32,14 +32,26 @@ import 'package:omi/utils/debug_log_manager.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/logger.dart'; -class DeveloperSettingsPage extends StatefulWidget { +class DeveloperSettingsPage extends StatelessWidget { const DeveloperSettingsPage({super.key}); @override - State createState() => _DeveloperSettingsPageState(); + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => DeveloperModeProvider()..initialize(), + child: const _DeveloperSettingsPageView(), + ); + } +} + +class _DeveloperSettingsPageView extends StatefulWidget { + const _DeveloperSettingsPageView(); + + @override + State<_DeveloperSettingsPageView> createState() => _DeveloperSettingsPageState(); } -class _DeveloperSettingsPageState extends State { +class _DeveloperSettingsPageState extends State<_DeveloperSettingsPageView> { @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) async { diff --git a/app/lib/providers/calendar_provider.dart b/app/lib/providers/calendar_provider.dart deleted file mode 100644 index c407178b39..0000000000 --- a/app/lib/providers/calendar_provider.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; - -import 'package:omi/backend/http/api/calendar_meetings.dart' as calendar_api; -import 'package:omi/backend/preferences.dart'; -import 'package:omi/backend/schema/calendar_meeting_context.dart'; -import 'package:omi/services/calendar_service.dart'; -import 'package:omi/utils/logger.dart'; - -class CalendarProvider extends ChangeNotifier { - final CalendarService _service = CalendarService(); - - // State - CalendarPermissionStatus _permissionStatus = CalendarPermissionStatus.notDetermined; - bool _isMonitoring = false; - List _upcomingMeetings = []; - List _systemCalendars = []; - bool _isLoading = false; - bool _isSyncing = false; - - // Getters - CalendarPermissionStatus get permissionStatus => _permissionStatus; - bool get isMonitoring => _isMonitoring; - - List get upcomingMeetings => _upcomingMeetings; - List get systemCalendars => _systemCalendars; - bool get isLoading => _isLoading; - bool get isAuthorized => _permissionStatus == CalendarPermissionStatus.authorized; - - /// Returns meetings in the immediate window (next 60 minutes or currently in progress) - List get immediateMeetings { - return _upcomingMeetings.where((meeting) { - final minutesUntilStart = meeting.minutesUntilStart; - final hasEnded = meeting.hasEnded; - // Include if starting in next 60 minutes or currently in progress - return (minutesUntilStart >= -5 && minutesUntilStart <= 60) || (meeting.hasStarted && !hasEnded); - }).toList(); - } - - /// Returns the currently active meeting (started within last 5 min or starting in next 5 min) - CalendarMeeting? get activeMeeting { - return _upcomingMeetings.firstWhereOrNull((meeting) { - final minutesUntilStart = meeting.minutesUntilStart; - final hasStarted = meeting.hasStarted; - final hasEnded = meeting.hasEnded; - - // Meeting is active if: - // - Starting in the next 5 minutes, OR - // - Started but not ended yet (currently in progress) - return (minutesUntilStart >= -5 && minutesUntilStart <= 5) || (hasStarted && !hasEnded); - }); - } - - CalendarProvider() { - _init(); - } - - Future _init() async { - // Calendar integration was only supported on macOS (now removed) - return; - } - - Future _applySavedSettings() async { - // Apply saved settings to calendar monitor - await _service.updateSettings( - showEventsWithNoParticipants: SharedPreferencesUtil().showEventsWithNoParticipants, - showMeetingsInMenuBar: SharedPreferencesUtil().showMeetingsInMenuBar, - ); - } - - Future updateShowEventsWithNoParticipants(bool value) async { - SharedPreferencesUtil().showEventsWithNoParticipants = value; - await _service.updateSettings(showEventsWithNoParticipants: value); - await refreshMeetings(); - } - - Future updateShowMeetingsInMenuBar(bool value) async { - SharedPreferencesUtil().showMeetingsInMenuBar = value; - await _service.updateSettings(showMeetingsInMenuBar: value); - } - - Future checkPermissionStatus() async { - _permissionStatus = await _service.checkPermissionStatus(); - notifyListeners(); - } - - Future requestPermission() async { - _isLoading = true; - notifyListeners(); - - try { - _permissionStatus = await _service.requestPermission(); - if (isAuthorized) { - // Mark calendar as enabled when user grants permission - SharedPreferencesUtil().calendarIntegrationEnabled = true; - await _applySavedSettings(); - await startMonitoring(); - await fetchSystemCalendars(); - await refreshMeetings(); - } - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future startMonitoring() async { - if (!isAuthorized) return; - - await _service.startMonitoring(); - _isMonitoring = true; - - // Listen to events - _service.initialize(onMeetingEvent: _handleMeetingEvent); - - // Initial fetch - await refreshMeetings(); - - notifyListeners(); - } - - Future stopMonitoring() async { - await _service.stopMonitoring(); - _service.dispose(); - _isMonitoring = false; - // Clear enabled flag when user disables calendar - SharedPreferencesUtil().calendarIntegrationEnabled = false; - notifyListeners(); - } - - Future refreshMeetings() async { - if (!isAuthorized) return; - - // Get fresh meetings from calendar - final freshMeetings = await _service.getUpcomingMeetings(); - - // Preserve meetingId from previous syncs - final meetingIdMap = { - for (var m in _upcomingMeetings) - if (m.meetingId != null) m.id: m.meetingId, - }; - - // Update meetings list, preserving meetingIds - _upcomingMeetings = freshMeetings.map((meeting) { - final existingMeetingId = meetingIdMap[meeting.id]; - if (existingMeetingId != null) { - return meeting.copyWith(meetingId: existingMeetingId); - } - return meeting; - }).toList(); - - // Sync meetings to backend (in background, don't block UI) - _syncMeetingsToBackend(); - - notifyListeners(); - } - - Future _syncMeetingsToBackend() async { - // Prevent concurrent syncs - if already syncing, skip this call - if (_isSyncing) { - Logger.debug('CalendarProvider: Sync already in progress, skipping'); - return; - } - - _isSyncing = true; - try { - // Build a map of already synced event IDs to avoid re-syncing on every refresh - final alreadySyncedIds = _upcomingMeetings.where((m) => m.meetingId != null).map((m) => m.id).toSet(); - - Logger.debug( - 'CalendarProvider: Syncing ${_upcomingMeetings.length} meetings (${alreadySyncedIds.length} already synced)', - ); - - for (final meeting in _upcomingMeetings) { - // Skip if we've already synced this calendar event in this session - if (alreadySyncedIds.contains(meeting.id)) { - continue; - } - - try { - // Convert participants - final participants = meeting.participants.map((p) { - return MeetingParticipant(name: p.name, email: p.email); - }).toList(); - - // Store meeting in backend (backend handles create vs update based on calendar_event_id) - final response = await calendar_api.storeMeeting( - calendarEventId: meeting.id, - calendarSource: 'macos_calendar', // TODO: Detect source dynamically - title: meeting.title, - startTime: meeting.startTime, - endTime: meeting.endTime, - platform: meeting.platform, - meetingLink: meeting.meetingUrl, - participants: participants, - notes: meeting.notes, - ); - - if (response != null) { - // Update local meeting with backend meeting_id to mark as synced - final index = _upcomingMeetings.indexWhere((m) => m.id == meeting.id); - if (index != -1) { - _upcomingMeetings[index] = meeting.copyWith(meetingId: response.meetingId); - } - } - } catch (e) { - Logger.debug('CalendarProvider: Error syncing meeting ${meeting.id}: $e'); - // Continue with other meetings even if one fails - } - } - notifyListeners(); - } finally { - _isSyncing = false; - } - } - - Future fetchSystemCalendars() async { - if (!isAuthorized) return; - - _systemCalendars = await _service.getAvailableCalendars(); - notifyListeners(); - } - - void _handleMeetingEvent(CalendarMeetingEvent event) { - // Refresh list when events happen - refreshMeetings(); - } - - @override - void dispose() { - _service.dispose(); - super.dispose(); - } -} diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index 92c07c0a7d..77736fb704 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -26,7 +26,6 @@ import 'package:omi/backend/schema/structured.dart'; import 'package:omi/backend/schema/transcript_segment.dart'; import 'package:omi/models/custom_stt_config.dart'; import 'package:omi/models/stt_provider.dart'; -import 'package:omi/providers/calendar_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/message_provider.dart'; import 'package:omi/providers/people_provider.dart'; @@ -68,7 +67,6 @@ class CaptureProvider extends ChangeNotifier MessageProvider? messageProvider; PeopleProvider? peopleProvider; UsageProvider? usageProvider; - CalendarProvider? calendarProvider; // Cache refresh for backend-created persons Future? _peopleRefreshFuture; @@ -399,9 +397,8 @@ class CaptureProvider extends ChangeNotifier Logger.debug('Initiating WebSocket with: codec=$codec, sampleRate=$sampleRate, channels=$channels, isPcm=$isPcm'); // Get language and custom STT config - String language = SharedPreferencesUtil().hasSetPrimaryLanguage - ? SharedPreferencesUtil().userPrimaryLanguage - : "multi"; + String language = + SharedPreferencesUtil().hasSetPrimaryLanguage ? SharedPreferencesUtil().userPrimaryLanguage : "multi"; final customSttConfig = SharedPreferencesUtil().customSttConfig; Logger.debug('Custom STT enabled: ${customSttConfig.isEnabled}, provider: ${customSttConfig.provider}'); @@ -415,13 +412,13 @@ class CaptureProvider extends ChangeNotifier // Connect to the transcript socket _socket = await ServiceManager.instance().socket.conversation( - codec: codec, - sampleRate: sampleRate, - language: language, - force: force, - source: source, - customSttConfig: effectiveConfig, - ); + codec: codec, + sampleRate: sampleRate, + language: language, + force: force, + source: source, + customSttConfig: effectiveConfig, + ); if (_socket == null) { _startKeepAliveServices(); Logger.debug("Can not create new conversation socket"); @@ -514,24 +511,20 @@ class CaptureProvider extends ChangeNotifier _isProcessingButtonEvent = true; if (_isPaused) { MixpanelManager().omiDoubleTap(feature: 'unmute'); - resumeDeviceRecording() - .then((_) { - _isProcessingButtonEvent = false; - }) - .catchError((e) { - Logger.debug("Error resuming device recording: $e"); - _isProcessingButtonEvent = false; - }); + resumeDeviceRecording().then((_) { + _isProcessingButtonEvent = false; + }).catchError((e) { + Logger.debug("Error resuming device recording: $e"); + _isProcessingButtonEvent = false; + }); } else { MixpanelManager().omiDoubleTap(feature: 'mute'); - pauseDeviceRecording() - .then((_) { - _isProcessingButtonEvent = false; - }) - .catchError((e) { - Logger.debug("Error pausing device recording: $e"); - _isProcessingButtonEvent = false; - }); + pauseDeviceRecording().then((_) { + _isProcessingButtonEvent = false; + }).catchError((e) { + Logger.debug("Error pausing device recording: $e"); + _isProcessingButtonEvent = false; + }); } } else if (doubleTapAction == 2) { // Star ongoing conversation (doesn't end it) @@ -619,8 +612,8 @@ class CaptureProvider extends ChangeNotifier } // Local storage syncs - var checkWalSupported = - (_recordingDevice?.type == DeviceType.omi || _recordingDevice?.type == DeviceType.openglass) && + var checkWalSupported = (_recordingDevice?.type == DeviceType.omi || + _recordingDevice?.type == DeviceType.openglass) && codec.isOpusSupported() && (_socket?.state != SocketServiceState.connected || SharedPreferencesUtil().unlimitedLocalStorageEnabled); if (checkWalSupported != _isWalSupported) { @@ -723,9 +716,8 @@ class CaptureProvider extends ChangeNotifier return; } BleAudioCodec codec = await _getAudioCodec(_recordingDevice!.id); - var language = SharedPreferencesUtil().hasSetPrimaryLanguage - ? SharedPreferencesUtil().userPrimaryLanguage - : "multi"; + var language = + SharedPreferencesUtil().hasSetPrimaryLanguage ? SharedPreferencesUtil().userPrimaryLanguage : "multi"; final customSttConfig = SharedPreferencesUtil().customSttConfig; final sttConfigId = customSttConfig.sttConfigId; diff --git a/app/lib/services/calendar_service.dart b/app/lib/services/calendar_service.dart deleted file mode 100644 index d55bfb2f0f..0000000000 --- a/app/lib/services/calendar_service.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/services.dart'; - -class CalendarService { - static const MethodChannel _methodChannel = MethodChannel('com.omi/calendar'); - static const EventChannel _eventChannel = EventChannel('com.omi/calendar/events'); - - Stream? _calendarStream; - StreamSubscription? _streamSubscription; - - /// Request calendar access permission - Future requestPermission() async { - try { - final String? result = await _methodChannel.invokeMethod('requestPermission'); - return _parsePermissionStatus(result); - } catch (e) { - print('CalendarService: Error requesting permission: $e'); - return CalendarPermissionStatus.denied; - } - } - - /// Check current permission status - Future checkPermissionStatus() async { - try { - final String? result = await _methodChannel.invokeMethod('checkPermissionStatus'); - return _parsePermissionStatus(result); - } catch (e) { - print('CalendarService: Error checking permission status: $e'); - return CalendarPermissionStatus.notDetermined; - } - } - - /// Start monitoring calendar events - Future startMonitoring() async { - try { - await _methodChannel.invokeMethod('startMonitoring'); - print('CalendarService: Started monitoring'); - } catch (e) { - print('CalendarService: Error starting monitoring: $e'); - } - } - - /// Stop monitoring calendar events - Future stopMonitoring() async { - try { - await _methodChannel.invokeMethod('stopMonitoring'); - print('CalendarService: Stopped monitoring'); - } catch (e) { - print('CalendarService: Error stopping monitoring: $e'); - } - } - - /// Get list of upcoming meetings - Future> getUpcomingMeetings() async { - try { - final List? result = await _methodChannel.invokeMethod('getUpcomingMeetings'); - if (result == null) return []; - - return result.map((item) => CalendarMeeting.fromMap(item as Map)).toList(); - } catch (e) { - print('CalendarService: Error getting upcoming meetings: $e'); - return []; - } - } - - /// Get all available calendars from the system - Future> getAvailableCalendars() async { - try { - final result = await _methodChannel.invokeMethod('getAvailableCalendars'); - final calendars = (result as List).map((calendar) { - return SystemCalendar.fromMap(calendar as Map); - }).toList(); - return calendars; - } catch (e) { - print('CalendarService: Error getting available calendars: $e'); - return []; - } - } - - /// Update calendar settings - Future updateSettings({bool? showEventsWithNoParticipants, bool? showMeetingsInMenuBar}) async { - try { - final args = {}; - if (showEventsWithNoParticipants != null) { - args['showEventsWithNoParticipants'] = showEventsWithNoParticipants; - } - if (showMeetingsInMenuBar != null) { - args['showMeetingsInMenuBar'] = showMeetingsInMenuBar; - } - - await _methodChannel.invokeMethod('updateCalendarSettings', args); - } catch (e) { - print('CalendarService: Error updating settings: $e'); - } - } - - /// Snooze a meeting notification - Future snoozeMeeting(String eventId, int minutes) async { - try { - await _methodChannel.invokeMethod('snoozeMeeting', {'eventId': eventId, 'minutes': minutes}); - print('CalendarService: Snoozed meeting $eventId for $minutes minutes'); - } catch (e) { - print('CalendarService: Error snoozing meeting: $e'); - } - } - - /// Stream of calendar meeting events - Stream get meetingStream { - _calendarStream ??= _eventChannel.receiveBroadcastStream().map((event) { - return CalendarMeetingEvent.fromMap(event as Map); - }); - return _calendarStream!; - } - - /// Initialize and listen to calendar events - void initialize({Function(CalendarMeetingEvent)? onMeetingEvent}) { - _streamSubscription = meetingStream.listen( - (event) { - print('CalendarService: Received event: ${event.type} - ${event.title}'); - onMeetingEvent?.call(event); - }, - onError: (error) { - print('CalendarService: Stream error: $error'); - }, - ); - } - - /// Cleanup - void dispose() { - _streamSubscription?.cancel(); - _streamSubscription = null; - } - - CalendarPermissionStatus _parsePermissionStatus(String? status) { - switch (status) { - case 'authorized': - return CalendarPermissionStatus.authorized; - case 'denied': - return CalendarPermissionStatus.denied; - case 'restricted': - return CalendarPermissionStatus.restricted; - default: - return CalendarPermissionStatus.notDetermined; - } - } -} - -/// Participant in a calendar meeting -class CalendarParticipant { - final String? name; - final String? email; - - CalendarParticipant({this.name, this.email}); - - factory CalendarParticipant.fromMap(Map map) { - return CalendarParticipant(name: map['name'] as String?, email: map['email'] as String?); - } - - String get displayName { - if (name != null && name!.isNotEmpty) return name!; - if (email != null && email!.isNotEmpty) return email!; - return 'Unknown'; - } -} - -/// Calendar meeting model -class CalendarMeeting { - final String id; // Calendar event ID from system (macOS) - final String title; - final DateTime startTime; - final DateTime endTime; - final String platform; - final String? meetingUrl; - final int attendeeCount; - final List participants; - final String? notes; - final String? meetingId; // Backend meeting ID (synced to backend) - - CalendarMeeting({ - required this.id, - required this.title, - required this.startTime, - required this.endTime, - required this.platform, - this.meetingUrl, - required this.attendeeCount, - this.participants = const [], - this.notes, - this.meetingId, - }); - - factory CalendarMeeting.fromMap(Map map) { - final participantsList = - (map['participants'] as List?) - ?.map((p) => CalendarParticipant.fromMap(p as Map)) - .toList() ?? - []; - - return CalendarMeeting( - id: map['id'] as String, - title: map['title'] as String, - startTime: DateTime.parse(map['startTime'] as String).toLocal(), - endTime: DateTime.parse(map['endTime'] as String).toLocal(), - platform: map['platform'] as String, - meetingUrl: map['meetingUrl'] as String?, - attendeeCount: map['attendeeCount'] as int, - participants: participantsList, - notes: map['notes'] as String?, - ); - } - - Map toMap() { - return { - 'id': id, - 'title': title, - 'startTime': startTime.toUtc().toIso8601String(), - 'endTime': endTime.toUtc().toIso8601String(), - 'platform': platform, - 'meetingUrl': meetingUrl, - 'attendeeCount': attendeeCount, - 'participants': participants - .map((p) => {if (p.name != null) 'name': p.name, if (p.email != null) 'email': p.email}) - .toList(), - if (notes != null) 'notes': notes, - if (meetingId != null) 'meetingId': meetingId, - }; - } - - CalendarMeeting copyWith({ - String? id, - String? title, - DateTime? startTime, - DateTime? endTime, - String? platform, - String? meetingUrl, - int? attendeeCount, - List? participants, - String? notes, - String? meetingId, - }) { - return CalendarMeeting( - id: id ?? this.id, - title: title ?? this.title, - startTime: startTime ?? this.startTime, - endTime: endTime ?? this.endTime, - platform: platform ?? this.platform, - meetingUrl: meetingUrl ?? this.meetingUrl, - attendeeCount: attendeeCount ?? this.attendeeCount, - participants: participants ?? this.participants, - notes: notes ?? this.notes, - meetingId: meetingId ?? this.meetingId, - ); - } - - /// Time until meeting starts (negative if already started) - Duration get timeUntilStart => startTime.difference(DateTime.now()); - - /// Minutes until meeting starts - int get minutesUntilStart => timeUntilStart.inMinutes; - - /// Whether meeting has started - bool get hasStarted => DateTime.now().isAfter(startTime); - - /// Whether meeting has ended - bool get hasEnded => DateTime.now().isAfter(endTime); - - /// Whether meeting is currently active - bool get isActive => hasStarted && !hasEnded; - - @override - String toString() { - return 'CalendarMeeting(title: $title, platform: $platform, starts: $startTime)'; - } -} - -/// Calendar meeting event from event stream -class CalendarMeetingEvent { - final CalendarMeetingEventType type; - final String eventId; - final String title; - final String platform; - final DateTime? startTime; - final int? minutesUntilStart; - - CalendarMeetingEvent({ - required this.type, - required this.eventId, - required this.title, - required this.platform, - this.startTime, - this.minutesUntilStart, - }); - - factory CalendarMeetingEvent.fromMap(Map map) { - return CalendarMeetingEvent( - type: _parseEventType(map['type'] as String), - eventId: map['eventId'] as String? ?? '', - title: map['title'] as String? ?? '', - platform: map['platform'] as String? ?? '', - startTime: map['startTime'] != null ? DateTime.parse(map['startTime'] as String).toLocal() : null, - minutesUntilStart: map['minutesUntilStart'] as int?, - ); - } - - static CalendarMeetingEventType _parseEventType(String type) { - switch (type) { - case 'upcomingSoon': - return CalendarMeetingEventType.upcomingSoon; - case 'started': - return CalendarMeetingEventType.started; - case 'ended': - return CalendarMeetingEventType.ended; - case 'meetingsUpdated': - return CalendarMeetingEventType.meetingsUpdated; - default: - return CalendarMeetingEventType.upcomingSoon; - } - } - - @override - String toString() { - return 'CalendarMeetingEvent(type: $type, title: $title, platform: $platform, minutesUntilStart: $minutesUntilStart)'; - } -} - -/// System calendar from macOS Calendar -class SystemCalendar { - final String id; - final String title; - final int type; - final bool isSubscribed; - final Color? color; - - SystemCalendar({required this.id, required this.title, required this.type, required this.isSubscribed, this.color}); - - factory SystemCalendar.fromMap(Map map) { - Color? calendarColor; - if (map['colorRed'] != null && map['colorGreen'] != null && map['colorBlue'] != null) { - calendarColor = Color.fromRGBO(map['colorRed'] as int, map['colorGreen'] as int, map['colorBlue'] as int, 1.0); - } - - return SystemCalendar( - id: map['id'] as String, - title: map['title'] as String, - type: map['type'] as int, - isSubscribed: map['isSubscribed'] as bool, - color: calendarColor, - ); - } - - @override - String toString() { - return 'SystemCalendar(title: $title, id: $id)'; - } -} - -/// Types of calendar meeting events -enum CalendarMeetingEventType { - upcomingSoon, // Meeting starting in 2-5 minutes - started, // Meeting just started - ended, // Meeting ended - meetingsUpdated, // Meetings list was refreshed/updated -} - -/// Calendar permission status -enum CalendarPermissionStatus { - authorized, // User granted permission - denied, // User denied permission - notDetermined, // Permission not yet requested - restricted, // Permission restricted (parental controls, etc.) -} - -/// Extension for user-friendly status messages -extension CalendarPermissionStatusExtension on CalendarPermissionStatus { - String get displayName { - switch (this) { - case CalendarPermissionStatus.authorized: - return 'Authorized'; - case CalendarPermissionStatus.denied: - return 'Denied'; - case CalendarPermissionStatus.notDetermined: - return 'Not Determined'; - case CalendarPermissionStatus.restricted: - return 'Restricted'; - } - } - - bool get isGranted => this == CalendarPermissionStatus.authorized; -} diff --git a/app/lib/utils/analytics/mixpanel.dart b/app/lib/utils/analytics/mixpanel.dart index 7f5f7fabce..b27c47efbb 100644 --- a/app/lib/utils/analytics/mixpanel.dart +++ b/app/lib/utils/analytics/mixpanel.dart @@ -155,12 +155,12 @@ class MixpanelManager { track('User Acquisition Source', properties: {'source': source}); void settingsSaved({bool hasWebhookConversationCreated = false, bool hasWebhookTranscriptReceived = false}) => track( - 'Developer Settings Saved', - properties: { - 'has_webhook_memory_created': hasWebhookConversationCreated, - 'has_webhook_transcript_received': hasWebhookTranscriptReceived, - }, - ); + 'Developer Settings Saved', + properties: { + 'has_webhook_memory_created': hasWebhookConversationCreated, + 'has_webhook_transcript_received': hasWebhookTranscriptReceived, + }, + ); void pageOpened(String name) => track('$name Opened'); @@ -247,8 +247,6 @@ class MixpanelManager { void calendarModePressed(String mode) => track('Calendar Mode $mode Pressed'); - void calendarTypeChanged(String type) => track('Calendar Type Changed', properties: {'type': type}); - void calendarSelected() => track('Calendar Selected'); void bottomNavigationTabClicked(String tab) => track('Bottom Navigation Tab Clicked', properties: {'tab': tab}); @@ -370,18 +368,19 @@ class MixpanelManager { required String chatTargetId, required bool isPersonaChat, required bool isVoiceInput, - }) => track( - 'Chat Message Sent', - properties: { - 'message_length': message.length, - 'message_word_count': message.split(' ').length, - 'includes_files': includesFiles, - 'number_of_files': numberOfFiles, - 'chat_target_id': chatTargetId, - 'is_persona_chat': isPersonaChat, - 'is_voice_input': isVoiceInput, - }, - ); + }) => + track( + 'Chat Message Sent', + properties: { + 'message_length': message.length, + 'message_word_count': message.split(' ').length, + 'includes_files': includesFiles, + 'number_of_files': numberOfFiles, + 'chat_target_id': chatTargetId, + 'is_persona_chat': isPersonaChat, + 'is_voice_input': isVoiceInput, + }, + ); void chatVoiceInputUsed({required String chatTargetId, required bool isPersonaChat}) { track('Chat Voice Input Used', properties: {'chat_target_id': chatTargetId, 'is_persona_chat': isPersonaChat}); @@ -402,9 +401,9 @@ class MixpanelManager { track('Show Discarded Conversations Toggled', properties: {'show_discarded': showDiscarded}); void shortConversationThresholdChanged(int thresholdSeconds) => track( - 'Short Conversation Threshold Changed', - properties: {'threshold_seconds': thresholdSeconds, 'threshold_minutes': thresholdSeconds ~/ 60}, - ); + 'Short Conversation Threshold Changed', + properties: {'threshold_seconds': thresholdSeconds, 'threshold_minutes': thresholdSeconds ~/ 60}, + ); // Conversation Merge Events void conversationMergeSelectionModeEntered() => track('Conversation Merge Selection Mode Entered'); @@ -412,28 +411,28 @@ class MixpanelManager { void conversationMergeSelectionModeExited() => track('Conversation Merge Selection Mode Exited'); void conversationSelectedForMerge(String conversationId, int totalSelected) => track( - 'Conversation Selected For Merge', - properties: {'conversation_id': conversationId, 'total_selected': totalSelected}, - ); + 'Conversation Selected For Merge', + properties: {'conversation_id': conversationId, 'total_selected': totalSelected}, + ); void conversationMergeInitiated(List conversationIds) => track( - 'Conversation Merge Initiated', - properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, - ); + 'Conversation Merge Initiated', + properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, + ); void conversationMergeCompleted(String mergedConversationId, List removedConversationIds) => track( - 'Conversation Merge Completed', - properties: { - 'merged_conversation_id': mergedConversationId, - 'removed_count': removedConversationIds.length, - 'removed_conversation_ids': removedConversationIds, - }, - ); + 'Conversation Merge Completed', + properties: { + 'merged_conversation_id': mergedConversationId, + 'removed_count': removedConversationIds.length, + 'removed_conversation_ids': removedConversationIds, + }, + ); void conversationMergeFailed(List conversationIds) => track( - 'Conversation Merge Failed', - properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, - ); + 'Conversation Merge Failed', + properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, + ); // Important Conversation Share Events void importantConversationNotificationReceived(String conversationId) => @@ -443,14 +442,14 @@ class MixpanelManager { track('Share To Contacts Sheet Opened', properties: {'conversation_id': conversationId}); void shareToContactsSelected(String conversationId, int contactCount) => track( - 'Share To Contacts Selected', - properties: {'conversation_id': conversationId, 'contact_count': contactCount}, - ); + 'Share To Contacts Selected', + properties: {'conversation_id': conversationId, 'contact_count': contactCount}, + ); void shareToContactsSmsOpened(String conversationId, int contactCount) => track( - 'Share To Contacts SMS Opened', - properties: {'conversation_id': conversationId, 'contact_count': contactCount}, - ); + 'Share To Contacts SMS Opened', + properties: {'conversation_id': conversationId, 'contact_count': contactCount}, + ); void chatMessageConversationClicked(ServerConversation conversation) => track('Chat Message Memory Clicked', properties: getConversationEventProperties(conversation)); @@ -565,12 +564,12 @@ class MixpanelManager { void deleteAccountCancelled() => track('Delete Account Cancelled'); void deleteUser() => PlatformService.executeIfSupported(PlatformService.isMixpanelSupported, () { - if (PlatformService.isMixpanelNativelySupported) { - _mixpanel?.getPeople().deleteUser(); - } else { - _mixpanelAnalytics?.engage(operation: MixpanelUpdateOperations.$delete, value: {}); - } - }); + if (PlatformService.isMixpanelNativelySupported) { + _mixpanel?.getPeople().deleteUser(); + } else { + _mixpanelAnalytics?.engage(operation: MixpanelUpdateOperations.$delete, value: {}); + } + }); // Apps Filter void appsFilterOpened() => track('Apps Filter Opened');