diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 27b534e..5c73020 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Frontend CI (Flutter) +name: Frontend CI on: push: @@ -9,25 +9,11 @@ on: jobs: flutter: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Detect Flutter project - id: detect - run: | - if [ -f pubspec.yaml ]; then - echo "has_flutter=true" >> $GITHUB_OUTPUT - else - echo "has_flutter=false" >> $GITHUB_OUTPUT - fi - - - name: Repo not scaffolded yet - if: steps.detect.outputs.has_flutter == 'false' - run: echo "No pubspec.yaml yet — skipping Flutter CI." + - name: Checkout + uses: actions/checkout@v6 - name: Set up Flutter - if: steps.detect.outputs.has_flutter == 'true' uses: subosito/flutter-action@v2 with: flutter-version: "3.24.0" @@ -35,17 +21,13 @@ jobs: cache: true - name: Install dependencies - if: steps.detect.outputs.has_flutter == 'true' run: flutter pub get - name: Check formatting - if: steps.detect.outputs.has_flutter == 'true' - run: dart format --output=none --set-exit-if-changed . + run: dart format --set-exit-if-changed . - name: Analyze - if: steps.detect.outputs.has_flutter == 'true' run: flutter analyze - name: Run tests - if: steps.detect.outputs.has_flutter == 'true' run: flutter test diff --git a/.gitignore b/.gitignore index 84d9097..9b552aa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,8 @@ migrate_working_dir/ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +# Vscode related (not in version control since it overrides user settings) +.vscode/ # Flutter/Dart/Pub related **/doc/api/ @@ -47,6 +45,4 @@ app.*.map.json /android/app/.*/* -config_dev.json -config_staging.json -config_test.json \ No newline at end of file +config_*.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 9a12a5e..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "App (Staging)", - "request": "launch", - "type": "dart", - "args": ["--dart-define-from-file=config_staging.json"] - }, - { - "name": "App (Dev)", - "request": "launch", - "type": "dart", - "args": ["--dart-define-from-file=config_dev.json"] - }, - { - "name": "App (Test)", - "request": "launch", - "type": "dart", - "args": ["--dart-define-from-file=config_test.json"] - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3d8a545..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "editor.formatOnSave": true, - "[dart]": { - "editor.defaultFormatter": "Dart-Code.dart-code", - "editor.formatOnSave": true, - "editor.selectionHighlight": false, - "editor.rulers": [80], - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - } - }, - "dart.lineLength": 80 -} diff --git a/README.md b/README.md index 9b0232f..56280ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![GHA workflow badge](https://github.com/AI-Smarties/front/actions/workflows/main.yml/badge.svg) -# AI-Smarties - Frontend (Flutter) +# AI-Smarties - Frontend (Dart + Flutter) ## Requirements @@ -22,7 +22,7 @@ ## 2. Switch to development branch (dev) ```bash - git checkout dev + git switch dev ``` ## 3. Confirm the Flutter environment @@ -91,8 +91,6 @@ Run locally: Linting is enabled by adding `very_good_analysis` and `analysis_options.yaml`. ---- - ## Daily development workflow When you return to coding: @@ -133,11 +131,33 @@ When you return to coding: flutter test ``` ---- +## Build app + +### Android + +run command + +```bash +flutter build apk --dart-define-from-file=config_staging.json +``` + +### IOS + +run command + +```bash +flutter build ios --release --dart-define-from-file=config_staging.json +``` + +## Install app -## Project structure (Flutter) +then install it to usb connected phone -When `flutter create .` is run, the structure is typically: +```bash +flutter install +``` + +## Project structure - `lib/` – Application UI and application logic - `test/` – Unit- and widget testing @@ -146,8 +166,9 @@ When `flutter create .` is run, the structure is typically: - `analysis_options.yaml` – lint-rules - `pubspec.yaml` – Flutter/Dart dependencies ---- - ## About Frontend for Everyday AI productivity interface for Even Realities G1 smart glasses. + +## Backend integration +Frontend is intended to be used with the [FastAPI backend](https://github.com/AI-Smarties/back) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5de857a..e72c19d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,11 +6,14 @@ + + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - + - + + - + \ No newline at end of file diff --git a/config_staging.example.json b/config_staging.example.json index 46792ba..e2f7695 100644 --- a/config_staging.example.json +++ b/config_staging.example.json @@ -1,3 +1,3 @@ { - "API_URL": "your-backend-url-here.fi" + "API_URL": "your-backend-url-here.fi:443" } diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..d8f4363 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,48 @@ +PODS: + - device_calendar (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS + - flutter_sound (9.30.0): + - Flutter + - flutter_sound_core (= 9.30.0) + - flutter_sound_core (9.30.0) + - permission_handler_apple (9.3.0): + - Flutter + +DEPENDENCIES: + - device_calendar (from `.symlinks/plugins/device_calendar/ios`) + - Flutter (from `Flutter`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - flutter_sound (from `.symlinks/plugins/flutter_sound/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + +SPEC REPOS: + trunk: + - flutter_sound_core + +EXTERNAL SOURCES: + device_calendar: + :path: ".symlinks/plugins/device_calendar/ios" + Flutter: + :path: Flutter + flutter_blue_plus_darwin: + :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" + flutter_sound: + :path: ".symlinks/plugins/flutter_sound/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + +SPEC CHECKSUMS: + device_calendar: b55b2c5406cfba45c95a59f9059156daee1f74ed + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + flutter_sound: d95194f6476c9ad211d22b3a414d852c12c7ca44 + flutter_sound_core: 7f2626d249d3a57bfa6da892ef7e22d234482c1a + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1d1b77b..665f40d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 11042EE6E6B7D9E6B3203B5F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384BAE8F142FA9F916740E80 /* Pods_Runner.framework */; }; + 13B07DAAD2277EC02485444C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C7762E6CD773CA6A6329EA96 /* Pods_RunnerTests.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; @@ -44,10 +46,15 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 384BAE8F142FA9F916740E80 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4AF0703E326EC888C79A6934 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 6F703DB2DF8D00352F539BEF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 75606A58A2724B2236273247 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 80B8CDD639846BDA7DEBA175 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,13 +62,25 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C7762E6CD773CA6A6329EA96 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E3CB0A10B61A6DA72D29435D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F85674E7919C9C6910F5C52A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 1C1F3D585EBC7A9D5F841A4B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07DAAD2277EC02485444C /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 11042EE6E6B7D9E6B3203B5F /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +95,28 @@ path = RunnerTests; sourceTree = ""; }; + 3A67259495ADFC50EA787FF3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 384BAE8F142FA9F916740E80 /* Pods_Runner.framework */, + C7762E6CD773CA6A6329EA96 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 820C0A71E31DDDCBE4781028 /* Pods */ = { + isa = PBXGroup; + children = ( + 80B8CDD639846BDA7DEBA175 /* Pods-Runner.debug.xcconfig */, + 4AF0703E326EC888C79A6934 /* Pods-Runner.release.xcconfig */, + 75606A58A2724B2236273247 /* Pods-Runner.profile.xcconfig */, + E3CB0A10B61A6DA72D29435D /* Pods-RunnerTests.debug.xcconfig */, + F85674E7919C9C6910F5C52A /* Pods-RunnerTests.release.xcconfig */, + 6F703DB2DF8D00352F539BEF /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +135,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 820C0A71E31DDDCBE4781028 /* Pods */, + 3A67259495ADFC50EA787FF3 /* Frameworks */, ); sourceTree = ""; }; @@ -128,8 +171,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 30194C4A061055FA00E6E695 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 1C1F3D585EBC7A9D5F841A4B /* Frameworks */, ); buildRules = ( ); @@ -145,12 +190,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 12062EAA91003871FC9F02AB /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + E7F491FBE75B21AAC1960BCF /* [CP] Embed Pods Frameworks */, + 5930E5E9A4A35EF8EAF80E24 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -222,6 +270,50 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 12062EAA91003871FC9F02AB /* [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-Runner-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; + }; + 30194C4A061055FA00E6E695 /* [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; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -238,6 +330,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 5930E5E9A4A35EF8EAF80E24 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +362,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + E7F491FBE75B21AAC1960BCF /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -368,7 +494,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.ai-smarties.front"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -378,6 +504,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E3CB0A10B61A6DA72D29435D /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +522,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F85674E7919C9C6910F5C52A /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +538,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6F703DB2DF8D00352F539BEF /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -547,7 +676,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.ai-smarties.front"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -569,7 +698,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.ai-smarties.front"; 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/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7788352..a9948d4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,14 @@ + CADisableMinimumFrameDurationOnPhone + + LSApplicationQueriesSchemes + + com.even.g1 + eveng1 + even-g1 + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +32,31 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + NSCalendarsFullAccessUsageDescription + Access most functions for calendar viewing. + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +74,9 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - + NSBluetoothAlwaysUsageDescription + This app uses Bluetooth to discover and connect to Even Realities G1 glasses. + NSMicrophoneUsageDescription + This app uses the microphone for audio capture and transcription. diff --git a/lib/main.dart b/lib/main.dart index 0596182..f258a60 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,8 +5,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; void main() { WidgetsFlutterBinding.ensureInitialized(); fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, - color: false); // lokitus bännäyksen poisto terminalista - + color: false); // Disable logging for fbp package runApp(const MyApp()); } diff --git a/lib/models/api_models.dart b/lib/models/api_models.dart new file mode 100644 index 0000000..d36a229 --- /dev/null +++ b/lib/models/api_models.dart @@ -0,0 +1,72 @@ +// Small data models added so that frontend doesn't need to work directly with raw JSON maps. +// These models represent the backend REST response shapes in typed Dart objects. + +class Category { + final int id; + final String name; + + const Category({ + required this.id, + required this.name, + }); + + factory Category.fromJson(Map json) { + return Category( + id: json['id'] as int, + // Defensive parsing to avoid crashing if backend gives null or odd types. + name: (json['name'] ?? '').toString(), + ); + } + + @override + String toString() => name; +} + +class Conversation { + final int id; + final String name; + final String summary; + final int? categoryId; + final DateTime? timestamp; + + const Conversation({ + required this.id, + required this.name, + required this.summary, + required this.categoryId, + required this.timestamp, + }); + + factory Conversation.fromJson(Map json) { + return Conversation( + id: json['id'] as int, + name: (json['name'] ?? '').toString(), + summary: (json['summary'] ?? '').toString(), + categoryId: json['category_id'] as int?, + // Defensive parsing for nullable timestamp so malformed or missing values don't crash the app or side panel UI + timestamp: json['timestamp'] != null + ? DateTime.tryParse(json['timestamp'].toString()) + : null, + ); + } +} + +class ConversationVector { + final int id; + final String text; + final int conversationId; + + const ConversationVector({ + required this.id, + required this.text, + required this.conversationId, + }); + + factory ConversationVector.fromJson(Map json) { + return ConversationVector( + id: json['id'] as int, + text: (json['text'] ?? '').toString(), + conversationId: json['conversation_id'] as int, + ); + } +} diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index c788fa8..22ad44d 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -1,53 +1,166 @@ +import 'dart:async'; + import 'package:even_realities_g1/even_realities_g1.dart'; import 'package:flutter/material.dart'; -import 'package:front/services/lc3_decoder.dart'; import 'package:front/services/audio_pipeline.dart'; +import 'package:android_intent_plus/android_intent.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'dart:io'; import '../widgets/g1_connection.dart'; import '../services/websocket_service.dart'; +import 'package:front/services/lc3_decoder.dart'; + +import '../models/api_models.dart'; +import '../services/calendar_service.dart'; import '../services/phone_audio_service.dart'; +import '../services/rest_api_service.dart'; +import '../widgets/side_panel.dart'; import 'login_screen.dart'; import 'register_screen.dart'; -import 'dart:async'; /// Landing screen of the app. Manages BLE glasses connection, /// audio streaming, and live transcription display. /// Also manages display of the landing page and navigation to login/register screens. - class LandingScreen extends StatefulWidget { - /// All dependencies are optional — defaults are created in initState - /// so they can be injected as mocks in tests. final G1Manager? manager; final WebsocketService? ws; final Lc3Decoder? decoder; final AudioPipeline? audioPipeline; - const LandingScreen( - {this.manager, this.decoder, this.ws, this.audioPipeline, super.key}); + final RestApiService? api; + + const LandingScreen({ + this.manager, + this.decoder, + this.ws, + this.audioPipeline, + this.api, + super.key, + }); @override State createState() => _LandingScreenState(); } class _LandingScreenState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + late final G1Manager _manager; late final Lc3Decoder _decoder; late final WebsocketService _ws; late final AudioPipeline _audioPipeline; - late final PhoneAudioService _phoneAudio; + late final RestApiService _api; + late final CalendarService _calendarService; + + PhoneAudioService? _phoneAudio; + bool _phoneAudioInitialized = false; bool _usePhoneMic = false; + bool _isMuted = false; + final ValueNotifier _isRecording = ValueNotifier(false); + final ValueNotifier _isRecordingBusy = ValueNotifier(false); final List _displayedSentences = []; static const int _maxDisplayedSentences = 4; + // Show confirmation dialog before switching to Even app + Future _switchToEvenApp() async { + if (_isRecording.value) { + return; + } + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Switch app"), + content: const Text( + "Glasses will disconnect and the Even app will open. Continue?", + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text("Open Even"), + ), + ], + ); + }, + ); + if (confirmed != true) return; + if (_manager.isConnected) { + await _manager.disconnect(); + } + await _openEvenApp(); + } + + // Open the Even app + // Android: check if app is installed via intent URI before launching, + // show error dialog if not found (no Play Store fallback) + // iOS: attempt possible deep link schemes, fallback to App Store + Future _openEvenApp() async { + if (Platform.isAndroid) { + try { + const intent = AndroidIntent( + action: 'android.intent.action.MAIN', + package: 'com.even.g1', + componentName: 'com.even.g1.MainActivity', + flags: [0x10000000], + ); + await intent.launch(); + } catch (_) { + if (mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Even app not found'), + content: const Text( + 'The Even G1 app is not installed on this device.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + } + } + } else if (Platform.isIOS) { + const schemes = [ + 'com.even.g1://', + 'eveng1://', + 'even-g1://', + ]; + + for (final scheme in schemes) { + final uri = Uri.parse(scheme); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + return; + } + } + + // jos appia ei voitu avata mennään App Storeen + final store = Uri.parse('https://apps.apple.com/app/id6499140518'); + await launchUrl(store, mode: LaunchMode.externalApplication); + } + } + @override void initState() { super.initState(); - // Use injected dependencies or create real ones _manager = widget.manager ?? G1Manager(); _decoder = widget.decoder ?? Lc3Decoder(); _ws = widget.ws ?? WebsocketService(); + _api = widget.api ?? const RestApiService(); + _calendarService = CalendarService(); + _audioPipeline = widget.audioPipeline ?? AudioPipeline( _manager, @@ -57,13 +170,7 @@ class _LandingScreenState extends State { }, ); - // Connect to backend WebSocket _ws.connect(); - - _phoneAudio = PhoneAudioService(); - _phoneAudio.init(); - - // React to committed (final) text only — interim is too noisy for glasses _ws.aiResponse.addListener(_onAiResponse); } @@ -71,139 +178,194 @@ class _LandingScreenState extends State { void dispose() { _ws.aiResponse.removeListener(_onAiResponse); _isRecording.dispose(); + _isRecordingBusy.dispose(); _audioPipeline.dispose(); - _phoneAudio.dispose(); + if (_phoneAudio != null) { + unawaited(_phoneAudio!.dispose()); + } _ws.dispose(); _manager.dispose(); super.dispose(); } + Future _ensurePhoneAudioReady() async { + _phoneAudio ??= PhoneAudioService(); + if (!_phoneAudioInitialized) { + await _phoneAudio!.init(); + _phoneAudioInitialized = true; + } + } + + Future _sendCalendarContextIfAvailable() async { + debugPrint('CALENDAR: trying to fetch calendar context'); + + final granted = await _calendarService.requestPermission(); + debugPrint('CALENDAR: permission granted = $granted'); + + if (!granted) { + debugPrint('CALENDAR: permission denied, skipping calendar context'); + return; + } + + final events = await _calendarService.getUpcomingEvents(); + debugPrint('CALENDAR: events found = ${events.length}'); + + final activeEvent = _calendarService.selectActiveContext(events); + debugPrint('CALENDAR: selected event = ${activeEvent?.title ?? "none"}'); + + final payload = _calendarService.buildCalendarPayload(activeEvent); + debugPrint('CALENDAR: payload = $payload'); + + _ws.sendCalendarContext(payload); + } + + void _handleCategorySelected(Category? category) { + final categoryId = category?.id; + debugPrint('CATEGORY: selected category id = $categoryId'); + _ws.sendSelectedCategory(categoryId); + } + void _onAiResponse() { final aiResponse = _ws.aiResponse.value; - debugPrint(aiResponse); - debugPrint("→ Adding to display: '$aiResponse'"); - if (_manager.isConnected && _manager.transcription.isActive.value) { - _addSentenceToDisplay(aiResponse); + if (!_isMuted) { + debugPrint("→ Adding to display: '$aiResponse'"); + if (_manager.isConnected && _manager.transcription.isActive.value) { + _addSentenceToDisplay(aiResponse); + } + } else { + debugPrint("→ Display is muted, skipping display update: '$aiResponse'"); } } - /// Adds a sentence to the on-screen queue. - /// - /// Each sentence is a separate BLE packet (lineNumber 1..N). - /// When the list is full, the oldest sentence is evicted to make room. - /// Sentences never disappear on a timer — they scroll off only when - /// pushed out by new ones. void _addSentenceToDisplay(String sentence) { + if (_isMuted) return; + if (sentence.trim().isEmpty) return; + if (_displayedSentences.length >= _maxDisplayedSentences) { _displayedSentences.removeAt(0); } - _displayedSentences.add(sentence); - _manager.transcription.displayLines( - List.unmodifiable(_displayedSentences), - ); + _manager.transcription.displayLines(List.unmodifiable(_displayedSentences)); Future.delayed(const Duration(seconds: 10), () { + if (!mounted || _isMuted) return; _displayedSentences.remove(sentence); - _manager.transcription.displayLines( - List.unmodifiable(_displayedSentences), - ); + _manager.transcription + .displayLines(List.unmodifiable(_displayedSentences)); }); } void _clearDisplayQueue() { _displayedSentences.clear(); + if (_manager.isConnected && _manager.transcription.isActive.value) { + _manager.transcription.displayLines(const []); + } } - /// Begin a transcription session Future _startTranscription() async { - if (_manager.isConnected) { - //glasses implementation - await _manager.transcription.stop(); // pakota clean stop ensin - await Future.delayed(const Duration(milliseconds: 300)); - _ws.clearCommittedText(); // reset accumulated text — backend starts fresh too - _clearDisplayQueue(); - - await _ws.startAudioStream(); - await _manager.transcription.start(); - - if (_usePhoneMic) { - await _phoneAudio.start((pcm) { - if (_ws.connected.value) { - _ws.sendAudio(pcm); - } - }); + if (_isRecordingBusy.value) return; + if (!_usePhoneMic && !_manager.isConnected) return; + + _isRecordingBusy.value = true; + try { + await _sendCalendarContextIfAvailable(); + + if (_manager.isConnected) { + await _manager.transcription.stop(); + await Future.delayed(const Duration(milliseconds: 300)); + _ws.clearCommittedText(); + _clearDisplayQueue(); + await _ws.startAudioStream(); + await _manager.transcription.start(); + + if (_usePhoneMic) { + await _ensurePhoneAudioReady(); + await _phoneAudio!.start((pcm) { + if (_ws.connected.value) _ws.sendAudio(pcm); + }); + } else { + await _manager.microphone.enable(); + _audioPipeline.addListenerToMicrophone(); + } + + await _manager.transcription.displayText('Recording started.'); + debugPrint('Transcription (re)started'); } else { - await _manager.microphone.enable(); - _audioPipeline.addListenerToMicrophone(); + _ws.clearCommittedText(); + _clearDisplayQueue(); + await _ws.startAudioStream(); + await _ensurePhoneAudioReady(); + await _phoneAudio!.start((pcm) { + if (_ws.connected.value) _ws.sendAudio(pcm); + }); } - await _manager.transcription.displayText('Recording started.'); - debugPrint("Transcription (re)started"); - } else { - //wo glasses - _ws.clearCommittedText(); // reset accumulated text — backend starts fresh too - _clearDisplayQueue(); - await _ws.startAudioStream(); - await _phoneAudio.start( - (pcm) { - if (_ws.connected.value) _ws.sendAudio(pcm); - }, - ); + _isRecording.value = true; + } finally { + _isRecordingBusy.value = false; } - _isRecording.value = true; } - /// End a transcription session Future _stopTranscription() async { + if (_isRecordingBusy.value) return; + + _isRecordingBusy.value = true; _isRecording.value = false; - if (_manager.isConnected) { - _clearDisplayQueue(); - await _manager.transcription.displayText('Recording stopped.'); - await Future.delayed(const Duration(seconds: 2)); - if (_usePhoneMic) { - await _phoneAudio.stop(); + try { + if (_manager.isConnected) { + _clearDisplayQueue(); + await _manager.transcription.displayText('Recording stopped.'); + await Future.delayed(const Duration(seconds: 2)); + + if (_usePhoneMic) { + await _phoneAudio?.stop(); + } else { + await _manager.microphone.disable(); + await _audioPipeline.stop(); + } + + await Future.delayed(const Duration(milliseconds: 200)); + await _ws.stopAudioStream(); + await _manager.transcription.stop(); } else { - await _manager.microphone.disable(); - await _audioPipeline.stop(); + await _phoneAudio?.stop(); + await _ws.stopAudioStream(); } - // lisätty jotta paketit kerkiävät lähteä ennen sulkemista - await Future.delayed(const Duration(milliseconds: 200)); - await _ws.stopAudioStream(); - await _manager.transcription.stop(); - } else { - await _phoneAudio.stop(); - await _ws.stopAudioStream(); + } finally { + _isRecordingBusy.value = false; } } + void _openDrawer() => _scaffoldKey.currentState?.openDrawer(); + @override Widget build(BuildContext context) { return Scaffold( + key: _scaffoldKey, backgroundColor: Colors.white, + drawer: SidePanel( + api: _api, + onCategorySelected: _handleCategorySelected, + ), body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), child: Column( children: [ - // ===== YLÄBANNERI ===== Row( children: [ - // Vasen SizedBox( width: 96, child: Align( alignment: Alignment.centerLeft, child: IconButton( - onPressed: () {}, + onPressed: _openDrawer, icon: const Icon(Icons.menu, color: Color(0xFF00239D)), ), ), ), - - // Logo keskelle Expanded( child: Center( child: Image.asset( @@ -213,8 +375,6 @@ class _LandingScreenState extends State { ), ), ), - - // Oikea SizedBox( width: 96, child: Row( @@ -245,247 +405,402 @@ class _LandingScreenState extends State { ), ], ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - 'assets/images/g1-smart-glasses.webp', - height: 120, - fit: BoxFit.contain, - ), - - const SizedBox(height: 6), - - const Text( - 'Even realities G1 smart glasses', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - - const SizedBox(height: 34), - - // ===== CONNECT - Row( - children: [ - // Connect / Disconnect - Expanded( - child: GlassesConnection( - manager: _manager, - ), - ), - - const SizedBox(width: 14), - - // Mic toggle - Expanded( - child: InkWell( - onTap: () { - setState(() { - _usePhoneMic = !_usePhoneMic; - }); - }, - child: Container( - height: 72, - padding: - const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: _usePhoneMic - ? Colors.lightGreen - .withAlpha((0.15 * 255).round()) - : Colors.transparent, - border: Border.all( - color: _usePhoneMic - ? Colors.lightGreen - : Colors.black12, - ), - borderRadius: BorderRadius.circular(8), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/g1-smart-glasses.webp', + height: 120, + fit: BoxFit.contain, + ), + const SizedBox(height: 6), + const Text( + 'Even realities G1 smart glasses', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _usePhoneMic - ? const Icon( - Icons.mic, - size: 22, - color: Colors.lightGreen, - ) - : Image.asset( - 'assets/images/g1-smart-glasses.webp', - height: 22, - fit: BoxFit.contain, + ), + const SizedBox(height: 34), + Row( + children: [ + Expanded( + child: GlassesConnection( + manager: _manager, + onRecordToggle: () async { + if (!_manager + .transcription.isActive.value) { + await _startTranscription(); + } else { + await _stopTranscription(); + } + }, + ), + ), + const SizedBox(width: 14), + Expanded( + child: ListenableBuilder( + listenable: Listenable.merge( + [_isRecording, _isRecordingBusy], + ), + builder: (context, _) { + final isLocked = _isRecording.value || + _isRecordingBusy.value; + final borderColor = isLocked + ? Colors.black26 + : (_usePhoneMic + ? Colors.lightGreen + : Colors.black12); + final backgroundColor = isLocked + ? Colors.black + .withAlpha((0.04 * 255).round()) + : (_usePhoneMic + ? Colors.lightGreen.withAlpha( + (0.15 * 255).round()) + : Colors.transparent); + final textColor = isLocked + ? Colors.black38 + : (_usePhoneMic + ? Colors.lightGreen + : Colors.black); + + return Opacity( + opacity: isLocked ? 0.55 : 1, + child: InkWell( + onTap: isLocked + ? null + : () => setState( + () => _usePhoneMic = + !_usePhoneMic, + ), + child: Container( + height: 72, + padding: const EdgeInsets.symmetric( + horizontal: 14, + ), + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all( + color: borderColor), + borderRadius: + BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + _usePhoneMic + ? Icon( + Icons.phone_android, + size: 22, + color: isLocked + ? Colors.black38 + : Colors.lightGreen, + ) + : Image.asset( + 'assets/images/g1-smart-glasses.webp', + height: 22, + fit: BoxFit.contain, + color: isLocked + ? Colors.black38 + : null, + colorBlendMode: isLocked + ? BlendMode.srcIn + : null, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _usePhoneMic + ? 'Switch to glasses mic' + : 'Switch to phone mic', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: _usePhoneMic + ? FontWeight.bold + : FontWeight.normal, + color: textColor, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: StreamBuilder( + stream: _manager.connectionState, + initialData: G1ConnectionEvent( + state: _manager.isConnected + ? G1ConnectionState.connected + : G1ConnectionState.disconnected, + ), + builder: (context, snapshot) { + final isGlassesConnected = + snapshot.data?.state == + G1ConnectionState.connected; + + return ListenableBuilder( + listenable: Listenable.merge( + [_isRecording, _isRecordingBusy], ), - const SizedBox(width: 10), - Expanded( - child: Text( - _usePhoneMic - ? 'Phone mic\n(Active)' - : 'Glasses mic\n(Active)', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - fontWeight: _usePhoneMic - ? FontWeight.bold - : FontWeight.normal, - color: _usePhoneMic - ? Colors.lightGreen - : Colors.black, + builder: (context, _) { + final isRecording = + _isRecording.value; + final isBusy = _isRecordingBusy.value; + final canStart = _usePhoneMic || + isGlassesConnected; + final isDisabled = isBusy || + (!isRecording && !canStart); + final borderColor = isDisabled + ? Colors.black26 + : (isRecording + ? Colors.red + : Colors.black12); + final backgroundColor = isDisabled + ? Colors.black.withAlpha( + (0.04 * 255).round()) + : (isRecording + ? Colors.red.withAlpha( + (0.15 * 255).round(), + ) + : Colors.transparent); + final foregroundColor = isDisabled + ? Colors.black38 + : (isRecording + ? Colors.red + : Colors.grey[800]); + + return Opacity( + opacity: isDisabled ? 0.55 : 1, + child: InkWell( + onTap: isDisabled + ? null + : () async { + if (!isRecording) { + await _startTranscription(); + } else { + await _stopTranscription(); + } + }, + child: Container( + height: 72, + padding: + const EdgeInsets.symmetric( + horizontal: 14, + ), + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all( + color: borderColor), + borderRadius: + BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + isRecording + ? Icons + .stop_circle_outlined + : Icons + .fiber_manual_record, + size: 22, + color: foregroundColor, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + isRecording + ? 'Stop\nRecording' + : 'Start\nRecording', + textAlign: + TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: + FontWeight.bold, + color: + foregroundColor, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(width: 14), + Expanded( + child: InkWell( + onTap: () => + setState(() => _isMuted = !_isMuted), + child: Container( + height: 72, + padding: const EdgeInsets.symmetric( + horizontal: 14, + ), + decoration: BoxDecoration( + color: _isMuted + ? Colors.orange + .withAlpha((0.15 * 255).round()) + : Colors.transparent, + border: Border.all( + color: _isMuted + ? Colors.orange + : Colors.black12, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + _isMuted + ? Icons + .comments_disabled_outlined + : Icons.comment_outlined, + size: 22, + color: _isMuted + ? Colors.orange + : Colors.grey[700], + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _isMuted + ? 'Unmute display' + : 'Mute display', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: _isMuted + ? FontWeight.bold + : FontWeight.normal, + color: _isMuted + ? Colors.orange + : Colors.grey[800], + ), + ), + ), + ], ), ), ), - ], - ), + ), + ], ), - ), - ), - ], - ), - - const SizedBox(height: 14), - - Row( - children: [ - // Start / Stop recording - Expanded( - child: ValueListenableBuilder( - valueListenable: _isRecording, - builder: (context, isRecording, _) { - return InkWell( - onTap: () async { - if (!isRecording) { - await _startTranscription(); - } else { - await _stopTranscription(); - } - }, - child: Container( - height: 72, + const SizedBox(height: 22), + ValueListenableBuilder( + valueListenable: _ws.aiResponse, + builder: (context, aiResponse, _) { + if (aiResponse.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + width: double.infinity, padding: const EdgeInsets.symmetric( - horizontal: 14), + horizontal: 14, + vertical: 10, + ), decoration: BoxDecoration( - color: isRecording - ? Colors.red - .withAlpha((0.15 * 255).round()) - : Colors.transparent, - border: Border.all( - color: isRecording - ? Colors.red - : Colors.black12, - ), + border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(8), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - isRecording - ? Icons.stop_circle_outlined - : Icons.fiber_manual_record, - size: 22, - color: isRecording - ? Colors.red - : Colors.grey[800], + child: Text( + aiResponse, + style: const TextStyle(fontSize: 14), + ), + ); + }, + ), + const SizedBox(height: 8), + Center( + child: ValueListenableBuilder( + valueListenable: _isRecording, + builder: (context, isRecording, _) { + return GestureDetector( + onTap: + isRecording ? null : _switchToEvenApp, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 5, ), - const SizedBox(width: 10), - Expanded( - child: Text( - isRecording - ? 'Stop\nRecording' - : 'Start\nRecording', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: isRecording - ? Colors.red - : Colors.grey[800], - ), + decoration: BoxDecoration( + border: Border.all( + color: isRecording + ? Colors.grey + : Colors.black12, ), + borderRadius: BorderRadius.circular(8), ), - ], - ), - ), - ); - }, - ), - ), - - const SizedBox(width: 14), - - // Recordings placeholder - Expanded( - child: LandingTile( - icon: Icons.play_circle_outline, - label: 'Recordings', - onTap: () {}, - ), - ), - ], - ), - - const SizedBox(height: 22), - - ValueListenableBuilder( - valueListenable: _ws.aiResponse, - builder: (context, aiResponse, _) { - if (aiResponse.isEmpty) return const SizedBox.shrink(); - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 10), - decoration: BoxDecoration( - border: Border.all(color: Colors.black12), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - aiResponse, - style: const TextStyle(fontSize: 14), - ), - ); - }, - ), - - const SizedBox(height: 8), - Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 5), - decoration: BoxDecoration( - border: Border.all(color: Colors.black12), - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.battery_full, size: 18), - SizedBox(width: 8), - Text('G1 smart glasses'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.battery_full, + size: 18), + const SizedBox(width: 8), + Text( + 'G1 smart glasses', + style: TextStyle( + color: isRecording + ? Colors.grey + : Colors.black, + ), + ), + ], + ), + ), + ); + }, + ), + ), ], ), ), - ), - ], + ); + }, ), ), - - // ===== LOGIN / REGISTER ===== Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LoginScreen(), - ), - ); - }, + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen(), + ), + ), child: const Text( 'Sign in', style: TextStyle( @@ -496,14 +811,12 @@ class _LandingScreenState extends State { ), const Text('|'), TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const RegisterScreen(), - ), - ); - }, + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const RegisterScreen(), + ), + ), child: const Text( 'Register', style: TextStyle( @@ -522,51 +835,3 @@ class _LandingScreenState extends State { ); } } - -class LandingTile extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - final bool enabled; - - const LandingTile({ - super.key, - required this.icon, - required this.label, - required this.onTap, - this.enabled = true, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Container( - height: 72, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - border: Border.all(color: Colors.black12), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - icon, - size: 22, - color: Colors.grey[700], - ), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 14, - color: Colors.grey[800], - ), - )), - ], - ), - ), - ); - } -} diff --git a/lib/services/calendar_service.dart b/lib/services/calendar_service.dart new file mode 100644 index 0000000..b420a60 --- /dev/null +++ b/lib/services/calendar_service.dart @@ -0,0 +1,138 @@ +import 'package:device_calendar/device_calendar.dart'; +import 'package:flutter/foundation.dart'; + +class CalendarService { + final DeviceCalendarPlugin _calendarPlugin = DeviceCalendarPlugin(); + + /// Requests calendar permission. + Future requestPermission() async { + debugPrint('CALENDAR SERVICE: requesting permission'); + final permissionsGranted = await _calendarPlugin.requestPermissions(); + + final granted = + permissionsGranted.isSuccess && permissionsGranted.data == true; + + debugPrint('CALENDAR SERVICE: permission granted = $granted'); + return granted; + } + + /// Searches for upcoming events in the next 7 days. + Future> getUpcomingEvents() async { + debugPrint('CALENDAR SERVICE: retrieving calendars'); + + final calendarResult = await _calendarPlugin.retrieveCalendars(); + if (calendarResult.isSuccess && calendarResult.data != null) { + final List calendars = calendarResult.data!; + final List events = []; + + final DateTime startDate = DateTime.now(); + final DateTime endDate = startDate.add(const Duration(days: 7)); + + debugPrint( + 'CALENDAR SERVICE: searching events from $startDate to $endDate', + ); + + for (final calendar in calendars) { + debugPrint( + 'CALENDAR SERVICE: checking calendar ${calendar.name} (${calendar.id})', + ); + + final eventResult = await _calendarPlugin.retrieveEvents( + calendar.id!, + RetrieveEventsParams(startDate: startDate, endDate: endDate), + ); + + if (eventResult.isSuccess && eventResult.data != null) { + final List calendarEvents = eventResult.data!; + for (final 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)); + debugPrint('CALENDAR SERVICE: found ${events.length} upcoming events'); + return events; + } + + debugPrint( + 'CALENDAR SERVICE: no calendars or failed to retrieve calendars'); + return []; + } + + /// Selects the active or upcoming event. + CalendarEventModel? selectActiveContext(List events) { + final DateTime now = DateTime.now(); + + for (final event in events) { + if (event.start.isBefore(now) && event.end.isAfter(now)) { + debugPrint('CALENDAR SERVICE: active event found = ${event.title}'); + return event; + } + } + + for (final event in events) { + if (event.start.isAfter(now)) { + debugPrint('CALENDAR SERVICE: upcoming event found = ${event.title}'); + return event; + } + } + + debugPrint('CALENDAR SERVICE: no active or upcoming event found'); + 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, + }); + + @override + String toString() { + return 'CalendarEventModel(title: $title, start: $start, end: $end)'; + } +} diff --git a/lib/services/phone_audio_service.dart b/lib/services/phone_audio_service.dart index 675eda1..5b03a1d 100644 --- a/lib/services/phone_audio_service.dart +++ b/lib/services/phone_audio_service.dart @@ -1,26 +1,38 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:flutter_sound/flutter_sound.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'dart:typed_data'; -import 'dart:async'; class PhoneAudioService { final FlutterSoundRecorder _recorder = FlutterSoundRecorder(); - final StreamController _controller = StreamController(); + final StreamController _controller = + StreamController.broadcast(); + + StreamSubscription? _controllerSubscription; bool _initialized = false; + bool _initializing = false; + + Function(Uint8List)? _onPcm; Future init() async { - await Permission.microphone.request(); - await _recorder.openRecorder(); + if (_initialized || _initializing) return; - _controller.stream.listen((buffer) { - _onPcm?.call(buffer); - }); + _initializing = true; + try { + await Permission.microphone.request(); + await _recorder.openRecorder(); - _initialized = true; - } + _controllerSubscription ??= _controller.stream.listen((buffer) { + _onPcm?.call(buffer); + }); - Function(Uint8List)? _onPcm; + _initialized = true; + } finally { + _initializing = false; + } + } Future start(Function(Uint8List pcm) onPcm) async { if (!_initialized) { @@ -29,6 +41,10 @@ class PhoneAudioService { _onPcm = onPcm; + if (_recorder.isRecording) { + return; + } + await _recorder.startRecorder( codec: Codec.pcm16, sampleRate: 16000, @@ -44,6 +60,8 @@ class PhoneAudioService { } Future dispose() async { + await stop(); + await _controllerSubscription?.cancel(); await _controller.close(); await _recorder.closeRecorder(); } diff --git a/lib/services/rest_api_service.dart b/lib/services/rest_api_service.dart new file mode 100644 index 0000000..403ee53 --- /dev/null +++ b/lib/services/rest_api_service.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/api_models.dart'; + +// Service was added to keep all REST API logic in one place. +// The UI should ask this service for data instead of building HTTP requests itself. +class RestApiService { + final String baseUrl; + + const RestApiService({ + // Uses same API_URL idea as the websocket setup. + // Makes local device/emulator testing configurable. + this.baseUrl = const String.fromEnvironment( + 'API_URL', + defaultValue: '127.0.0.1:8000', + ), + }); + + Uri _uri( + String path, { + Map? queryParameters, + }) { + return Uri.parse('https://$baseUrl$path').replace( + queryParameters: queryParameters, + ); + } + +// Added for the side panel category chip list. + Future> getCategories() async { + final res = await http.get(_uri('/get/categories')); + _checkStatus(res, 'GET /get/categories'); + + return (jsonDecode(res.body) as List) + .map((e) => Category.fromJson(e as Map)) + .toList(); + } + +// Added so that the side panel can create new categories from the inline form. + Future createCategory(String name) async { + final trimmed = name.trim(); + if (trimmed.isEmpty) { + throw const ApiException( + statusCode: 0, + message: 'Name cannot be empty', + ); + } + + final res = await http.post( + _uri('/create/category', queryParameters: {'name': trimmed}), + ); + _checkStatus(res, 'POST /create/category'); + + return Category.fromJson(jsonDecode(res.body) as Map); + } + +// Added for the conversations section. +// Optional categoryId is used when filtering by selected category chip. + Future> getConversations({int? categoryId}) async { + final params = categoryId != null ? {'cat_id': '$categoryId'} : null; + + final res = await http.get( + _uri('/get/conversations', queryParameters: params), + ); + _checkStatus(res, 'GET /get/conversations'); + + return (jsonDecode(res.body) as List) + .map((e) => Conversation.fromJson(e as Map)) + .toList(); + } + +// Added for the transcript/segments sections that appears when a conversation is selected in the side panel. + Future> getVectors(int conversationId) async { + final res = await http.get( + _uri('/get/vectors', queryParameters: {'conv_id': '$conversationId'}), + ); + _checkStatus(res, 'GET /get/vectors?conv_id=$conversationId'); + + return (jsonDecode(res.body) as List) + .map((e) => ConversationVector.fromJson(e as Map)) + .toList(); + } + + void _checkStatus(http.Response res, String label) { + if (res.statusCode >= 200 && res.statusCode < 300) return; + +//Added to surface useful backend error details in the UI instead of just showing generic failed request. + String message = 'HTTP ${res.statusCode}'; + try { + final decoded = jsonDecode(res.body); + if (decoded is Map) { + message = + (decoded['detail'] ?? decoded['message'] ?? message).toString(); + } else if (decoded is String && decoded.isNotEmpty) { + message = decoded; + } + } catch (_) { + if (res.body.trim().isNotEmpty) { + message = res.body.trim(); + } + } + + throw ApiException( + statusCode: res.statusCode, + message: '[$label] $message', + ); + } +} + +class ApiException implements Exception { + final int statusCode; + final String message; + + const ApiException({ + required this.statusCode, + required this.message, + }); + + @override + String toString() => 'ApiException($statusCode): $message'; +} diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index b9ceda0..67a49ee 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -1,42 +1,33 @@ import 'dart:async'; import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -/// Handles all communication with the backend over a WebSocket. -/// -/// Responsibilities: -/// - Connect/disconnect to `ws://:/ws/` -/// - Send raw PCM audio bytes for speech-to-text -/// - Send control commands (start/stop audio stream) -/// - Receive and expose transcription results (committed + interim text) -/// -/// Message protocol (JSON): -/// Incoming: -/// { "type": "control", "cmd": "ready" | "asr_started" | "asr_stopped" } -/// { "type": "transcript", "data": { "status": "partial"|"final", "text": "..." } } -/// { "type": "error", ... } -/// Outgoing: -/// { "type": "control", "cmd": "start" | "stop" } -/// Raw PCM bytes (binary frame) class WebsocketService { final String baseUrl; - - WebsocketService({ - this.baseUrl = - const String.fromEnvironment('API_URL', defaultValue: '127.0.0.1:8000'), - }); + static const String defaultBaseUrl = '127.0.0.1:8000'; + + WebsocketService({String? baseUrl}) + : baseUrl = baseUrl ?? + const String.fromEnvironment( + 'API_URL', + defaultValue: defaultBaseUrl, + ) { + if (baseUrl == null && const String.fromEnvironment('API_URL').isEmpty) { + debugPrint( + 'WARNING: API_URL is not set; using default baseUrl=$defaultBaseUrl. ' + 'Set via --dart-define-from-file=config_.json', + ); + } + } WebSocketChannel? _audioChannel; final connected = ValueNotifier(false); - final committedText = ValueNotifier(''); final interimText = ValueNotifier(''); final aiResponse = ValueNotifier(''); - - /// Whether the backend's ASR (speech recognition) engine is active. - /// Can be used for UI indicator final asrActive = ValueNotifier(false); void clearCommittedText() { @@ -45,8 +36,13 @@ class WebsocketService { Future connect() async { if (connected.value) return; + final Uri uri; try { - final uri = Uri.parse('ws://$baseUrl/ws/'); + if (baseUrl.contains(':443')) { + uri = Uri.parse('wss://$baseUrl/ws/'); + } else { + uri = Uri.parse('ws://$baseUrl/ws/'); + } _audioChannel = WebSocketChannel.connect(uri); await _audioChannel!.ready; @@ -68,33 +64,36 @@ class WebsocketService { debugPrint("→ Final/committed updated: ${committedText.value}"); } } else if (type == 'control') { - // Server signals readiness or ASR state changes if (data['cmd'] == 'ready') { connected.value = true; } else if (data['cmd'] == 'asr_started') { asrActive.value = true; } else if (data['cmd'] == 'asr_stopped') { asrActive.value = false; + } else if (data['cmd'] == 'calendar_context_received') { + debugPrint('WS: calendar context received by backend'); + } else if (data['cmd'] == 'selected_category_received') { + debugPrint('WS: selected category received by backend'); } } else if (type == 'ai') { - String response = data['data']; + final String response = data['data']; aiResponse.value = response; debugPrint(response); } else if (type == 'error') { - //todo + debugPrint('WS ERROR: ${data['message']}'); } }, onError: (_) => disconnect(), onDone: () => disconnect(), ); - } catch (e) { + } catch (_) { await disconnect(); } } Future disconnect() async { final channel = _audioChannel; - _audioChannel = null; // Asetetaan heti nulliksi + _audioChannel = null; connected.value = false; try { channel?.sink.add(jsonEncode({'type': 'control', 'cmd': 'stop'})); @@ -107,24 +106,37 @@ class WebsocketService { committedText.value = ''; interimText.value = ''; aiResponse.value = ''; + asrActive.value = false; } } - /// Send raw PCM audio bytes to the backend for transcription. void sendAudio(Uint8List pcmData) { if (connected.value) { _audioChannel?.sink.add(pcmData); } } - /// Tell the backend to stop expecting audio data. + void sendCalendarContext(Map payload) { + if (connected.value) { + _audioChannel?.sink.add(jsonEncode(payload)); + } + } + + void sendSelectedCategory(int? categoryId) { + if (connected.value) { + _audioChannel?.sink.add(jsonEncode({ + 'type': 'selected_category', + 'category_id': categoryId, + })); + } + } + Future stopAudioStream() async { if (connected.value) { _audioChannel?.sink.add(jsonEncode({'type': 'control', 'cmd': 'stop'})); } } - /// Tell the backend to start expecting audio data. Future startAudioStream() async { if (connected.value) { _audioChannel?.sink.add(jsonEncode({'type': 'control', 'cmd': 'start'})); @@ -136,11 +148,25 @@ class WebsocketService { } void dispose() { - disconnect(); - connected.dispose(); - committedText.dispose(); - interimText.dispose(); - asrActive.dispose(); - aiResponse.dispose(); + final channel = _audioChannel; + _audioChannel = null; + connected.value = false; + try { + channel?.sink.add(jsonEncode({'type': 'control', 'cmd': 'stop'})); + channel?.sink.close(); + } catch (_) { + // + } finally { + committedText.value = ''; + interimText.value = ''; + asrActive.value = false; + aiResponse.value = ''; + + connected.dispose(); + committedText.dispose(); + interimText.dispose(); + asrActive.dispose(); + aiResponse.dispose(); + } } } diff --git a/lib/widgets/side_panel.dart b/lib/widgets/side_panel.dart new file mode 100644 index 0000000..eb21857 --- /dev/null +++ b/lib/widgets/side_panel.dart @@ -0,0 +1,748 @@ +import 'package:flutter/material.dart'; + +import '../models/api_models.dart'; +import '../services/rest_api_service.dart'; + +class SidePanel extends StatefulWidget { + const SidePanel({ + super.key, + required this.api, + required this.onCategorySelected, + }); + + final RestApiService api; + final ValueChanged onCategorySelected; + + @override + State createState() => _SidePanelState(); +} + +class _SidePanelState extends State { + List _categories = []; + List _conversations = []; + List _vectors = []; + + Category? _selectedCategory; + Conversation? _selectedConversation; + + bool _loadingCategories = false; + bool _loadingConversations = false; + bool _loadingVectors = false; + + String? _categoryError; + String? _conversationError; + String? _vectorError; + + bool _showNewCategoryField = false; + final _newCatController = TextEditingController(); + final _newCatFocusNode = FocusNode(); + bool _creatingCategory = false; + String? _createCategoryError; + + @override + void initState() { + super.initState(); + _loadCategories(); + _loadConversations(); + } + + @override + void dispose() { + _newCatController.dispose(); + _newCatFocusNode.dispose(); + super.dispose(); + } + + Future _loadCategories() async { + setState(() { + _loadingCategories = true; + _categoryError = null; + }); + + try { + final cats = await widget.api.getCategories(); + if (!mounted) return; + setState(() => _categories = cats); + } catch (e) { + if (!mounted) return; + setState(() => _categoryError = 'Could not load categories — $e'); + } finally { + if (mounted) { + setState(() => _loadingCategories = false); + } + } + } + + Future _loadConversations({int? categoryId}) async { + setState(() { + _loadingConversations = true; + _conversationError = null; + _selectedConversation = null; + _vectors = []; + _vectorError = null; + }); + + try { + final convs = await widget.api.getConversations(categoryId: categoryId); + if (!mounted) return; + setState(() => _conversations = convs); + } catch (e) { + if (!mounted) return; + setState(() => _conversationError = 'Could not load conversations — $e'); + } finally { + if (mounted) { + setState(() => _loadingConversations = false); + } + } + } + + Future _loadVectors(int conversationId) async { + setState(() { + _loadingVectors = true; + _vectorError = null; + _vectors = []; + }); + + try { + final vecs = await widget.api.getVectors(conversationId); + if (!mounted) return; + setState(() => _vectors = vecs); + } catch (e) { + if (!mounted) return; + setState(() => _vectorError = 'Could not load transcripts — $e'); + } finally { + if (mounted) { + setState(() => _loadingVectors = false); + } + } + } + + Future _submitNewCategory() async { + final name = _newCatController.text.trim(); + if (name.isEmpty) return; + + setState(() { + _creatingCategory = true; + _createCategoryError = null; + }); + + try { + await widget.api.createCategory(name); + if (!mounted) return; + + await _loadCategories(); + if (!mounted) return; + + final createdCategory = _categories.cast().firstWhere( + (category) => + category?.name.trim().toLowerCase() == name.toLowerCase(), + orElse: () => null, + ); + + _newCatController.clear(); + FocusScope.of(context).unfocus(); + + setState(() { + _showNewCategoryField = false; + _selectedCategory = createdCategory; + }); + + widget.onCategorySelected(createdCategory); + await _loadConversations(categoryId: createdCategory?.id); + } on ApiException catch (e) { + if (!mounted) return; + setState(() { + _createCategoryError = + e.statusCode == 409 ? '"$name" already exists' : e.message; + }); + } catch (e) { + if (!mounted) return; + setState(() => _createCategoryError = e.toString()); + } finally { + if (mounted) { + setState(() => _creatingCategory = false); + } + } + } + + Future _refreshAll() async { + final selectedCategoryId = _selectedCategory?.id; + final selectedConversationId = _selectedConversation?.id; + + await _loadCategories(); + await _loadConversations(categoryId: selectedCategoryId); + + if (!mounted) return; + + if (selectedConversationId != null) { + final restoredConversation = + _conversations.cast().firstWhere( + (conversation) => conversation?.id == selectedConversationId, + orElse: () => null, + ); + + if (restoredConversation != null) { + setState(() { + _selectedConversation = restoredConversation; + }); + await _loadVectors(selectedConversationId); + } + } + } + + void _selectCategory(Category? cat) { + setState(() => _selectedCategory = cat); + widget.onCategorySelected(cat); + _loadConversations(categoryId: cat?.id); + } + + void _selectConversation(Conversation conv) { + if (_selectedConversation?.id == conv.id) { + setState(() { + _selectedConversation = null; + _vectors = []; + }); + } else { + setState(() => _selectedConversation = conv); + _loadVectors(conv.id); + } + } + + String _formatDate(DateTime? dt) { + if (dt == null) return '—'; + final local = dt.toLocal(); + return '${local.day}.${local.month}.${local.year} ' + '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; + } + + void _showSummarySheet(BuildContext context, Conversation conv) { + final summary = conv.summary.trim(); + if (summary.isEmpty) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.85, + expand: false, + builder: (_, scrollController) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 12, 0), + child: Row( + children: [ + const Icon( + Icons.summarize_outlined, + size: 18, + color: Color(0xFF00239D), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + conv.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF00239D), + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 12), + child: Text( + _formatDate(conv.timestamp), + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Text( + summary, + style: TextStyle( + fontSize: 14, + color: Colors.grey[800], + height: 1.6, + ), + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final keyboardBottom = MediaQuery.of(context).viewInsets.bottom; + + return Drawer( + child: SafeArea( + child: AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: keyboardBottom), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + Expanded( + child: RefreshIndicator( + onRefresh: _refreshAll, + child: ListView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.zero, + children: [ + _buildSectionLabel('Categories'), + _buildCategoryChips(), + _buildNewCategoryRow(), + const Divider(height: 24), + _buildSectionLabel('Conversations'), + _buildConversationList(), + if (_selectedConversation != null) ...[ + const Divider(height: 24), + _buildSectionLabel('Transcripts'), + _buildVectorList(), + ], + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + width: double.infinity, + color: const Color(0xFF00239D), + padding: const EdgeInsets.fromLTRB(16, 20, 8, 16), + child: Row( + children: [ + const Expanded( + child: Text( + 'History', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + tooltip: 'Refresh', + onPressed: + (_loadingCategories || _loadingConversations || _loadingVectors) + ? null + : _refreshAll, + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } + + Widget _buildSectionLabel(String text) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.1, + color: Colors.grey[600], + ), + ), + ); + } + + Widget _buildCategoryChips() { + if (_loadingCategories) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (_categoryError != null) { + return _ErrorRow( + message: _categoryError!, + onRetry: _loadCategories, + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: const Text('All'), + selected: _selectedCategory == null, + onSelected: (_) => _selectCategory(null), + ), + ), + ..._categories.map( + (cat) => Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text(cat.name), + selected: _selectedCategory?.id == cat.id, + onSelected: (_) => _selectCategory(cat), + ), + ), + ), + ActionChip( + avatar: const Icon(Icons.add, size: 16), + label: const Text('New'), + onPressed: () { + setState(() { + _showNewCategoryField = !_showNewCategoryField; + _createCategoryError = null; + }); + + if (_showNewCategoryField) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _newCatFocusNode.requestFocus(); + } + }); + } else { + FocusScope.of(context).unfocus(); + } + }, + ), + ], + ), + ); + } + + Widget _buildNewCategoryRow() { + if (!_showNewCategoryField) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _newCatController, + focusNode: _newCatFocusNode, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + hintText: 'Category name', + isDense: true, + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 10, vertical: 8), + ), + onSubmitted: (_) => _submitNewCategory(), + ), + ), + const SizedBox(width: 8), + _creatingCategory + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + icon: const Icon( + Icons.check, + color: Color(0xFF00239D), + ), + tooltip: 'Create', + onPressed: _submitNewCategory, + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + tooltip: 'Cancel', + onPressed: () => setState(() { + _showNewCategoryField = false; + _newCatController.clear(); + _createCategoryError = null; + FocusScope.of(context).unfocus(); + }), + ), + ], + ), + if (_createCategoryError != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _createCategoryError!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + ], + ), + ); + } + + Widget _buildConversationList() { + if (_loadingConversations) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (_conversationError != null) { + return _ErrorRow( + message: _conversationError!, + onRetry: () => _loadConversations(categoryId: _selectedCategory?.id), + ); + } + + if (_conversations.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text( + 'No conversations yet.', + style: TextStyle(color: Colors.grey), + ), + ); + } + + return Column( + children: _conversations.map((conv) { + final isSelected = _selectedConversation?.id == conv.id; + final hasSummary = conv.summary.trim().isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + dense: true, + selected: isSelected, + selectedTileColor: const Color(0xFF00239D).withAlpha(20), + leading: Icon( + Icons.chat_bubble_outline, + size: 18, + color: isSelected ? const Color(0xFF00239D) : Colors.grey[600], + ), + title: Text( + conv.name, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + subtitle: Text( + _formatDate(conv.timestamp), + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + trailing: Icon( + isSelected ? Icons.expand_less : Icons.chevron_right, + size: 18, + ), + onTap: () => _selectConversation(conv), + ), + if (hasSummary) + Padding( + padding: const EdgeInsets.fromLTRB(56, 0, 16, 8), + child: InkWell( + onTap: isSelected + ? () => _showSummarySheet(context, conv) + : null, + borderRadius: BorderRadius.circular(6), + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF00239D).withAlpha(10) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSelected + ? const Color(0xFF00239D).withAlpha(31) + : Colors.transparent, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + conv.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: isSelected + ? Colors.grey[700] + : Colors.grey[500], + fontStyle: FontStyle.italic, + height: 1.4, + ), + ), + ), + if (isSelected) ...[ + const SizedBox(width: 4), + Icon( + Icons.open_in_full, + size: 13, + color: Colors.grey[400], + ), + ], + ], + ), + ), + ), + ), + ], + ); + }).toList(), + ); + } + + Widget _buildVectorList() { + if (_loadingVectors) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (_vectorError != null) { + return _ErrorRow( + message: _vectorError!, + onRetry: () => _loadVectors(_selectedConversation!.id), + ); + } + + if (_vectors.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'No transcript segments for this conversation.', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + ); + } + + return Column( + children: _vectors.asMap().entries.map((entry) { + final vec = entry.value; + final i = entry.key; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Segment ${i + 1}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + SelectableText( + vec.text, + style: const TextStyle(fontSize: 13), + ), + ], + ), + ), + ); + }).toList(), + ); + } +} + +class _ErrorRow extends StatelessWidget { + const _ErrorRow({ + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Icon(Icons.error_outline, size: 16, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + TextButton( + onPressed: onRetry, + child: const Text('Retry'), + ), + ], + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 938839d..9c92be3 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_sound_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin"); flutter_sound_plugin_register_with_registrar(flutter_sound_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 86609f6..8f31061 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_sound + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8e1adb5..71a3ff5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import flutter_blue_plus_darwin import flutter_sound +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterSoundPlugin.register(with: registry.registrar(forPlugin: "FlutterSoundPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/even_realities_g1/lib/src/bluetooth/g1_glass.dart b/packages/even_realities_g1/lib/src/bluetooth/g1_glass.dart index 2c0a16b..dd501c3 100644 --- a/packages/even_realities_g1/lib/src/bluetooth/g1_glass.dart +++ b/packages/even_realities_g1/lib/src/bluetooth/g1_glass.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -51,10 +52,12 @@ class G1Glass { try { await device.connect(); await _discoverServices(); - await device.requestMtu(BluetoothConstants.defaultMtu); - await device.requestConnectionPriority( - connectionPriorityRequest: ConnectionPriority.high, - ); + if (!kIsWeb && Platform.isAndroid) { + await device.requestMtu(BluetoothConstants.defaultMtu); + await device.requestConnectionPriority( + connectionPriorityRequest: ConnectionPriority.high, + ); + } _startHeartbeat(); debugPrint('[$side Glass] Connected successfully'); } catch (e) { diff --git a/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart b/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart index bdfa491..b32fca1 100644 --- a/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart +++ b/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart @@ -66,6 +66,7 @@ class G1Manager { Timer? _scanTimer; StreamSubscription? _scanSubscription; bool _isScanning = false; + bool _isConnecting = false; int _retryCount = 0; bool _connectionCallbackFired = false; @@ -166,7 +167,7 @@ class G1Manager { throw Exception(msg); } - final adapterState = await FlutterBluePlus.adapterState.first; + final adapterState = FlutterBluePlus.adapterStateNow; if (adapterState != BluetoothAdapterState.on) { const msg = 'Bluetooth is turned off'; onUpdate?.call(msg); @@ -175,6 +176,7 @@ class G1Manager { // Reset state _isScanning = true; + _isConnecting = false; _retryCount = 0; _connectionCallbackFired = false; _leftGlass = null; @@ -204,7 +206,9 @@ class G1Manager { ) async { try { // Check system connected devices - final connectedDevices = await FlutterBluePlus.systemDevices([]); + final connectedDevices = await FlutterBluePlus.systemDevices([ + Guid(BluetoothConstants.uartServiceUuid), + ]); debugPrint('Found ${connectedDevices.length} system connected devices'); for (final device in connectedDevices) { @@ -381,11 +385,57 @@ class G1Manager { } if (glass != null) { - await glass.connect(); - _setupReconnect(glass); + if (_leftGlass != null && _rightGlass != null && !_isConnecting) { + await _connectDiscoveredGlasses( + onUpdate, + onGlassesFound, + onConnected, + ); + } + } + } + + Future _connectDiscoveredGlasses( + OnStatusUpdate? onUpdate, + OnGlassesFound? onGlassesFound, + OnConnected? onConnected, + ) async { + if (_leftGlass == null || _rightGlass == null || _isConnecting) return; + + _isConnecting = true; + _connectionStateController.add(const G1ConnectionEvent( + state: G1ConnectionState.connecting, + )); + onConnectionChanged?.call(G1ConnectionState.connecting, null); + onUpdate?.call('Connecting to glasses...'); + + try { + if (_isScanning) { + await stopScan(); + } + + if (!_leftGlass!.isConnected) { + await _leftGlass!.connect(); + _setupReconnect(_leftGlass!); + } + + if (!_rightGlass!.isConnected) { + await _rightGlass!.connect(); + _setupReconnect(_rightGlass!); + } - // Check if both glasses are now connected after this connection completes _checkBothConnected(onUpdate, onGlassesFound, onConnected); + } catch (e, st) { + debugPrint('Error connecting discovered glasses: $e'); + debugPrintStack(stackTrace: st); + onUpdate?.call('Failed to connect to glasses: $e'); + _connectionStateController.add(G1ConnectionEvent( + state: G1ConnectionState.error, + errorMessage: e.toString(), + )); + onConnectionChanged?.call(G1ConnectionState.error, null); + } finally { + _isConnecting = false; } } diff --git a/pubspec.lock b/pubspec.lock index 1ecbb85..82947e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: e1c62bb41c90e15083b7fb84dc327fe90396cc9c1445b55ff1082144fabfb4d9 + url: "https://pub.dev" + source: hosted + version: "4.0.3" args: dependency: transitive description: @@ -37,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -93,10 +101,18 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" + 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: @@ -116,10 +132,10 @@ packages: dependency: transitive description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" file: dependency: transitive description: @@ -235,10 +251,10 @@ packages: dependency: transitive description: name: hooks - sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" http: dependency: "direct main" description: @@ -291,10 +307,10 @@ packages: dependency: transitive description: name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" logging: dependency: transitive description: @@ -307,18 +323,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -331,10 +347,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" url: "https://pub.dev" source: hosted - version: "0.17.4" + version: "0.17.5" objective_c: dependency: transitive description: @@ -451,10 +467,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -496,10 +512,18 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -544,10 +568,18 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.9.4" typed_data: dependency: transitive description: @@ -556,6 +588,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 87ed094..6b14a9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,11 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + url_launcher: ^6.3.1 + android_intent_plus: ^4.0.0 + # Local path dependency + device_calendar: ^4.3.3 even_realities_g1: path: packages/even_realities_g1 flutter: diff --git a/run.sh b/run.sh deleted file mode 100755 index b5142c0..0000000 --- a/run.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -CONFIG="config_dev.json" -[ "$1" == "staging" ] && CONFIG="config_staging.json" - -echo "Käytetään konfiguraatiota: $CONFIG" - -flutter run --dart-define-from-file=$CONFIG 2>&1 \ -| grep -E "E/flutter|Unhandled Exception" diff --git a/test/ble_mock/g1_manager_mock.dart b/test/ble_mock/g1_manager_mock.dart index afa2579..7640d1b 100644 --- a/test/ble_mock/g1_manager_mock.dart +++ b/test/ble_mock/g1_manager_mock.dart @@ -145,6 +145,9 @@ class MockG1Microphone implements G1Microphone { } class MockG1Transcription implements G1Transcription { + final List displayTextCalls = []; + final List> displayLinesCalls = []; + @override final isActive = ValueNotifier(false); @@ -159,7 +162,20 @@ class MockG1Transcription implements G1Transcription { } @override - Future displayText(String text, {bool isInterim = false}) async {} + Future displayText(String text, {bool isInterim = false}) async { + displayTextCalls.add(text); + } + + @override + Future displayLines(List lines, + {bool isInterim = false}) async { + displayLinesCalls.add(List.from(lines)); + } + + void clearDisplayCalls() { + displayTextCalls.clear(); + displayLinesCalls.clear(); + } @override Future pause() async {} diff --git a/test/mocks/fake_rest_api_service.dart b/test/mocks/fake_rest_api_service.dart new file mode 100644 index 0000000..7448e87 --- /dev/null +++ b/test/mocks/fake_rest_api_service.dart @@ -0,0 +1,106 @@ +import 'package:front/models/api_models.dart'; +import 'package:front/services/rest_api_service.dart'; + +/// In-memory fake RestApiService for widget tests. + +class FakeRestApiService implements RestApiService { + FakeRestApiService({ + List? categories, + List? conversations, + List? vectors, + }) : _categories = List.from(categories ?? const []), + _conversations = List.from(conversations ?? const []), + _vectors = List.from(vectors ?? const []) { + for (final category in _categories) { + if (category.id >= _nextCategoryId) { + _nextCategoryId = category.id + 1; + } + } + } + + final List _categories; + final List _conversations; + final List _vectors; + + int _nextCategoryId = 1; + + @override + Future> getCategories() async { + return List.unmodifiable(_categories); + } + + @override + Future> getConversations({int? categoryId}) async { + if (categoryId == null) { + return List.unmodifiable(_conversations); + } + + return _conversations + .where((conversation) => conversation.categoryId == categoryId) + .toList(growable: false); + } + + @override + Future> getVectors(int conversationId) async { + return _vectors + .where((vector) => vector.conversationId == conversationId) + .toList(growable: false); + } + + @override + Future createCategory(String name) async { + final trimmed = name.trim(); + + if (trimmed.isEmpty) { + throw const ApiException( + statusCode: 0, + message: 'Name cannot be empty', + ); + } + + final alreadyExists = _categories.any( + (category) => category.name.toLowerCase() == trimmed.toLowerCase(), + ); + + if (alreadyExists) { + throw const ApiException( + statusCode: 409, + message: 'Category already exists', + ); + } + + final category = Category( + id: _nextCategoryId++, + name: trimmed, + ); + _categories.add(category); + return category; + } + + // Test helpers + + void addCategory(Category category) { + _categories.add(category); + if (category.id >= _nextCategoryId) { + _nextCategoryId = category.id + 1; + } + } + + void addConversation(Conversation conversation) { + _conversations.add(conversation); + } + + void addVector(ConversationVector vector) { + _vectors.add(vector); + } + + void clearAll() { + _categories.clear(); + _conversations.clear(); + _vectors.clear(); + _nextCategoryId = 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/mocks/fake_websocket_service.dart b/test/mocks/fake_websocket_service.dart new file mode 100644 index 0000000..d75a558 --- /dev/null +++ b/test/mocks/fake_websocket_service.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; +import 'package:front/services/websocket_service.dart'; + +class FakeWebsocketService implements WebsocketService { + bool _disposed = false; + + @override + final ValueNotifier connected = ValueNotifier(false); + + @override + final ValueNotifier committedText = ValueNotifier(''); + + @override + final ValueNotifier interimText = ValueNotifier(''); + + @override + final ValueNotifier asrActive = ValueNotifier(false); + + @override + final ValueNotifier aiResponse = ValueNotifier(''); + + bool audioStreamStarted = false; + final List> sentAudioChunks = []; + + @override + Future connect() async { + connected.value = true; + } + + @override + Future disconnect() async { + connected.value = false; + asrActive.value = false; + committedText.value = ''; + interimText.value = ''; + aiResponse.value = ''; + audioStreamStarted = false; + } + + @override + Future startAudioStream() async { + audioStreamStarted = true; + asrActive.value = true; + } + + @override + Future stopAudioStream() async { + audioStreamStarted = false; + asrActive.value = false; + } + + @override + void sendAudio(List pcm) { + sentAudioChunks.add(pcm); + } + + @override + void clearCommittedText() { + committedText.value = ''; + interimText.value = ''; + } + + @override + String getFullText() => [committedText.value, interimText.value] + .where((s) => s.isNotEmpty) + .join(' '); + + // Test helper methods + void setConnected(bool value) { + connected.value = value; + } + + void setCommittedText(String text) { + committedText.value = text; + } + + void setInterimText(String text) { + interimText.value = text; + } + + void setAiResponse(String text) { + aiResponse.value = text; + } + + void setAsrActive(bool value) { + asrActive.value = value; + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + + connected.dispose(); + committedText.dispose(); + interimText.dispose(); + asrActive.dispose(); + aiResponse.dispose(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} 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); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 5003a35..0cb793b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,14 +1,20 @@ import 'package:even_realities_g1/even_realities_g1.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'ble_mock/g1_manager_mock.dart'; + import 'package:front/screens/landing_screen.dart'; +import 'ble_mock/g1_manager_mock.dart'; +import 'mocks/fake_rest_api_service.dart'; +import 'mocks/fake_websocket_service.dart'; + void main() { late MockG1Manager mockManager; + late FakeWebsocketService fakeWs; setUp(() { mockManager = MockG1Manager(); + fakeWs = FakeWebsocketService(); }); tearDown(() { @@ -20,9 +26,12 @@ void main() { MaterialApp( home: LandingScreen( manager: mockManager, + ws: fakeWs, + api: FakeRestApiService(), ), ), ); + await tester.pump(); } Future disposeLanding(WidgetTester tester) async { @@ -31,123 +40,142 @@ void main() { await tester.pump(const Duration(milliseconds: 600)); } - testWidgets('App shows text input and send button', - (WidgetTester tester) async { + testWidgets('Landing screen shows key elements', (WidgetTester tester) async { await pumpLanding(tester); expect(find.text('Even realities G1 smart glasses'), findsOneWidget); - expect(find.text('Recordings'), findsOneWidget); - expect(find.text('Even realities G1 smart glasses'), findsOneWidget); + expect(find.text('Start\nRecording'), findsOneWidget); + expect(find.text('Mute display'), findsOneWidget); + expect(find.text('Sign in'), findsOneWidget); + expect(find.text('Register'), findsOneWidget); await disposeLanding(tester); }); - testWidgets('Connecting to glasses text is shown when bluetooth is scanning', - (tester) async { + testWidgets('Landing screen shows menu icon button', (tester) async { await pumpLanding(tester); + expect(find.byIcon(Icons.menu), findsOneWidget); + await disposeLanding(tester); + }); - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.connecting)); + testWidgets('Landing screen shows Sign in and Register links', + (tester) async { + await pumpLanding(tester); + expect(find.text('Sign in'), findsOneWidget); + expect(find.text('Register'), findsOneWidget); + await disposeLanding(tester); + }); + testWidgets('Shows Reconnect button when WebSocket is disconnected', + (tester) async { + await pumpLanding(tester); + fakeWs.setConnected(false); await tester.pump(); - - expect(find.text('Connecting to glasses'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - + expect(find.byIcon(Icons.refresh), findsOneWidget); await disposeLanding(tester); }); - testWidgets('Disconnect from glasses button is shown', (tester) async { + testWidgets('Shows Connected indicator when WebSocket is connected', + (tester) async { + fakeWs.setConnected(true); await pumpLanding(tester); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.disconnected)); - await tester.pump(); + expect(find.byIcon(Icons.signal_cellular_alt), findsOneWidget); + await disposeLanding(tester); + }); - expect(find.text('Connect to glasses'), findsOneWidget); - + testWidgets('Mic toggle shows switch option', (tester) async { + await pumpLanding(tester); + expect(find.text('Switch to phone mic'), findsOneWidget); await disposeLanding(tester); }); - testWidgets('On connecting error right error message is shown', + testWidgets('Tapping mic toggle switches to glasses mic option', (tester) async { await pumpLanding(tester); - - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.error)); - + await tester.tap(find.text('Switch to phone mic')); await tester.pump(); + expect(find.text('Switch to glasses mic'), findsOneWidget); + await disposeLanding(tester); + }); - expect(find.text('Error in connecting to glasses'), findsOneWidget); - expect(find.text('Connect to glasses'), findsOneWidget); - + testWidgets('Tapping menu icon opens the side panel drawer', (tester) async { + await pumpLanding(tester); + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + expect(find.text('History'), findsOneWidget); await disposeLanding(tester); }); - testWidgets('On scanning Scanning for glasses message is shown', - (tester) async { + testWidgets('No text is sent to glasses when display is muted', + (WidgetTester tester) async { + mockManager.setConnected(true); + await mockManager.transcription.start(); + await pumpLanding(tester); - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.scanning)); + await tester.tap(find.text('Mute display')); + await tester.pump(); + expect(find.text('Unmute display'), findsOneWidget); + + (mockManager.transcription as MockG1Transcription).clearDisplayCalls(); + fakeWs.aiResponse.value = 'Hello from backend'; await tester.pump(); - expect(find.text('Searching for glasses'), findsOneWidget); + final tx = mockManager.transcription as MockG1Transcription; + expect(tx.displayTextCalls, isEmpty); + expect(tx.displayLinesCalls, isEmpty); await disposeLanding(tester); }); - testWidgets('When connected show right text', (tester) async { + testWidgets('Text is sent to glasses when display is not muted', + (WidgetTester tester) async { + mockManager.setConnected(true); + await mockManager.transcription.start(); + await pumpLanding(tester); - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.connected)); + final tx = mockManager.transcription as MockG1Transcription; + tx.clearDisplayCalls(); + fakeWs.aiResponse.value = 'Hello from backend'; await tester.pump(); - expect(find.text('Connected'), findsOneWidget); + expect(tx.displayLinesCalls, isNotEmpty); + expect(tx.displayLinesCalls.last, contains('Hello from backend')); await disposeLanding(tester); + await tester.pump(const Duration(seconds: 11)); }); - testWidgets('Shows scanning state when connecting', (tester) async { + testWidgets('Connect to glasses button is shown when disconnected', + (tester) async { await pumpLanding(tester); - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.scanning)); - await tester.pump(); - expect(find.text('Searching for glasses'), findsOneWidget); - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.connecting)); - await tester.pump(); - expect(find.text('Connecting to glasses'), findsOneWidget); + const G1ConnectionEvent(state: G1ConnectionState.disconnected), + ); - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.connected)); await tester.pump(); - expect(find.text('Connected'), findsOneWidget); + + expect(find.text('Connect to glasses'), findsOneWidget); await disposeLanding(tester); }); test('Can send text to glasses when connected', () async { mockManager.setConnected(true); - await mockManager.sendTextToGlasses('test'); - final mockDisplay = mockManager.display as MockG1Display; expect(mockDisplay.getText, contains('test')); }); test('Cannot send text to glasses when not connected', () async { mockManager.setConnected(false); - await mockManager.sendTextToGlasses('test'); - final mockDisplay = mockManager.display as MockG1Display; - expect(mockDisplay.getText, []); + expect(mockDisplay.getText, isEmpty); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index e4eb53a..671da66 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSoundPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSoundPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 183a45b..849e876 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_sound permission_handler_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST