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 @@

-# 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