diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5de857a..72c9895 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FEF6B413DA9FC937656B660D /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -362,13 +471,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.front-ai-smarties"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -378,6 +488,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 807E81FEBDA1B3C207D2F02D /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +506,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E805B90C1540E73EE0890817 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +522,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 96D8DD3C040343A039F310FE /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -541,13 +654,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.front-ai-smarties"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -563,13 +677,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.front-ai-smarties"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7788352..010caf8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,10 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCalendarsFullAccessUsageDescription + Access most functions for calendar viewing. + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +47,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index c788fa8..86b3ded 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -7,6 +7,7 @@ import '../services/websocket_service.dart'; import '../services/phone_audio_service.dart'; import 'login_screen.dart'; import 'register_screen.dart'; +import '../services/calendar_service.dart'; import 'dart:async'; /// Landing screen of the app. Manages BLE glasses connection, @@ -32,6 +33,7 @@ class _LandingScreenState extends State { late final Lc3Decoder _decoder; late final WebsocketService _ws; late final AudioPipeline _audioPipeline; + late final CalendarService _calendarService; late final PhoneAudioService _phoneAudio; bool _usePhoneMic = false; @@ -48,6 +50,7 @@ class _LandingScreenState extends State { _manager = widget.manager ?? G1Manager(); _decoder = widget.decoder ?? Lc3Decoder(); _ws = widget.ws ?? WebsocketService(); + _calendarService = CalendarService(); _audioPipeline = widget.audioPipeline ?? AudioPipeline( _manager, @@ -275,6 +278,26 @@ class _LandingScreenState extends State { Expanded( child: GlassesConnection( manager: _manager, + onRecordToggle: () async { + if (!_manager.transcription.isActive.value) { + final granted = + await _calendarService.requestPermission(); + if (granted) { + final events = await _calendarService + .getUpcomingEvents(); + final activeEvent = _calendarService + .selectActiveContext(events); + if (activeEvent != null) { + final payload = _calendarService + .buildCalendarPayload(activeEvent); + _ws.sendCalendarContext(payload); + } + } + await _startTranscription(); + } else { + await _stopTranscription(); + } + }, ), ), diff --git a/lib/services/calendar_service.dart b/lib/services/calendar_service.dart new file mode 100644 index 0000000..2eabc15 --- /dev/null +++ b/lib/services/calendar_service.dart @@ -0,0 +1,113 @@ +import 'package:device_calendar/device_calendar.dart'; + +class CalendarService { + final DeviceCalendarPlugin _calendarPlugin = DeviceCalendarPlugin(); + + //Requests calendar permission + Future requestPermission() async { + var permissionsGranted = await _calendarPlugin.requestPermissions(); + if (permissionsGranted.isSuccess && permissionsGranted.data == true) { + return true; + // Permission granted, you can now access the calendar + } else { + return false; + // Permission denied, handle accordingly + } + } + + //Searches for upcoming events in the next 7 days + Future> getUpcomingEvents() async { + var calendarResult = await _calendarPlugin.retrieveCalendars(); + if (calendarResult.isSuccess && calendarResult.data != null) { + List calendars = calendarResult.data!; + List events = []; + + DateTime startDate = DateTime.now(); + DateTime endDate = startDate.add(const Duration(days: 7)); + + for (var calendar in calendars) { + var eventResult = await _calendarPlugin.retrieveEvents( + calendar.id!, + RetrieveEventsParams(startDate: startDate, endDate: endDate), + ); + + if (eventResult.isSuccess && eventResult.data != null) { + List calendarEvents = eventResult.data!; + for (var event in calendarEvents) { + if (event.start != null && event.end != null) { + events.add( + CalendarEventModel( + title: event.title ?? 'No Title', + description: event.description, + start: event.start!, + end: event.end!, + ), + ); + } + } + } + } + events.sort((a, b) => a.start.compareTo(b.start)); + return events; + } + return []; + } + + //Selects the active or upcoming event + CalendarEventModel? selectActiveContext(List events) { + DateTime now = DateTime.now(); + //Event is happening now + for (var event in events) { + if (event.start.isBefore(now) && event.end.isAfter(now)) { + return event; + } + } + //Upcoming event + for (var event in events) { + if (event.start.isAfter(now)) { + return event; + } + } + //No active or upcoming events + return null; + } + + //Builds the payload to send to backend + Map buildCalendarPayload(CalendarEventModel? event) { + if (event == null) { + return { + "type": "calendar_context", + "data": { + "title": "General conversation", + "description": null, + "start": null, + "end": null + } + }; + } + return { + "type": "calendar_context", + "data": { + "title": event.title, + "description": event.description, + "start": event.start.toIso8601String(), + "end": event.end.toIso8601String() + } + }; + } +} + +// Model to represent calendar events in a simplified way for our application +class CalendarEventModel { + final String title; + final String? description; + final DateTime start; + final DateTime end; + + CalendarEventModel({ + required this.title, + required this.description, + required this.start, + required this.end, + }); +} diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index 52adcfb..aa61d00 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -130,6 +130,12 @@ class WebsocketService { } } + void sendCalendarContext(Map payload) { + if (connected.value) { + _audioChannel?.sink.add(jsonEncode(payload)); + } + } + /// Tell the backend to stop expecting audio data. Future stopAudioStream() async { if (connected.value) { diff --git a/pubspec.lock b/pubspec.lock index 1ecbb85..aa8ce67 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + device_calendar: + dependency: "direct main" + description: + name: device_calendar + sha256: "683fb93ec302b6a65c0ce57df40ff9dcc2404f59c67a2f8b93e59318c8a0a225" + url: "https://pub.dev" + source: hosted + version: "4.3.3" even_realities_g1: dependency: "direct main" description: @@ -500,6 +508,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -548,6 +564,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 87ed094..b2a8782 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: cupertino_icons: ^1.0.8 # Local path dependency + device_calendar: ^4.3.3 even_realities_g1: path: packages/even_realities_g1 flutter: diff --git a/test/services/calendar_service_test.dart b/test/services/calendar_service_test.dart new file mode 100644 index 0000000..3b6252f --- /dev/null +++ b/test/services/calendar_service_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:front/services/calendar_service.dart'; + +void main() { + group('CalendarService.selectActiveContext', () { + final service = CalendarService(); + + test('returns null when event list is empty', () { + final result = service.selectActiveContext([]); + + expect(result, isNull); + }); + test('return active event when one is happening now', () { + final now = DateTime.now(); + final events = [ + CalendarEventModel( + title: 'Business meeting', + description: 'Discussing quarterly results', + start: now.subtract(const Duration(minutes: 30)), + end: now.add(const Duration(minutes: 30))), + ]; + final result = service.selectActiveContext(events); + + expect(result, isNotNull); + expect(result!.title, 'Business meeting'); + }); + test('return upcoming event when no event is active', () { + final now = DateTime.now(); + final events = [ + CalendarEventModel( + title: 'Project deadline', + description: 'Submit final report', + start: now.add(const Duration(hours: 1)), + end: now.add(const Duration(hours: 2))), + CalendarEventModel( + title: 'Later meeting', + description: 'Retrospective', + start: now.add(const Duration(hours: 3)), + end: now.add(const Duration(hours: 4))), + ]; + final result = service.selectActiveContext(events); + + expect(result, isNotNull); + expect(result!.title, 'Project deadline'); + }); + }); + + group('CalendarService.buildCalendarPayload', () { + final service = CalendarService(); + + test('Build payload from active event', () { + final event = CalendarEventModel( + title: 'Business meeting', + description: 'Discussing quarterly results', + start: DateTime.parse('2025-06-01T10:00:00Z'), + end: DateTime.parse('2025-06-01T11:00:00Z')); + final payload = service.buildCalendarPayload(event); + + expect(payload['type'], 'calendar_context'); + expect(payload['data']['title'], 'Business meeting'); + expect(payload['data']['description'], 'Discussing quarterly results'); + expect(payload['data']['start'], event.start.toIso8601String()); + expect(payload['data']['end'], event.end.toIso8601String()); + }); + test('Build payload with null description', () { + final event = CalendarEventModel( + title: 'Quick meeting', + description: null, + start: DateTime.parse('2025-06-01T12:00:00Z'), + end: DateTime.parse('2025-06-01T12:30:00Z')); + final payload = service.buildCalendarPayload(event); + + expect(payload['data']['title'], 'Quick meeting'); + expect(payload['data']['description'], isNull); + }); + }); +}