From aa2604c8ed9004e1dcafe780bcb55e78ff356e9b Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Mon, 2 Mar 2026 05:33:31 +0200 Subject: [PATCH 01/68] Added REST side panel for categories, conversations, and transcripts --- lib/models/api_models.dart | 72 ++++ lib/screens/landing_screen.dart | 263 ++++++++----- lib/services/rest_api_service.dart | 122 ++++++ lib/widgets/side_panel.dart | 596 +++++++++++++++++++++++++++++ 4 files changed, 951 insertions(+), 102 deletions(-) create mode 100644 lib/models/api_models.dart create mode 100644 lib/services/rest_api_service.dart create mode 100644 lib/widgets/side_panel.dart 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 335a444..f0eb99c 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -2,61 +2,71 @@ 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 '../widgets/g1_connection.dart'; +import '../services/rest_api_service.dart'; import '../services/websocket_service.dart'; +import '../widgets/g1_connection.dart'; +import '../widgets/side_panel.dart'; import 'login_screen.dart'; import 'register_screen.dart'; /// 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 + /// 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 { + // GlobalKey lets us open the drawer from multiple places,, + // not just from a local BuildContext next to the Scaffold. + final GlobalKey _scaffoldKey = GlobalKey(); + late final G1Manager _manager; late final Lc3Decoder _decoder; late final WebsocketService _ws; late final AudioPipeline _audioPipeline; + late final RestApiService _api; @override void initState() { super.initState(); - // Use injected dependencies or create real ones _manager = widget.manager ?? G1Manager(); _decoder = widget.decoder ?? Lc3Decoder(); _ws = widget.ws ?? WebsocketService(); + + // Added as injectable dependency so REST API access stays testable + // and consistent with other optional dependencies in this screen. + _api = widget.api ?? const RestApiService(); _audioPipeline = widget.audioPipeline ?? AudioPipeline( _manager, _decoder, onPcmData: (pcm) { - // Forward decoded pcm audio to the backend via WebSocket if (_ws.connected.value) _ws.sendAudio(pcm); }, ); - // Connect to backend WebSocket server when homePage is initialized _ws.connect(); - - // Add listener for mic audio packets from glasses _audioPipeline.addListenerToMicrophone(); - - // React to Speech to text updates from the backend - // Used to update the UI (fired when committedText/interimText is changed) _ws.committedText.addListener(_onWsChange); _ws.interimText.addListener(_onWsChange); } @@ -71,34 +81,37 @@ class _LandingScreenState extends State { super.dispose(); } - /// Forwards changes to the glasses display if connected and transcription is active. void _onWsChange() { if (_manager.isConnected && _manager.transcription.isActive.value) { - final text = _ws.getFullText(); _manager.transcription.displayText( - text, + _ws.getFullText(), isInterim: _ws.interimText.value.isNotEmpty, ); } } - /// Begin a transcription session Future _startTranscription() async { await _ws.startAudioStream(); await _manager.transcription.start(); } - /// End a transcription session Future _stopTranscription() async { await _audioPipeline.stop(); await _ws.stopAudioStream(); await _manager.transcription.stop(); } +// Added helper so both menu button and landing tile can open the side panel. + void _openDrawer() => _scaffoldKey.currentState?.openDrawer(); + @override Widget build(BuildContext context) { return Scaffold( + key: _scaffoldKey, + resizeToAvoidBottomInset: false, backgroundColor: Colors.white, + // Main integration point for the new REST side panel feature. + drawer: SidePanel(api: _api), body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), @@ -107,41 +120,75 @@ class _LandingScreenState extends State { Row( children: [ IconButton( - onPressed: () {}, + onPressed: _openDrawer, icon: const Icon(Icons.menu, color: Color(0xFF00239D)), + tooltip: 'Open history panel', ), - const Spacer(), + + // Top row layout changed to avoid right overflow on narrow phones. + // Old version used Spacer() + a long button, which overflowed. Image.asset( 'assets/images/Elisa_logo_blue_RGB.png', - height: 50, + height: 40, fit: BoxFit.contain, ), - const Spacer(), - Padding( - padding: const EdgeInsets.only(right: 8), - child: ListenableBuilder( - listenable: _ws.connected, - builder: (context, _) => _ws.connected.value - ? const Row( - children: [ - Icon(Icons.signal_cellular_alt, - color: Colors.green, size: 20), - SizedBox(width: 6), - Text('Connected', + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerRight, + child: ListenableBuilder( + listenable: _ws.connected, + builder: (context, _) => _ws.connected.value + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.signal_cellular_alt, + color: Colors.green, + size: 20, + ), + SizedBox(width: 6), + Text( + 'Connected', style: TextStyle( - fontSize: 12, color: Colors.green)), - ], - ) - : OutlinedButton.icon( - onPressed: () => _ws.connect(), - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Reconnect to server'), - ), - )), + fontSize: 12, + color: Colors.green, + ), + ), + ], + ) + : FittedBox( + // FittedBox added so the reconnect button can scale down + // instead of overflowing on smaller screens. + fit: BoxFit.scaleDown, + child: OutlinedButton.icon( + onPressed: () => _ws.connect(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text( + 'Reconnect to server', + overflow: TextOverflow.ellipsis, + ), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + ), + ), + ), + ), IconButton( - onPressed: () {}, - icon: const Icon(Icons.wb_sunny_outlined, - color: Color(0xFF00239D))), + onPressed: () {}, + icon: const Icon( + Icons.wb_sunny_outlined, + color: Color(0xFF00239D), + ), + ), ], ), Expanded( @@ -176,18 +223,21 @@ class _LandingScreenState extends State { Row( children: [ Expanded( + // Changed so "Key points" now opens the REST side panel. child: LandingTile( icon: Icons.list_alt, label: 'Key points', - onTap: () {}, + onTap: _openDrawer, ), ), const SizedBox(width: 14), Expanded( + // Kept disabled because no recordings feature was added here. (Changeable when available) child: LandingTile( icon: Icons.play_circle_outline, label: 'Recordings', onTap: () {}, + enabled: false, ), ), ], @@ -196,7 +246,9 @@ class _LandingScreenState extends State { Center( child: Container( padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 5), + horizontal: 14, + vertical: 5, + ), decoration: BoxDecoration( border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(8), @@ -215,47 +267,44 @@ class _LandingScreenState extends State { ), ), Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LoginScreen(), - ), - ); - }, - child: const Text( - 'Sign in', - style: TextStyle( - color: Color(0xFF00239D), - fontWeight: FontWeight.bold, - ), + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen(), ), ), - const Text('|'), - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const RegisterScreen(), - ), - ); - }, - child: const Text( - 'Register', - style: TextStyle( - color: Color(0xFF00239D), - fontWeight: FontWeight.bold, - ), + child: const Text( + 'Sign in', + style: TextStyle( + color: Color(0xFF00239D), + fontWeight: FontWeight.bold, ), ), - ], - )), + ), + const Text('|'), + TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const RegisterScreen(), + ), + ), + child: const Text( + 'Register', + style: TextStyle( + color: Color(0xFF00239D), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), ], ), ), @@ -280,25 +329,35 @@ class LandingTile extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Container( - height: 64, - 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), - const SizedBox(width: 10), - Expanded( + // Styling adjusted so disabled tiles look visibly disabled + // instead of feeling like broken clickable buttons. + return Opacity( + opacity: enabled ? 1.0 : 0.5, + child: InkWell( + onTap: enabled ? onTap : null, + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + border: Border.all(color: Colors.black12), + borderRadius: BorderRadius.circular(8), + color: enabled ? null : Colors.grey.withValues(alpha: 0.06), + ), + child: Row( + children: [ + Icon(icon, size: 22, color: enabled ? null : Colors.grey), + const SizedBox(width: 10), + Expanded( child: Text( - label, - style: const TextStyle(fontSize: 14), - )), - ], + label, + style: TextStyle( + fontSize: 14, + color: enabled ? null : Colors.grey, + ), + ), + ), + ], + ), ), ), ); diff --git a/lib/services/rest_api_service.dart b/lib/services/rest_api_service.dart new file mode 100644 index 0000000..69e5d77 --- /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('http://$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/widgets/side_panel.dart b/lib/widgets/side_panel.dart new file mode 100644 index 0000000..e6b3d98 --- /dev/null +++ b/lib/widgets/side_panel.dart @@ -0,0 +1,596 @@ +import 'package:flutter/material.dart'; + +import '../models/api_models.dart'; +import '../services/rest_api_service.dart'; + +// This widget was added as the new REST-driven side drawer. +// Focuses on categories, conversations, and transcript segments. +class SidePanel extends StatefulWidget { + const SidePanel({ + super.key, + required this.api, + }); + +// Injected service instead of costructing it inside the widget +// so that the widget is easier to test and stays consistent with other dependencies. + final RestApiService api; + + @override + State createState() => _SidePanelState(); +} + +class _SidePanelState extends State { + List _categories = []; + List _conversations = []; + List _vectors = []; + + Category? _selectedCategory; + Conversation? _selectedConversation; + +// Separate loading states were added so only the relevant section +// shows loading, instead of blanking out the whole drawer. + bool _loadingCategories = false; + bool _loadingConversations = false; + bool _loadingVectors = false; + +// Separate errors per section for more useful debugging and UX. + String? _categoryError; + String? _conversationError; + String? _vectorError; + +// Inline category creation UI. + bool _showNewCategoryField = false; + final _newCatController = TextEditingController(); + bool _creatingCategory = false; + String? _createCategoryError; + + @override + void initState() { + super.initState(); + _loadCategories(); + _loadConversations(); + } + + @override + void dispose() { + _newCatController.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; + + // Changing category clears selected conversation + // and transcript segments because they may no longer match. (possible for change) + _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; + _newCatController.clear(); + setState(() => _showNewCategoryField = false); + + // After creating a new category, reload categories so the chip list updates. + await _loadCategories(); + } 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 { + // These ids are preserved so refresh can store the selected conversations + // instead of losing transcript context unnecessarily. + 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); + _loadConversations(categoryId: cat?.id); + } + + void _selectConversation(Conversation conv) { + // Tapping the selected conversation again collapses the transcript section. + 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')}'; + } + + @override + Widget build(BuildContext context) { + return Drawer( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + Expanded( + child: RefreshIndicator( + onRefresh: _refreshAll, + child: ListView( + padding: EdgeInsets.zero, + children: [ + // Focuses only on relevant data, could possibly add counts if needed/wanted + _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, + ), + ), + ), + // Refresh button added so that the drawer can re-fetch backend data + // without needing to fully close/reopen it. + 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: [ + // "All" chip added so the user can clear category filtering. + 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), + ), + ), + ), + // "New" action chip added to open the inline category creation form. + ActionChip( + avatar: const Icon(Icons.add, size: 16), + label: const Text('New'), + onPressed: () => setState( + () => _showNewCategoryField = !_showNewCategoryField, + ), + ), + ], + ), + ); + } + + Widget _buildNewCategoryRow() { + if (!_showNewCategoryField) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _newCatController, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + 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; + }), + ), + ], + ), + 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; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + dense: true, + selected: isSelected, + // Highlight added so the selected conversation is visually obvious. + selectedTileColor: + const Color(0xFF00239D).withValues(alpha: 0.08), + 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), + ), + // Conversation summary shown only for the currently selected item + // to keep the list compact. + if (isSelected && conv.summary.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(56, 0, 16, 8), + child: Text( + conv.summary, + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + }).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: [ + // Segment numbering added for easier reading/debugging. + 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'), + ), + ], + ), + ); + } +} From f44fe069a7c623234630bc2351a121fcef51160e Mon Sep 17 00:00:00 2001 From: HorttanainenSami Date: Thu, 5 Mar 2026 17:54:39 +0200 Subject: [PATCH 02/68] Update README.md Add build instructions for android --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 9b0232f..48bb497 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,19 @@ When you return to coding: ``` --- +## Build app to android + +run command + +```bash +flutter build apk --dart-define-from-file=config_staging.json +``` + +then install it to usb connected android phone + +```bash +flutter install +``` ## Project structure (Flutter) From 2393374ec7c47788388d14e66fc06d1c39ab7c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:07:06 +0200 Subject: [PATCH 03/68] fix: refactor github action --- .github/workflows/main.yml | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 27b534e..c16f682 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Frontend CI (Flutter) +name: Frontend CI on: push: @@ -11,23 +11,10 @@ jobs: 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 +22,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 . - 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 From d0fe43c459c200584d60f56ef469b71cf30553a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:09:55 +0200 Subject: [PATCH 04/68] fix: more CI refactor --- .github/workflows/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c16f682..6b7a3b0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,7 +9,6 @@ on: jobs: flutter: runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@v6 From 8ead4c815a55f27be17e31fe39967dfe066d5cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:27:53 +0200 Subject: [PATCH 05/68] fix: macos files --- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 ++++++++++++++++++++++++++ macos/Flutter/Flutter-Debug.xcconfig | 1 + macos/Flutter/Flutter-Release.xcconfig | 1 + macos/Podfile | 42 +++++++++++++++++++++++++ 6 files changed, 89 insertions(+) create mode 100644 ios/Podfile create mode 100644 macos/Podfile 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/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/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 From 4a9f07049841686638e8790615b2e6f645f77d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:41:37 +0200 Subject: [PATCH 06/68] fix: stop tracking vscode files --- .gitignore | 6 ++---- .vscode/launch.json | 23 ----------------------- .vscode/settings.json | 13 ------------- 3 files changed, 2 insertions(+), 40 deletions(-) delete mode 100644 .vscode/launch.json delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 84d9097..9c570ce 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/ 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 -} From 63365f4bf646f309f8f7253914ea9b7fdcd5038d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:57:31 +0200 Subject: [PATCH 07/68] fix: edit comment Co-authored-by: vainiovesa <186166946+vainiovesa@users.noreply.github.com> --- lib/main.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0596182..bf49d39 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,9 +4,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 - + fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, color: false); // Disable logging runApp(const MyApp()); } From 987df3eb69f2caba5427be697125243df145d073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:09:45 +0200 Subject: [PATCH 08/68] fix: format --- lib/main.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index bf49d39..844cf96 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,8 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; void main() { WidgetsFlutterBinding.ensureInitialized(); - fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, color: false); // Disable logging + fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, + color: false); // Disable logging for fbp package runApp(const MyApp()); } From 85ad69f0664e3dacd652bad6c23f65d42e8a749d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:12:26 +0200 Subject: [PATCH 09/68] fix: format --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 844cf96..f258a60 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +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); // Disable logging for fbp package + color: false); // Disable logging for fbp package runApp(const MyApp()); } From 5a06ccafb40f377b1c993953960769de7014eb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:13:03 +0200 Subject: [PATCH 10/68] feat: remove output=none from dart format CI --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6b7a3b0..5c73020 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ jobs: run: flutter pub get - name: Check formatting - run: dart format --output=none --set-exit-if-changed . + run: dart format --set-exit-if-changed . - name: Analyze run: flutter analyze From e92aa485f85322e85dec361d80cbb719c15f4fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:13:12 +0200 Subject: [PATCH 11/68] feat: renovate app startup config Co-authored-by: vainiovesa <186166946+vainiovesa@users.noreply.github.com> --- .gitignore | 4 +--- config_staging.example.json | 2 +- lib/services/websocket_service.dart | 24 ++++++++++++++++++++---- run.sh | 9 --------- 4 files changed, 22 insertions(+), 17 deletions(-) delete mode 100755 run.sh diff --git a/.gitignore b/.gitignore index 9c570ce..9b552aa 100644 --- a/.gitignore +++ b/.gitignore @@ -45,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/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/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index b9ceda0..c601859 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -22,10 +22,21 @@ import 'package:web_socket_channel/web_socket_channel.dart'; class WebsocketService { final String baseUrl; + static const String _defaultBaseUrl = '127.0.0.1:8000'; + static const String _apiUrlFromEnv = String.fromEnvironment('API_URL'); + WebsocketService({ - this.baseUrl = - const String.fromEnvironment('API_URL', defaultValue: '127.0.0.1:8000'), - }); + String? baseUrl, + }) : baseUrl = baseUrl ?? + const String.fromEnvironment('API_URL', + defaultValue: _defaultBaseUrl) { + if (baseUrl == null && _apiUrlFromEnv.isEmpty) { + debugPrint( + 'WARNING: API_URL is not set; using default baseUrl=$_defaultBaseUrl. ' + 'Set via --dart-define-from-file=config_.json', + ); + } + } WebSocketChannel? _audioChannel; @@ -45,8 +56,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; 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" From 0fed30eb00921325224a2c68d6102dd47b352a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:35:20 +0200 Subject: [PATCH 12/68] fix: refactor --- lib/services/websocket_service.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index c601859..52adcfb 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -21,18 +21,15 @@ import 'package:web_socket_channel/web_socket_channel.dart'; /// Raw PCM bytes (binary frame) class WebsocketService { final String baseUrl; + static const String defaultBaseUrl = '127.0.0.1:8000'; - static const String _defaultBaseUrl = '127.0.0.1:8000'; - static const String _apiUrlFromEnv = String.fromEnvironment('API_URL'); - - WebsocketService({ - String? baseUrl, - }) : baseUrl = baseUrl ?? + WebsocketService({String? baseUrl}) + : baseUrl = baseUrl ?? const String.fromEnvironment('API_URL', - defaultValue: _defaultBaseUrl) { - if (baseUrl == null && _apiUrlFromEnv.isEmpty) { + defaultValue: defaultBaseUrl) { + if (baseUrl == null && const String.fromEnvironment('API_URL').isEmpty) { debugPrint( - 'WARNING: API_URL is not set; using default baseUrl=$_defaultBaseUrl. ' + 'WARNING: API_URL is not set; using default baseUrl=$defaultBaseUrl. ' 'Set via --dart-define-from-file=config_.json', ); } From 94129cf274aab1e2e770792d0e877779298868a7 Mon Sep 17 00:00:00 2001 From: vainiovesa Date: Tue, 10 Mar 2026 14:00:46 +0200 Subject: [PATCH 13/68] feat: add mute button --- lib/screens/landing_screen.dart | 60 ++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index c788fa8..3339593 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -35,6 +35,7 @@ class _LandingScreenState extends State { late final PhoneAudioService _phoneAudio; bool _usePhoneMic = false; + bool _isMuted = false; final ValueNotifier _isRecording = ValueNotifier(false); final List _displayedSentences = []; @@ -415,12 +416,61 @@ class _LandingScreenState extends State { const SizedBox(width: 14), - // Recordings placeholder + // Mute button Expanded( - child: LandingTile( - icon: Icons.play_circle_outline, - label: 'Recordings', - onTap: () {}, + 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], + ), + ), + ), + ], + ), + ), ), ), ], From 83396265fa5aa78aae825593b6389c8324fcef9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:03:22 +0200 Subject: [PATCH 14/68] fix: update mic icon and text for clarity --- lib/screens/landing_screen.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 3339593..9e7e26f 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -310,7 +310,7 @@ class _LandingScreenState extends State { children: [ _usePhoneMic ? const Icon( - Icons.mic, + Icons.phone_android, size: 22, color: Colors.lightGreen, ) @@ -323,8 +323,8 @@ class _LandingScreenState extends State { Expanded( child: Text( _usePhoneMic - ? 'Phone mic\n(Active)' - : 'Glasses mic\n(Active)', + ? 'Switch to glasses mic' + : 'Switch to phone mic', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, From 77c2b23e8fdc00e9ce12b3c5f73acfb6c3391d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:45:12 +0200 Subject: [PATCH 15/68] feat: update readme --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 48bb497..02ddffe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![GHA workflow badge](https://github.com/AI-Smarties/front/actions/workflows/main.yml/badge.svg) -# AI-Smarties - Frontend (Flutter) +# AI-Smarties - Frontend (Dart + Flutter) ## Requirements @@ -22,7 +22,7 @@ ## 2. Switch to development branch (dev) ```bash - git checkout dev + git switch dev ``` ## 3. Confirm the Flutter environment @@ -148,9 +148,7 @@ then install it to usb connected android phone flutter install ``` -## Project structure (Flutter) - -When `flutter create .` is run, the structure is typically: +## Project structure - `lib/` – Application UI and application logic - `test/` – Unit- and widget testing @@ -164,3 +162,6 @@ When `flutter create .` is run, the structure is typically: ## 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) From 267ea3f8882469752782078708c623beefa59aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:40:39 +0200 Subject: [PATCH 16/68] feat: add functionality for display mute Co-authored-by: vainiovesa --- lib/screens/landing_screen.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 9e7e26f..e87446b 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -84,9 +84,13 @@ class _LandingScreenState extends State { 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'"); } } From e15e043202096b10f6acff26e93c13cba5ecf2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:42:22 +0200 Subject: [PATCH 17/68] fix: edit ios files --- ios/Podfile.lock | 42 ++++++ ios/Runner.xcodeproj/project.pbxproj | 130 ++++++++++++++++++ .../contents.xcworkspacedata | 3 + 3 files changed, 175 insertions(+) create mode 100644 ios/Podfile.lock diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..ce5377c --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,42 @@ +PODS: + - 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: + - 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: + 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: + 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..00afd12 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,29 @@ 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 */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +136,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 820C0A71E31DDDCBE4781028 /* Pods */, + 3A67259495ADFC50EA787FF3 /* Frameworks */, ); sourceTree = ""; }; @@ -128,8 +172,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 +191,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 +271,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 +331,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 +363,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 */ @@ -378,6 +505,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E3CB0A10B61A6DA72D29435D /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +523,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F85674E7919C9C6910F5C52A /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +539,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6F703DB2DF8D00352F539BEF /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; 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 @@ + + From 528658013b2100afdf07385deb0ec6a2d07bc6e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:57:43 +0200 Subject: [PATCH 18/68] fix: tests --- test/widget_test.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/widget_test.dart b/test/widget_test.dart index 5003a35..d023755 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -31,13 +31,16 @@ 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('Connect to glasses'), findsOneWidget); + expect(find.text('Switch to phone mic'), 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); }); From 2f3746325d202337d1e831dfcfc8d00f5794b84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:10:32 +0200 Subject: [PATCH 19/68] feat: add functionality for blocking pressing buttons when not supposed to be pressed --- lib/screens/landing_screen.dart | 405 +++++++++++++++++++------------- 1 file changed, 246 insertions(+), 159 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index e87446b..ab7db22 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -37,6 +37,7 @@ class _LandingScreenState extends State { bool _usePhoneMic = false; bool _isMuted = false; final ValueNotifier _isRecording = ValueNotifier(false); + final ValueNotifier _isRecordingBusy = ValueNotifier(false); final List _displayedSentences = []; static const int _maxDisplayedSentences = 4; @@ -72,6 +73,7 @@ class _LandingScreenState extends State { void dispose() { _ws.aiResponse.removeListener(_onAiResponse); _isRecording.dispose(); + _isRecordingBusy.dispose(); _audioPipeline.dispose(); _phoneAudio.dispose(); _ws.dispose(); @@ -124,63 +126,76 @@ class _LandingScreenState extends State { /// 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 { + 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); + } + }); + } else { + await _manager.microphone.enable(); + _audioPipeline.addListenerToMicrophone(); + } + + await _manager.transcription.displayText('Recording started.'); + debugPrint("Transcription (re)started"); } else { - await _manager.microphone.enable(); - _audioPipeline.addListenerToMicrophone(); + //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); + }, + ); } - - 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(); + } + // 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 _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; } } @@ -287,63 +302,95 @@ class _LandingScreenState extends State { // 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 + 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, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _usePhoneMic - ? const Icon( - Icons.phone_android, - size: 22, - color: Colors.lightGreen, - ) - : Image.asset( - 'assets/images/g1-smart-glasses.webp', - height: 22, - fit: BoxFit.contain, + : 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(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: _usePhoneMic - ? Colors.lightGreen - : Colors.black, - ), + ], ), ), - ], - ), - ), + ), + ); + }, ), ), ], @@ -355,64 +402,103 @@ class _LandingScreenState extends State { 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, - padding: const EdgeInsets.symmetric( - horizontal: 14), - decoration: BoxDecoration( - color: isRecording - ? Colors.red - .withAlpha((0.15 * 255).round()) - : Colors.transparent, - border: Border.all( - color: isRecording + 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]), + builder: (context, _) { + final isRecording = _isRecording.value; + final isBusy = _isRecordingBusy.value; + + final canStart = _usePhoneMic || + isGlassesConnected == true; + + final isDisabled = + isBusy || (!isRecording && !canStart); + + final borderColor = isDisabled + ? Colors.black26 + : (isRecording ? Colors.red - : 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], - ), - 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], - ), + : 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, + ), + ), + ), + ], ), ), - ], - ), - ), + ), + ); + }, ); }, ), @@ -438,9 +524,8 @@ class _LandingScreenState extends State { .withAlpha((0.15 * 255).round()) : Colors.transparent, border: Border.all( - color: _isMuted - ? Colors.orange - : Colors.black12, + color: + _isMuted ? Colors.orange : Colors.black12, ), borderRadius: BorderRadius.circular(8), ), @@ -459,7 +544,9 @@ class _LandingScreenState extends State { const SizedBox(width: 10), Expanded( child: Text( - _isMuted ? 'Unmute display' : 'Mute display', + _isMuted + ? 'Unmute display' + : 'Mute display', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, From 4d2307c98f31e75eec7e3fb904318c5d893a2446 Mon Sep 17 00:00:00 2001 From: saraayy <128136969+saraayy@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:38:45 +0200 Subject: [PATCH 20/68] Send calendar context when recording starts --- android/app/src/main/AndroidManifest.xml | 1 + lib/screens/landing_screen.dart | 26 ++++- lib/services/calendar_service.dart | 119 +++++++++++++++++++++++ lib/services/websocket_service.dart | 6 ++ pubspec.lock | 24 +++++ pubspec.yaml | 1 + 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 lib/services/calendar_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index af13917..445a5d6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + { late final Lc3Decoder _decoder; late final WebsocketService _ws; late final AudioPipeline _audioPipeline; + late final CalendarService _calendarService; @override void initState() { @@ -39,6 +41,7 @@ class _LandingScreenState extends State { _manager = widget.manager ?? G1Manager(); _decoder = widget.decoder ?? Lc3Decoder(); _ws = widget.ws ?? WebsocketService(); + _calendarService = CalendarService(); _audioPipeline = widget.audioPipeline ?? AudioPipeline( _manager, @@ -106,10 +109,16 @@ class _LandingScreenState extends State { children: [ Row( children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.menu, color: Color(0xFF00239D)), - ), + SizedBox( + width: 96, + child: Align( + alignment: Alignment.centerLeft, + child: IconButton( + onPressed: () {}, + icon: const Icon(Icons.menu, color: Color(0xFF00239D)), + ), + ), + ), const Spacer(), Image.asset( 'assets/images/Elisa_logo_blue_RGB.png', @@ -166,6 +175,15 @@ class _LandingScreenState extends State { manager: _manager, onRecordToggle: () async { if (!_manager.transcription.isActive.value) { + final granted = await _calendarService.requestPermission(); + if (granted) { + final events = await _calendarService.getUpcomingEvents(); + final activeEvent = _calendarService.selectActiveContext(events); + if (activeEvent != null) { + final payload = _calendarService.buildCalendarPayload(activeEvent); + _ws.sendCalendarContext(payload); + } + } await _startTranscription(); } else { await _stopTranscription(); diff --git a/lib/services/calendar_service.dart b/lib/services/calendar_service.dart new file mode 100644 index 0000000..9a3aff7 --- /dev/null +++ b/lib/services/calendar_service.dart @@ -0,0 +1,119 @@ +import 'package:device_calendar/device_calendar.dart'; + + + + +class CalendarService { + final DeviceCalendarPlugin _calendarPlugin = DeviceCalendarPlugin(); + + //Requests calendar permission + Future requestPermission () async { + var permissionsGranted = await _calendarPlugin.requestPermissions(); + if (permissionsGranted.isSuccess && permissionsGranted.data == true) { + return true; + // Permission granted, you can now access the calendar + } else { + return false; + // Permission denied, handle accordingly + } + } + + //Searches for upcoming events in the next 7 days + Future > getUpcomingEvents() async { + var calendarResult = await _calendarPlugin.retrieveCalendars(); + if (calendarResult.isSuccess && calendarResult.data != null) { + List calendars = calendarResult.data!; + List events = []; + + DateTime startDate = DateTime.now(); + DateTime endDate = startDate.add(const Duration(days: 7)); + + + for (var calendar in calendars) { + var eventResult = await _calendarPlugin.retrieveEvents( + calendar.id!, + RetrieveEventsParams(startDate: startDate, endDate: endDate), + ); + + if (eventResult.isSuccess && eventResult.data != null) { + List calendarEvents = eventResult.data!; + for (var event in calendarEvents) { + if (event.start != null && event.end != null) { + events.add( + CalendarEventModel( + title: event.title ?? 'No Title', + description: event.description, + start: event.start!, + end: event.end!, + ), + ); + } + } + } + } + events.sort((a, b) => a.start.compareTo(b.start)); + return events; + } + return []; + } + + //Selects the active or upcoming event + CalendarEventModel? selectActiveContext(List events) { + DateTime now = DateTime.now(); + //Event is happening now + for (var event in events) { + if (event.start.isBefore(now) && event.end.isAfter(now) ) { + return event; + } + } + //Upcoming event + for (var event in events) { + if (event.start.isAfter(now)) { + return event; + } + } + //No active or upcoming events + return null; + } + + //Builds the payload to send to backend + Map buildCalendarPayload(CalendarEventModel? event) { + if (event == null) { + return { + "type": "calendar_context", + "data": { + "title": "General conversation", + "description": null, + "start": null, + "end": null + } + }; + } + return { + "type": "calendar_context", + "data": { + "title": event.title, + "description": event.description, + "start": event.start.toIso8601String(), + "end": event.end.toIso8601String() + } + }; + } + + +} + +// Model to represent calendar events in a simplified way for our application +class CalendarEventModel { + final String title; + final String? description; + final DateTime start; + final DateTime end; + + CalendarEventModel({ + required this.title, + required this.description, + required this.start, + required this.end, + }); +} \ No newline at end of file diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index a80017e..c9038f4 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -106,6 +106,12 @@ class WebsocketService { } } + void sendCalendarContext(Map payload) { + if (connected.value) { + _audioChannel?.sink.add(jsonEncode(payload)); + } + } + /// Tell the backend to stop expecting audio data. Future stopAudioStream() async { if (connected.value) { diff --git a/pubspec.lock b/pubspec.lock index 58e2349..ab477ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + device_calendar: + dependency: "direct main" + description: + name: device_calendar + sha256: "683fb93ec302b6a65c0ce57df40ff9dcc2404f59c67a2f8b93e59318c8a0a225" + url: "https://pub.dev" + source: hosted + version: "4.3.3" even_realities_g1: dependency: "direct main" description: @@ -292,6 +300,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -332,6 +348,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 930cc3a..3ba24f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: cupertino_icons: ^1.0.8 # Local path dependency + device_calendar: ^4.3.3 even_realities_g1: path: packages/even_realities_g1 flutter: From 6406bf2383f12c12b75caaf78e191ad0f086d276 Mon Sep 17 00:00:00 2001 From: saraayy <128136969+saraayy@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:49:46 +0200 Subject: [PATCH 21/68] Configure device calendar plugin and Cocoapods integration for ios --- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 +++++++ ios/Podfile.lock | 29 +++++ ios/Runner.xcodeproj/project.pbxproj | 121 +++++++++++++++++- .../contents.xcworkspacedata | 3 + ios/Runner/Info.plist | 10 +- 7 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 ios/Podfile create mode 100644 ios/Podfile.lock 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..23eab87 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '14.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..fbeca2d --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - device_calendar (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - device_calendar (from `.symlinks/plugins/device_calendar/ios`) + - Flutter (from `Flutter`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + +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" + +SPEC CHECKSUMS: + device_calendar: b55b2c5406cfba45c95a59f9059156daee1f74ed + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 + +PODFILE CHECKSUM: 8c4d30c2258325612f2b7276ac7aac1f617fcf63 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1d1b77b..6413778 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 99F5015DBD6F6F67074A1B9D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9B72C4D29009EF8D1D1EEA7 /* Pods_Runner.framework */; }; + F8A02A1E8E6684C1D8450DF1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9C6A6BD5AE93A3E540561BF /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,9 +47,12 @@ 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; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 61CA3FFAF8421699CD486D19 /* 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 = ""; }; 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 807E81FEBDA1B3C207D2F02D /* 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 = ""; }; + 96D8DD3C040343A039F310FE /* 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 = ""; }; 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 +60,27 @@ 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 = ""; }; + A66E4728E7B9DA7D3BF9212D /* 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 = ""; }; + A9B72C4D29009EF8D1D1EEA7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E805B90C1540E73EE0890817 /* 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 = ""; }; + E9C6A6BD5AE93A3E540561BF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F06CDC837EF05FDD4CA99461 /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 7A60E1A3672AB68E0E2A42D1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F8A02A1E8E6684C1D8450DF1 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 99F5015DBD6F6F67074A1B9D /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -94,6 +113,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + F0CFB2D7AEEBFE525929A1A9 /* Pods */, + CAAD90FF3B44AD28E7C94A7C /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +142,29 @@ path = Runner; sourceTree = ""; }; + CAAD90FF3B44AD28E7C94A7C /* Frameworks */ = { + isa = PBXGroup; + children = ( + A9B72C4D29009EF8D1D1EEA7 /* Pods_Runner.framework */, + E9C6A6BD5AE93A3E540561BF /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + F0CFB2D7AEEBFE525929A1A9 /* Pods */ = { + isa = PBXGroup; + children = ( + F06CDC837EF05FDD4CA99461 /* Pods-Runner.debug.xcconfig */, + A66E4728E7B9DA7D3BF9212D /* Pods-Runner.release.xcconfig */, + 61CA3FFAF8421699CD486D19 /* Pods-Runner.profile.xcconfig */, + 807E81FEBDA1B3C207D2F02D /* Pods-RunnerTests.debug.xcconfig */, + E805B90C1540E73EE0890817 /* Pods-RunnerTests.release.xcconfig */, + 96D8DD3C040343A039F310FE /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + FEF6B413DA9FC937656B660D /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 7A60E1A3672AB68E0E2A42D1 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + BEC52B047FD349E6B05F8651 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 62220B443F82F6667CD7939F /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -238,6 +286,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 62220B443F82F6667CD7939F /* [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; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +318,50 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + BEC52B047FD349E6B05F8651 /* [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; + }; + FEF6B413DA9FC937656B660D /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -362,13 +471,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.front-ai-smarties"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -378,6 +488,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 807E81FEBDA1B3C207D2F02D /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +506,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E805B90C1540E73EE0890817 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +522,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 96D8DD3C040343A039F310FE /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -541,13 +654,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.front-ai-smarties"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -563,13 +677,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.front-ai-smarties"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7788352..010caf8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,10 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCalendarsFullAccessUsageDescription + Access most functions for calendar viewing. + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +47,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - From d7cbd6eb4157fc5ac2d3f5eff9b739eea84650ba Mon Sep 17 00:00:00 2001 From: saraayy <128136969+saraayy@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:51:11 +0200 Subject: [PATCH 22/68] Add CalendarService unit tests --- test/services/calendar_service_test.dart | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 test/services/calendar_service_test.dart diff --git a/test/services/calendar_service_test.dart b/test/services/calendar_service_test.dart new file mode 100644 index 0000000..6aec032 --- /dev/null +++ b/test/services/calendar_service_test.dart @@ -0,0 +1,83 @@ +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); + }); + }); +} + + From a8a638f83831e23f5258147825ce79f08e4cd17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:02:34 +0200 Subject: [PATCH 23/68] fix: update package versions in pubspec.lock --- pubspec.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 1ecbb85..1ce19e3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,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: @@ -307,18 +307,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: @@ -544,10 +544,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: From 5b8fb07feee8fe0f27b9cf9e98300a7a768e18d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:19:37 +0200 Subject: [PATCH 24/68] feat: working IOS build --- ios/Flutter/AppFrameworkInfo.plist | 2 - ios/Runner.xcodeproj/project.pbxproj | 10 ++-- ios/Runner/AppDelegate.swift | 7 ++- ios/Runner/Info.plist | 33 ++++++++-- .../lib/src/bluetooth/g1_glass.dart | 11 ++-- .../lib/src/bluetooth/g1_manager.dart | 60 +++++++++++++++++-- 6 files changed, 102 insertions(+), 21 deletions(-) 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/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 00afd12..8593100 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -114,7 +114,6 @@ F85674E7919C9C6910F5C52A /* Pods-RunnerTests.release.xcconfig */, 6F703DB2DF8D00352F539BEF /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -489,13 +488,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = H5536DZLJW; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.ai-smarties.front"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -671,13 +671,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = H5536DZLJW; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.ai-smarties.front"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -693,13 +694,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = H5536DZLJW; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.front; + PRODUCT_BUNDLE_IDENTIFIER = "com.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/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..7f439fd 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,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/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; } } From f58f7f6e5874a8373d191b5daa4bd270f6bf1942 Mon Sep 17 00:00:00 2001 From: saraayy <128136969+saraayy@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:13:33 +0200 Subject: [PATCH 25/68] Apply dart format --- lib/screens/landing_screen.dart | 16 ++++-- lib/services/calendar_service.dart | 22 +++----- test/services/calendar_service_test.dart | 72 +++++++++++------------- 3 files changed, 51 insertions(+), 59 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 021af72..4435a7b 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -109,7 +109,7 @@ class _LandingScreenState extends State { children: [ Row( children: [ - SizedBox( + SizedBox( width: 96, child: Align( alignment: Alignment.centerLeft, @@ -118,7 +118,7 @@ class _LandingScreenState extends State { icon: const Icon(Icons.menu, color: Color(0xFF00239D)), ), ), - ), + ), const Spacer(), Image.asset( 'assets/images/Elisa_logo_blue_RGB.png', @@ -175,12 +175,16 @@ class _LandingScreenState extends State { manager: _manager, onRecordToggle: () async { if (!_manager.transcription.isActive.value) { - final granted = await _calendarService.requestPermission(); + final granted = + await _calendarService.requestPermission(); if (granted) { - final events = await _calendarService.getUpcomingEvents(); - final activeEvent = _calendarService.selectActiveContext(events); + final events = + await _calendarService.getUpcomingEvents(); + final activeEvent = + _calendarService.selectActiveContext(events); if (activeEvent != null) { - final payload = _calendarService.buildCalendarPayload(activeEvent); + final payload = _calendarService + .buildCalendarPayload(activeEvent); _ws.sendCalendarContext(payload); } } diff --git a/lib/services/calendar_service.dart b/lib/services/calendar_service.dart index 9a3aff7..2eabc15 100644 --- a/lib/services/calendar_service.dart +++ b/lib/services/calendar_service.dart @@ -1,13 +1,10 @@ import 'package:device_calendar/device_calendar.dart'; - - - class CalendarService { final DeviceCalendarPlugin _calendarPlugin = DeviceCalendarPlugin(); //Requests calendar permission - Future requestPermission () async { + Future requestPermission() async { var permissionsGranted = await _calendarPlugin.requestPermissions(); if (permissionsGranted.isSuccess && permissionsGranted.data == true) { return true; @@ -18,17 +15,16 @@ class CalendarService { } } - //Searches for upcoming events in the next 7 days - Future > getUpcomingEvents() async { + //Searches for upcoming events in the next 7 days + Future> getUpcomingEvents() async { var calendarResult = await _calendarPlugin.retrieveCalendars(); if (calendarResult.isSuccess && calendarResult.data != null) { List calendars = calendarResult.data!; List events = []; - + DateTime startDate = DateTime.now(); DateTime endDate = startDate.add(const Duration(days: 7)); - for (var calendar in calendars) { var eventResult = await _calendarPlugin.retrieveEvents( calendar.id!, @@ -48,13 +44,13 @@ class CalendarService { ), ); } - } + } } } events.sort((a, b) => a.start.compareTo(b.start)); return events; } - return []; + return []; } //Selects the active or upcoming event @@ -62,7 +58,7 @@ class CalendarService { DateTime now = DateTime.now(); //Event is happening now for (var event in events) { - if (event.start.isBefore(now) && event.end.isAfter(now) ) { + if (event.start.isBefore(now) && event.end.isAfter(now)) { return event; } } @@ -99,8 +95,6 @@ class CalendarService { } }; } - - } // Model to represent calendar events in a simplified way for our application @@ -116,4 +110,4 @@ class CalendarEventModel { required this.start, required this.end, }); -} \ No newline at end of file +} diff --git a/test/services/calendar_service_test.dart b/test/services/calendar_service_test.dart index 6aec032..3b6252f 100644 --- a/test/services/calendar_service_test.dart +++ b/test/services/calendar_service_test.dart @@ -12,38 +12,36 @@ void main() { }); 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); + 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'); + 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); + 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'); + expect(result, isNotNull); + expect(result!.title, 'Project deadline'); }); }); @@ -52,26 +50,24 @@ void main() { 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') - ); + 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']['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') - ); + 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'); @@ -79,5 +75,3 @@ void main() { }); }); } - - From 9d66271023f5a9026d69bea75b256560be5506c6 Mon Sep 17 00:00:00 2001 From: saraayy <128136969+saraayy@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:03:31 +0200 Subject: [PATCH 26/68] Apply dart format --- lib/screens/landing_screen.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 1b22dff..86b3ded 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -283,10 +283,10 @@ class _LandingScreenState extends State { final granted = await _calendarService.requestPermission(); if (granted) { - final events = - await _calendarService.getUpcomingEvents(); - final activeEvent = - _calendarService.selectActiveContext(events); + final events = await _calendarService + .getUpcomingEvents(); + final activeEvent = _calendarService + .selectActiveContext(events); if (activeEvent != null) { final payload = _calendarService .buildCalendarPayload(activeEvent); From 176a0371ee1fac8d6fe1b3e27390882f268bedd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:32 +0200 Subject: [PATCH 27/68] feat: tests --- lib/screens/landing_screen.dart | 2 + lib/services/websocket_service.dart | 29 +++++++++--- test/ble_mock/g1_manager_mock.dart | 17 ++++++- test/widget_test.dart | 73 +++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index ab7db22..89b0d84 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -103,6 +103,7 @@ class _LandingScreenState extends State { /// Sentences never disappear on a timer — they scroll off only when /// pushed out by new ones. void _addSentenceToDisplay(String sentence) { + if (_isMuted) return; if (_displayedSentences.length >= _maxDisplayedSentences) { _displayedSentences.removeAt(0); } @@ -113,6 +114,7 @@ class _LandingScreenState extends State { ); Future.delayed(const Duration(seconds: 10), () { + if (!mounted || _isMuted) return; _displayedSentences.remove(sentence); _manager.transcription.displayLines( List.unmodifiable(_displayedSentences), diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index 52adcfb..3bb5d1d 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -149,11 +149,28 @@ class WebsocketService { } void dispose() { - disconnect(); - connected.dispose(); - committedText.dispose(); - interimText.dispose(); - asrActive.dispose(); - aiResponse.dispose(); + // `disconnect()` is async and may touch ValueNotifiers after awaiting. + // During dispose we must not schedule work that can run after notifiers + // are disposed. + final channel = _audioChannel; + _audioChannel = null; + connected.value = false; + try { + channel?.sink.add(jsonEncode({'type': 'control', 'cmd': 'stop'})); + channel?.sink.close(); + } catch (_) { + // ignore: connection may already be closed + } finally { + committedText.value = ''; + interimText.value = ''; + asrActive.value = false; + aiResponse.value = ''; + + connected.dispose(); + committedText.dispose(); + interimText.dispose(); + asrActive.dispose(); + aiResponse.dispose(); + } } } diff --git a/test/ble_mock/g1_manager_mock.dart b/test/ble_mock/g1_manager_mock.dart index afa2579..339506d 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,19 @@ 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/widget_test.dart b/test/widget_test.dart index d023755..d9ff236 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,16 @@ 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 'package:front/services/websocket_service.dart'; + +class TestWebsocketService extends WebsocketService { + TestWebsocketService() : super(baseUrl: 'test'); + + @override + Future connect() async { + connected.value = true; + } +} void main() { late MockG1Manager mockManager; @@ -45,6 +55,69 @@ void main() { await disposeLanding(tester); }); + testWidgets('No text is sent to glasses when display is muted', + (WidgetTester tester) async { + final ws = TestWebsocketService(); + mockManager.setConnected(true); + await mockManager.transcription.start(); + + await tester.pumpWidget( + MaterialApp( + home: LandingScreen( + manager: mockManager, + ws: ws, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Mute display')); + await tester.pump(); + expect(find.text('Unmute display'), findsOneWidget); + + (mockManager.transcription as MockG1Transcription).clearDisplayCalls(); + + ws.aiResponse.value = 'Hello from backend'; + await tester.pump(); + + final tx = mockManager.transcription as MockG1Transcription; + expect(tx.displayTextCalls, isEmpty); + expect(tx.displayLinesCalls, isEmpty); + + await disposeLanding(tester); + }); + + testWidgets('Text is sent to glasses when display is not muted', + (WidgetTester tester) async { + final ws = TestWebsocketService(); + mockManager.setConnected(true); + await mockManager.transcription.start(); + + await tester.pumpWidget( + MaterialApp( + home: LandingScreen( + manager: mockManager, + ws: ws, + ), + ), + ); + await tester.pump(); + + final tx = mockManager.transcription as MockG1Transcription; + tx.clearDisplayCalls(); + + ws.aiResponse.value = 'Hello from backend'; + await tester.pump(); + + expect(tx.displayLinesCalls, isNotEmpty); + expect(tx.displayLinesCalls.last, contains('Hello from backend')); + + await disposeLanding(tester); + // LandingScreen schedules a 10s cleanup timer per sentence. + // Advance time so the timer fires and doesn't remain pending. + await tester.pump(const Duration(seconds: 11)); + }); + testWidgets('Connecting to glasses text is shown when bluetooth is scanning', (tester) async { await pumpLanding(tester); From 3099ba716649a6490f0f353788f5f7840b16ea59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:43:59 +0200 Subject: [PATCH 28/68] fix: remove hardcoded DEVELOPMENT_TEAM from project configuration --- ios/Runner.xcodeproj/project.pbxproj | 3 --- 1 file changed, 3 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8593100..665f40d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -488,7 +488,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = H5536DZLJW; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -671,7 +670,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = H5536DZLJW; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -694,7 +692,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = H5536DZLJW; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( From aa37067034538ada872754315975992b3fc94e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:46:24 +0200 Subject: [PATCH 29/68] fix: outdated comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/landing_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 89b0d84..6fcbd05 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -100,8 +100,8 @@ class _LandingScreenState extends State { /// /// 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. + /// Sentences are also automatically removed after a fixed timeout + /// (currently 10 seconds), so older lines can clear even without new ones. void _addSentenceToDisplay(String sentence) { if (_isMuted) return; if (_displayedSentences.length >= _maxDisplayedSentences) { From 2ae3c368c9dba429a62e31f114fe624512429438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:22:36 +0200 Subject: [PATCH 30/68] fix: formatting --- test/ble_mock/g1_manager_mock.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/ble_mock/g1_manager_mock.dart b/test/ble_mock/g1_manager_mock.dart index 339506d..7640d1b 100644 --- a/test/ble_mock/g1_manager_mock.dart +++ b/test/ble_mock/g1_manager_mock.dart @@ -167,7 +167,8 @@ class MockG1Transcription implements G1Transcription { } @override - Future displayLines(List lines, {bool isInterim = false}) async { + Future displayLines(List lines, + {bool isInterim = false}) async { displayLinesCalls.add(List.from(lines)); } From 3f3953902e78022fefed38ae5b868ea001f7f75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:34:48 +0200 Subject: [PATCH 31/68] fix: ios files --- ios/Podfile.lock | 4 ++-- ios/Runner.xcodeproj/project.pbxproj | 33 ---------------------------- 2 files changed, 2 insertions(+), 35 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ad3788e..d8f4363 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -13,11 +13,11 @@ PODS: - 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`) - - device_calendar (from `.symlinks/plugins/device_calendar/ios`) SPEC REPOS: trunk: @@ -36,12 +36,12 @@ EXTERNAL SOURCES: :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 - device_calendar: b55b2c5406cfba45c95a59f9059156daee1f74ed PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9875db3..665f40d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -16,8 +16,6 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 99F5015DBD6F6F67074A1B9D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9B72C4D29009EF8D1D1EEA7 /* Pods_Runner.framework */; }; - F8A02A1E8E6684C1D8450DF1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9C6A6BD5AE93A3E540561BF /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,8 +55,6 @@ 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 = ""; }; - 807E81FEBDA1B3C207D2F02D /* 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 = ""; }; - 96D8DD3C040343A039F310FE /* 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 = ""; }; 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; }; @@ -69,9 +65,6 @@ 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 = ""; }; - A66E4728E7B9DA7D3BF9212D /* 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 = ""; }; - A9B72C4D29009EF8D1D1EEA7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F06CDC837EF05FDD4CA99461 /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -171,29 +164,6 @@ path = Runner; sourceTree = ""; }; - CAAD90FF3B44AD28E7C94A7C /* Frameworks */ = { - isa = PBXGroup; - children = ( - A9B72C4D29009EF8D1D1EEA7 /* Pods_Runner.framework */, - E9C6A6BD5AE93A3E540561BF /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - F0CFB2D7AEEBFE525929A1A9 /* Pods */ = { - isa = PBXGroup; - children = ( - F06CDC837EF05FDD4CA99461 /* Pods-Runner.debug.xcconfig */, - A66E4728E7B9DA7D3BF9212D /* Pods-Runner.release.xcconfig */, - 61CA3FFAF8421699CD486D19 /* Pods-Runner.profile.xcconfig */, - 807E81FEBDA1B3C207D2F02D /* Pods-RunnerTests.debug.xcconfig */, - E805B90C1540E73EE0890817 /* Pods-RunnerTests.release.xcconfig */, - 96D8DD3C040343A039F310FE /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -518,7 +488,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -701,7 +670,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -724,7 +692,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = F2VMBPUNG6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( From 39c7dfcf945371e5167f34aabc4d8c77c3bcf015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:43:00 +0200 Subject: [PATCH 32/68] fix: update dependencies --- pubspec.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index c164041..9a7d202 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -93,10 +93,10 @@ 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: @@ -124,10 +124,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: @@ -243,10 +243,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: @@ -299,10 +299,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: @@ -339,10 +339,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: @@ -459,10 +459,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: @@ -504,10 +504,10 @@ 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: From 86ef05a7dfb1969d0352ad9d75d011e9b595d514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:57:35 +0200 Subject: [PATCH 33/68] feat: update readme to include build instructions for iphone --- README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 02ddffe..56280ee 100644 --- a/README.md +++ b/README.md @@ -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,8 +131,9 @@ When you return to coding: flutter test ``` ---- -## Build app to android +## Build app + +### Android run command @@ -142,7 +141,17 @@ run command flutter build apk --dart-define-from-file=config_staging.json ``` -then install it to usb connected android phone +### IOS + +run command + +```bash +flutter build ios --release --dart-define-from-file=config_staging.json +``` + +## Install app + +then install it to usb connected phone ```bash flutter install @@ -157,8 +166,6 @@ flutter install - `analysis_options.yaml` – lint-rules - `pubspec.yaml` – Flutter/Dart dependencies ---- - ## About Frontend for Everyday AI productivity interface for Even Realities G1 smart glasses. From d6efead23973184805830f4418aada4d1371e14d Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Fri, 13 Mar 2026 16:48:18 +0200 Subject: [PATCH 34/68] Add switch to Even app feature --- ios/Runner/Info.plist | 6 + lib/screens/landing_screen.dart | 145 +++++++++++++++--- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 80 +++++++++- pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 9 files changed, 223 insertions(+), 22 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2885430..a9948d4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -4,6 +4,12 @@ CADisableMinimumFrameDurationOnPhone + LSApplicationQueriesSchemes + + com.even.g1 + eveng1 + even-g1 + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index db042dd..1a1dc1b 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -2,6 +2,9 @@ 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 '../services/phone_audio_service.dart'; @@ -44,6 +47,93 @@ class _LandingScreenState extends State { 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: launch the app directly via package and activity + // 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 (_) { + const storeIntent = AndroidIntent( + action: 'android.intent.action.VIEW', + data: 'market://details?id=com.even.g1', + flags: [0x10000000], + ); + + try { + await storeIntent.launch(); + } catch (_) { + const webIntent = AndroidIntent( + action: 'android.intent.action.VIEW', + data: 'https://play.google.com/store/apps/details?id=com.even.g1', + flags: [0x10000000], + ); + await webIntent.launch(); + } + } + } 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(); @@ -615,24 +705,43 @@ class _LandingScreenState extends State { ), 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'), - ], - ), - ), - ), + ValueListenableBuilder( + valueListenable: _isRecording, + builder: (context, isRecording, _) { + return Center( + child: GestureDetector( + onTap: isRecording ? null : _switchToEvenApp, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 5), + decoration: BoxDecoration( + border: Border.all( + color: isRecording + ? Colors.grey + : Colors.black12, + ), + borderRadius: BorderRadius.circular(8), + ), + 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, + ), + ), + ], + ), + ), + ), + ); + }, + ) ], ), ), 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/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/pubspec.lock b/pubspec.lock index 9a7d202..dfd7e5c 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: @@ -315,10 +323,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -560,10 +568,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timezone: dependency: transitive description: @@ -580,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 b2a8782..6b14a9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,9 @@ 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: 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 From 1cc3d42b6dfd7f23623d92a15d6b9ac54cbba3b2 Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Sun, 1 Mar 2026 15:13:31 +0200 Subject: [PATCH 35/68] Implement mic source selection, stabilize transcription pipeline, and add recording start/stop confirmation on G1 display --- android/app/src/main/AndroidManifest.xml | 1 + lib/main.dart | 5 + lib/screens/landing_screen.dart | 319 +++++++++++++++--- lib/services/audio_pipeline.dart | 11 +- lib/services/phone_audio_service.dart | 51 +++ lib/services/websocket_service.dart | 31 +- lib/widgets/g1_connection.dart | 67 ++-- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + .../lib/src/bluetooth/g1_manager.dart | 11 +- .../lib/src/features/g1_transcription.dart | 57 +++- .../lib/src/voice/g1_microphone.dart | 3 +- pubspec.lock | 252 +++++++++++++- pubspec.yaml | 2 + run.sh | 12 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 18 files changed, 715 insertions(+), 122 deletions(-) create mode 100644 lib/services/phone_audio_service.dart create mode 100755 run.sh diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index af13917..5de857a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + { - // GlobalKey lets us open the drawer from multiple places,, + // GlobalKey lets us open the drawer from multiple places, // not just from a local BuildContext next to the Scaffold. final GlobalKey _scaffoldKey = GlobalKey(); @@ -45,6 +48,15 @@ class _LandingScreenState extends State { late final AudioPipeline _audioPipeline; late final RestApiService _api; + // Lazy-init: keeps FlutterSoundRecorder from being created during widget tests. + PhoneAudioService? _phoneAudio; + + bool _usePhoneMic = false; + final ValueNotifier _isRecording = ValueNotifier(false); + + final List _displayedSentences = []; + static const int _maxDisplayedSentences = 4; + @override void initState() { super.initState(); @@ -53,9 +65,9 @@ class _LandingScreenState extends State { _decoder = widget.decoder ?? Lc3Decoder(); _ws = widget.ws ?? WebsocketService(); - // Added as injectable dependency so REST API access stays testable - // and consistent with other optional dependencies in this screen. + // REST API service for the side panel drawer (injectable for tests). _api = widget.api ?? const RestApiService(); + _audioPipeline = widget.audioPipeline ?? AudioPipeline( _manager, @@ -65,43 +77,129 @@ class _LandingScreenState extends State { }, ); + // Connect to backend WebSocket _ws.connect(); - _audioPipeline.addListenerToMicrophone(); - _ws.committedText.addListener(_onWsChange); - _ws.interimText.addListener(_onWsChange); + + // React to committed/final AI text only. + _ws.aiResponse.addListener(_onAiResponse); } @override void dispose() { - _ws.committedText.removeListener(_onWsChange); - _ws.interimText.removeListener(_onWsChange); + _ws.aiResponse.removeListener(_onAiResponse); + _isRecording.dispose(); _audioPipeline.dispose(); + _phoneAudio?.dispose(); _ws.dispose(); _manager.dispose(); super.dispose(); } - void _onWsChange() { + // Create and init PhoneAudioService only when we actually need it. + Future _ensurePhoneAudioReady() async { + _phoneAudio ??= PhoneAudioService(); + await _phoneAudio!.init(); + } + + void _onAiResponse() { + final aiResponse = _ws.aiResponse.value; + + if (_manager.isConnected && _manager.transcription.isActive.value) { + _addSentenceToDisplay(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. + void _addSentenceToDisplay(String sentence) { + if (sentence.trim().isEmpty) return; + + if (_displayedSentences.length >= _maxDisplayedSentences) { + _displayedSentences.removeAt(0); + } + + _displayedSentences.add(sentence); + _manager.transcription.displayLines(List.unmodifiable(_displayedSentences)); + + Future.delayed(const Duration(seconds: 10), () { + if (!_displayedSentences.contains(sentence)) return; + _displayedSentences.remove(sentence); + if (_manager.isConnected && _manager.transcription.isActive.value) { + _manager.transcription + .displayLines(List.unmodifiable(_displayedSentences)); + } + }); + } + + void _clearDisplayQueue() { + _displayedSentences.clear(); if (_manager.isConnected && _manager.transcription.isActive.value) { - _manager.transcription.displayText( - _ws.getFullText(), - isInterim: _ws.interimText.value.isNotEmpty, - ); + _manager.transcription.displayLines(const []); } } Future _startTranscription() async { - await _ws.startAudioStream(); - await _manager.transcription.start(); + 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.'); + } else { + _ws.clearCommittedText(); + _clearDisplayQueue(); + await _ws.startAudioStream(); + + await _ensurePhoneAudioReady(); + await _phoneAudio!.start((pcm) { + if (_ws.connected.value) _ws.sendAudio(pcm); + }); + } + + _isRecording.value = true; } Future _stopTranscription() async { - await _audioPipeline.stop(); - await _ws.stopAudioStream(); - await _manager.transcription.stop(); + _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(); + } else { + await _manager.microphone.disable(); + await _audioPipeline.stop(); + } + + await Future.delayed(const Duration(milliseconds: 200)); + await _ws.stopAudioStream(); + await _manager.transcription.stop(); + } else { + await _phoneAudio?.stop(); + await _ws.stopAudioStream(); + } } -// Added helper so both menu button and landing tile can open the side panel. void _openDrawer() => _scaffoldKey.currentState?.openDrawer(); @override @@ -110,7 +208,6 @@ class _LandingScreenState extends State { key: _scaffoldKey, resizeToAvoidBottomInset: false, backgroundColor: Colors.white, - // Main integration point for the new REST side panel feature. drawer: SidePanel(api: _api), body: SafeArea( child: Padding( @@ -124,9 +221,6 @@ class _LandingScreenState extends State { icon: const Icon(Icons.menu, color: Color(0xFF00239D)), tooltip: 'Open history panel', ), - - // Top row layout changed to avoid right overflow on narrow phones. - // Old version used Spacer() + a long button, which overflowed. Image.asset( 'assets/images/Elisa_logo_blue_RGB.png', height: 40, @@ -159,8 +253,6 @@ class _LandingScreenState extends State { ], ) : FittedBox( - // FittedBox added so the reconnect button can scale down - // instead of overflowing on smaller screens. fit: BoxFit.scaleDown, child: OutlinedButton.icon( onPressed: () => _ws.connect(), @@ -209,21 +301,9 @@ class _LandingScreenState extends State { ), ), const SizedBox(height: 34), - GlassesConnection( - manager: _manager, - onRecordToggle: () async { - if (!_manager.transcription.isActive.value) { - await _startTranscription(); - } else { - await _stopTranscription(); - } - }, - ), - const SizedBox(height: 14), Row( children: [ Expanded( - // Changed so "Key points" now opens the REST side panel. child: LandingTile( icon: Icons.list_alt, label: 'Key points', @@ -232,7 +312,133 @@ class _LandingScreenState extends State { ), const SizedBox(width: 14), Expanded( - // Kept disabled because no recordings feature was added here. (Changeable when available) + 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: 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(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, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: _isRecording, + builder: (context, isRecording, _) { + return InkWell( + onTap: () async { + if (!isRecording) { + await _startTranscription(); + } else { + await _stopTranscription(); + } + }, + child: Container( + height: 72, + padding: const EdgeInsets.symmetric( + horizontal: 14), + decoration: BoxDecoration( + color: isRecording + ? Colors.red + .withAlpha((0.15 * 255).round()) + : Colors.transparent, + border: Border.all( + color: isRecording + ? Colors.red + : 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], + ), + 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], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(width: 14), + Expanded( child: LandingTile( icon: Icons.play_circle_outline, label: 'Recordings', @@ -243,12 +449,30 @@ class _LandingScreenState extends State { ], ), 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, - ), + horizontal: 14, vertical: 5), decoration: BoxDecoration( border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(8), @@ -274,9 +498,7 @@ class _LandingScreenState extends State { TextButton( onPressed: () => Navigator.push( context, - MaterialPageRoute( - builder: (_) => const LoginScreen(), - ), + MaterialPageRoute(builder: (_) => const LoginScreen()), ), child: const Text( 'Sign in', @@ -291,8 +513,7 @@ class _LandingScreenState extends State { onPressed: () => Navigator.push( context, MaterialPageRoute( - builder: (_) => const RegisterScreen(), - ), + builder: (_) => const RegisterScreen()), ), child: const Text( 'Register', @@ -329,8 +550,6 @@ class LandingTile extends StatelessWidget { @override Widget build(BuildContext context) { - // Styling adjusted so disabled tiles look visibly disabled - // instead of feeling like broken clickable buttons. return Opacity( opacity: enabled ? 1.0 : 0.5, child: InkWell( diff --git a/lib/services/audio_pipeline.dart b/lib/services/audio_pipeline.dart index 23c3c8e..06d0dd9 100644 --- a/lib/services/audio_pipeline.dart +++ b/lib/services/audio_pipeline.dart @@ -41,6 +41,11 @@ class AudioPipeline { } // Add to buffer _audioCollector.addChunk(data.seq, data.data); + if (_audioCollector.chunkCount >= 5) { + _getPcmDataAndClearBuffer().then((pcm) { + if (pcm != null) onPcmData(pcm); + }); + } }, onError: (error) { //todo @@ -55,10 +60,10 @@ class AudioPipeline { return await _decoder.decodeLc3(lc3Data); } - /// Start a periodic timer that flushes the audio buffer every 500 ms. + /// Start a periodic timer that flushes the audio buffer every 500 ms. // Changed 100ms void _startSendTimer() { _sendTimer?.cancel(); - _sendTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { + _sendTimer = Timer.periodic(const Duration(milliseconds: 100), (_) async { Uint8List? pcmData = await _getPcmDataAndClearBuffer(); if (pcmData != null) onPcmData(pcmData); }); @@ -67,6 +72,8 @@ class AudioPipeline { /// Stop recording: cancel the flush timer, send any remaining /// buffered audio, and mark recording as inactive. Future stop() async { + await _audioSubscription?.cancel(); + _audioSubscription = null; _sendTimer?.cancel(); _sendTimer = null; Uint8List? pcm = await _getPcmDataAndClearBuffer(); diff --git a/lib/services/phone_audio_service.dart b/lib/services/phone_audio_service.dart new file mode 100644 index 0000000..42c3af7 --- /dev/null +++ b/lib/services/phone_audio_service.dart @@ -0,0 +1,51 @@ +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(); + + bool _initialized = false; + + Future init() async { + await Permission.microphone.request(); + await _recorder.openRecorder(); + + _controller.stream.listen((buffer) { + _onPcm?.call(buffer); + }); + + _initialized = true; + } + + Function(Uint8List)? _onPcm; + + Future start(Function(Uint8List pcm) onPcm) async { + if (!_initialized) { + await init(); + } + + _onPcm = onPcm; + + await _recorder.startRecorder( + codec: Codec.pcm16, + sampleRate: 16000, + numChannels: 1, + toStream: _controller.sink, + ); + } + + Future stop() async { + if (_recorder.isRecording) { + await _recorder.stopRecorder(); + } + } + + Future dispose() async { + await _controller.close(); + await _recorder.closeRecorder(); + } +} \ No newline at end of file diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index a80017e..e45f7c9 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -54,24 +54,26 @@ class WebsocketService { final data = jsonDecode(msg as String); final type = data['type']; - 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 (type == 'transcript') { - // Speech-to-text results: partial (interim) or final (committed) + if (type == 'transcript') { + debugPrint("WS RECEIVED TRANSCRIPT: ${data['data']}"); final status = data['data']['status']; if (status == 'partial') { interimText.value = (data['data']['text'] ?? '').toString().trim(); + debugPrint("→ Interim updated: ${interimText.value}"); } else if (status == 'final') { committedText.value = (data['data']['text'] ?? '').toString().trim(); + 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 (type == 'error') { //todo @@ -86,9 +88,12 @@ class WebsocketService { } Future disconnect() async { + final channel = _audioChannel; + _audioChannel = null; // Asetetaan heti nulliksi + connected.value = false; try { - _audioChannel?.sink.add(jsonEncode({'type': 'control', 'cmd': 'stop'})); - await _audioChannel?.sink.close(); + channel?.sink.add(jsonEncode({'type': 'control', 'cmd': 'stop'})); + await channel?.sink.close().timeout(const Duration(milliseconds: 500)); } catch (_) { // Connection already closed or network gone } finally { diff --git a/lib/widgets/g1_connection.dart b/lib/widgets/g1_connection.dart index ccc3a1d..2f5a4c5 100644 --- a/lib/widgets/g1_connection.dart +++ b/lib/widgets/g1_connection.dart @@ -35,6 +35,11 @@ class _GlassesConnectionState extends State { // Rebuild whenever the glasses connection state changes return StreamBuilder( stream: widget.manager.connectionState, + initialData: G1ConnectionEvent( + state: widget.manager.isConnected + ? G1ConnectionState.connected + : G1ConnectionState.disconnected, + ), builder: (context, snapshot) { // No event yet — show the initial connect button if (snapshot.connectionState == ConnectionState.waiting) { @@ -46,39 +51,17 @@ class _GlassesConnectionState extends State { }, ); } - if (snapshot.hasData) { switch (snapshot.data!.state) { // Connected case G1ConnectionState.connected: - return ValueListenableBuilder( - valueListenable: widget.manager.transcription.isActive, - builder: (context, isRecording, _) { - return Row( - children: [ - Expanded( - child: LandingTileButton( - icon: Icons.bluetooth_connected, - label: 'Disconnect', - onTap: () async { - await widget.manager.disconnect(); - }, - ), - ), - const SizedBox(width: 14), - Expanded( - child: LandingTileButton( - icon: isRecording - ? Icons.stop_circle_outlined - : Icons.mic, - label: isRecording ? 'Stop' : 'Record', - onTap: () async { - await widget.onRecordToggle?.call(); - }, - ), - ), - ], - ); + return LandingTileButton( + icon: Icons.bluetooth_connected, + label: 'Connected', + activeColor: Colors.lightGreen, + onTap: () async { + await widget.manager.transcription.stop(); + await widget.manager.disconnect(); }, ); @@ -148,12 +131,14 @@ class _GlassesConnectionState extends State { class LandingTileButton extends StatelessWidget { final IconData icon; final String label; + final Color? activeColor; final Future Function()? onTap; const LandingTileButton({ super.key, required this.icon, required this.label, + this.activeColor, required this.onTap, }); @@ -163,18 +148,34 @@ class LandingTileButton extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(8), child: Container( - height: 64, + height: 72, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( - border: Border.all(color: Colors.black12), + color: activeColor != null + ? activeColor!.withValues(alpha: 0.15) + : Colors.transparent, + border: Border.all( + color: activeColor ?? Colors.black12, + ), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - Icon(icon, size: 22), + Icon( + icon, + size: 22, + color: activeColor ?? Colors.grey[700], + ), const SizedBox(width: 10), Expanded( - child: Text(label, style: const TextStyle(fontSize: 14)), + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: activeColor ?? Colors.grey[800], + ), + ), ), ], ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..938839d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..86609f6 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_sound ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 75f0199..8e1adb5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import flutter_blue_plus_darwin +import flutter_sound func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) + FlutterSoundPlugin.register(with: registry.registrar(forPlugin: "FlutterSoundPlugin")) } 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 65dae37..4e245ce 100644 --- a/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart +++ b/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart @@ -358,7 +358,7 @@ class G1Manager { if (deviceName.contains(BluetoothConstants.leftGlassPattern) && _leftGlass == null) { - debugPrint('Found left glass: $deviceName'); + // debugPrint('Found left glass: $deviceName'); // Kommentoitu pois spämmin takia glass = G1Glass( name: deviceName, device: result.device, @@ -369,7 +369,7 @@ class G1Manager { onUpdate?.call('Left glass found: $deviceName'); } else if (deviceName.contains(BluetoothConstants.rightGlassPattern) && _rightGlass == null) { - debugPrint('Found right glass: $deviceName'); + // debugPrint('Found right glass: $deviceName'); // Kommentoitu pois spämmin takia glass = G1Glass( name: deviceName, device: result.device, @@ -434,10 +434,11 @@ class G1Manager { void _setupReconnect(G1Glass glass) { glass.connectionState.listen((state) { - debugPrint('[${glass.side} Glass] Connection state: $state'); + // debugPrint('[${glass.side} Glass] Connection state: $state'); if (state == BluetoothConnectionState.disconnected) { - debugPrint('[${glass.side} Glass] Attempting reconnect...'); - glass.connect(); + // debugPrint('[${glass.side} Glass] Attempting reconnect...'); + // glass.connect(); // Kommentoitu pois sen takia että se tämä ei anna katkaista napista + // yhteyttä ja aiheuttaa sen takia ikuisen loopin } }); } diff --git a/packages/even_realities_g1/lib/src/features/g1_transcription.dart b/packages/even_realities_g1/lib/src/features/g1_transcription.dart index fa20840..f7ccb2c 100644 --- a/packages/even_realities_g1/lib/src/features/g1_transcription.dart +++ b/packages/even_realities_g1/lib/src/features/g1_transcription.dart @@ -35,6 +35,9 @@ class G1Transcription { Timer? _keepAliveTimer; + /// The lines currently shown on the display, reused by the keep-alive. + List _lastLines = []; + G1Transcription(this._manager); /// Whether transcription is currently paused. @@ -168,6 +171,7 @@ class G1Transcription { isActive.value = false; _keepAliveTimer?.cancel(); _keepAliveTimer = null; + _lastLines = []; // Close mic if not already paused if (!_isPaused) { @@ -208,25 +212,55 @@ class G1Transcription { chunk = chunk.substring(0, chunk.length - 1); } - await _sendDisplay(chunk, isInterim: isInterim); + await _sendDisplay(chunk, lineNumber: 1, totalLines: 1, isInterim: isInterim); + _startKeepAlive(); + } + + /// Send multiple lines to the glasses display, one packet per line. + /// + /// Each line is sent as a separate BLE packet with the correct + /// [lineNumber] and [totalLines] values so the glasses stack them. + /// Pass an empty list to blank the display. + Future displayLines(List lines, {bool isInterim = false}) async { + if (!isActive.value || _isPaused) return; + + if (lines.isEmpty) { + await _sendDisplay('', lineNumber: 1, totalLines: 1, isInterim: isInterim); + _startKeepAlive(); + return; + } + + _lastLines = List.from(lines); + final total = lines.length; + for (int i = 0; i < lines.length; i++) { + String chunk = lines[i]; + while (utf8.encode(chunk).length > _maxTextBytes) { + chunk = chunk.substring(0, chunk.length - 1); + } + await _sendDisplay(chunk, lineNumber: i + 1, totalLines: total, isInterim: isInterim); + } _startKeepAlive(); } /// Send a single text chunk to the glasses display. - Future _sendDisplay(String text, {bool isInterim = false}) async { + Future _sendDisplay( + String text, { + int lineNumber = 1, + int totalLines = 1, + bool isInterim = false, + }) async { final seq = _nextSeq(); final textBytes = utf8.encode(text); final body = textBytes.isEmpty ? [0x0a, 0x0a] : [...textBytes, 0x0a]; - final packet = [ G1TranscriptionCommands.transcribeDisplay, 0x00, // placeholder for total length 0x00, seq, 0x02, // sub-command: text display - 0x01, // totalLines + totalLines, 0x00, - 0x01, // line number + lineNumber, 0x00, isInterim ? 0x01 : 0x00, 0x00, @@ -238,12 +272,19 @@ class G1Transcription { await _manager.sendCommand(packet, needsAck: false); } - /// Keep-alive: resends empty display to keep transcription session alive. + /// Keep-alive: resends the current display content every 8 s to keep the + /// transcription session alive without clearing the screen. void _startKeepAlive() { _keepAliveTimer?.cancel(); _keepAliveTimer = Timer.periodic(const Duration(seconds: 8), (_) async { - if (isActive.value && !_isPaused) { - await _sendDisplay('', isInterim: false); + if (!isActive.value || _isPaused) return; + if (_lastLines.isEmpty) { + await _sendDisplay('', lineNumber: 1, totalLines: 1, isInterim: false); + } else { + final total = _lastLines.length; + for (int i = 0; i < _lastLines.length; i++) { + await _sendDisplay(_lastLines[i], lineNumber: i + 1, totalLines: total, isInterim: false); + } } }); } diff --git a/packages/even_realities_g1/lib/src/voice/g1_microphone.dart b/packages/even_realities_g1/lib/src/voice/g1_microphone.dart index aa94a79..103dafc 100644 --- a/packages/even_realities_g1/lib/src/voice/g1_microphone.dart +++ b/packages/even_realities_g1/lib/src/voice/g1_microphone.dart @@ -193,10 +193,11 @@ class G1Microphone { } } + // DEBUG PRINTTI KOMMENTOITU POIS KOSKA LIIKAA SPÄMMIÄ void _handleVoiceData(GlassSide side, int seq, List audioData) { // Accept audio if either _isActive OR if we're in an AI recording session if (!_isActive && !_aiSessionCollector.isRecording) { - debugPrint('[G1Microphone] Dropping audio packet - mic not active'); + // debugPrint('[G1Microphone] Dropping audio packet - mic not active'); return; } diff --git a/pubspec.lock b/pubspec.lock index 58e2349..eb00649 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,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: @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -112,6 +120,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -173,6 +189,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_sound: + dependency: "direct main" + description: + name: flutter_sound + sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85" + url: "https://pub.dev" + source: hosted + version: "9.30.0" + flutter_sound_platform_interface: + dependency: transitive + description: + name: flutter_sound_platform_interface + sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861" + url: "https://pub.dev" + source: hosted + version: "9.30.0" + flutter_sound_web: + dependency: transitive + description: + name: flutter_sound_web + sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb" + url: "https://pub.dev" + source: hosted + version: "9.30.0" flutter_test: dependency: "direct dev" description: flutter @@ -183,6 +223,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" http: dependency: "direct main" description: @@ -231,22 +287,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" 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: @@ -255,6 +327,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -263,6 +351,102 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -271,6 +455,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" rxdart: dependency: transitive description: @@ -316,6 +524,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -328,10 +544,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" typed_data: dependency: transitive description: @@ -380,6 +596,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -388,6 +612,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.19.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 930cc3a..87ed094 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: flutter_blue_plus: ^1.36.8 http: ^1.2.0 web_socket_channel: ^3.0.3 + flutter_sound: ^9.2.13 + permission_handler: ^11.0.1 dev_dependencies: flutter_test: diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..426edf9 --- /dev/null +++ b/run.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Valitsee konfiguraation argumentin perusteella +CONFIG="config_dev.json" +if [ "$1" == "staging" ]; then + CONFIG="config_staging.json" +fi + +echo "Käytetään konfiguraatiota: $CONFIG" + +# Suodattaa turhat lokit +flutter run --dart-define-from-file=$CONFIG | grep -vE "GSC|VRI|BLAST|SurfaceView|InputMethod|FlutterJNI|IDS_TAG" \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..e4eb53a 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSoundPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSoundPluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..183a45b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_sound + permission_handler_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 564a787a5edd5d7a380c50c8db1307c79d9ad582 Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Sun, 1 Mar 2026 16:08:48 +0200 Subject: [PATCH 36/68] Apply dart format --- lib/main.dart | 3 ++- lib/services/phone_audio_service.dart | 7 +++---- .../lib/src/bluetooth/g1_manager.dart | 6 +++--- .../lib/src/features/g1_transcription.dart | 15 ++++++++++----- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2fdd1f7..0596182 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,8 @@ 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 + fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, + color: false); // lokitus bännäyksen poisto terminalista runApp(const MyApp()); } diff --git a/lib/services/phone_audio_service.dart b/lib/services/phone_audio_service.dart index 42c3af7..675eda1 100644 --- a/lib/services/phone_audio_service.dart +++ b/lib/services/phone_audio_service.dart @@ -5,8 +5,7 @@ import 'dart:async'; class PhoneAudioService { final FlutterSoundRecorder _recorder = FlutterSoundRecorder(); - final StreamController _controller = - StreamController(); + final StreamController _controller = StreamController(); bool _initialized = false; @@ -40,7 +39,7 @@ class PhoneAudioService { Future stop() async { if (_recorder.isRecording) { - await _recorder.stopRecorder(); + await _recorder.stopRecorder(); } } @@ -48,4 +47,4 @@ class PhoneAudioService { await _controller.close(); await _recorder.closeRecorder(); } -} \ No newline at end of file +} 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 4e245ce..bdfa491 100644 --- a/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart +++ b/packages/even_realities_g1/lib/src/bluetooth/g1_manager.dart @@ -436,9 +436,9 @@ class G1Manager { glass.connectionState.listen((state) { // debugPrint('[${glass.side} Glass] Connection state: $state'); if (state == BluetoothConnectionState.disconnected) { - // debugPrint('[${glass.side} Glass] Attempting reconnect...'); - // glass.connect(); // Kommentoitu pois sen takia että se tämä ei anna katkaista napista - // yhteyttä ja aiheuttaa sen takia ikuisen loopin + // debugPrint('[${glass.side} Glass] Attempting reconnect...'); + // glass.connect(); // Kommentoitu pois sen takia että se tämä ei anna katkaista napista + // yhteyttä ja aiheuttaa sen takia ikuisen loopin } }); } diff --git a/packages/even_realities_g1/lib/src/features/g1_transcription.dart b/packages/even_realities_g1/lib/src/features/g1_transcription.dart index f7ccb2c..afb9a6a 100644 --- a/packages/even_realities_g1/lib/src/features/g1_transcription.dart +++ b/packages/even_realities_g1/lib/src/features/g1_transcription.dart @@ -212,7 +212,8 @@ class G1Transcription { chunk = chunk.substring(0, chunk.length - 1); } - await _sendDisplay(chunk, lineNumber: 1, totalLines: 1, isInterim: isInterim); + await _sendDisplay(chunk, + lineNumber: 1, totalLines: 1, isInterim: isInterim); _startKeepAlive(); } @@ -221,11 +222,13 @@ class G1Transcription { /// Each line is sent as a separate BLE packet with the correct /// [lineNumber] and [totalLines] values so the glasses stack them. /// Pass an empty list to blank the display. - Future displayLines(List lines, {bool isInterim = false}) async { + Future displayLines(List lines, + {bool isInterim = false}) async { if (!isActive.value || _isPaused) return; if (lines.isEmpty) { - await _sendDisplay('', lineNumber: 1, totalLines: 1, isInterim: isInterim); + await _sendDisplay('', + lineNumber: 1, totalLines: 1, isInterim: isInterim); _startKeepAlive(); return; } @@ -237,7 +240,8 @@ class G1Transcription { while (utf8.encode(chunk).length > _maxTextBytes) { chunk = chunk.substring(0, chunk.length - 1); } - await _sendDisplay(chunk, lineNumber: i + 1, totalLines: total, isInterim: isInterim); + await _sendDisplay(chunk, + lineNumber: i + 1, totalLines: total, isInterim: isInterim); } _startKeepAlive(); } @@ -283,7 +287,8 @@ class G1Transcription { } else { final total = _lastLines.length; for (int i = 0; i < _lastLines.length; i++) { - await _sendDisplay(_lastLines[i], lineNumber: i + 1, totalLines: total, isInterim: false); + await _sendDisplay(_lastLines[i], + lineNumber: i + 1, totalLines: total, isInterim: false); } } }); From 24a522e7257c74ca45c3c72e50bccd9f8d7acc7e Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Sun, 1 Mar 2026 16:34:46 +0200 Subject: [PATCH 37/68] Fix icon color regression in glasses connection button --- lib/widgets/glasses_connection.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/glasses_connection.dart b/lib/widgets/glasses_connection.dart index c2f3fba..7586671 100644 --- a/lib/widgets/glasses_connection.dart +++ b/lib/widgets/glasses_connection.dart @@ -65,7 +65,8 @@ class _GlassesConnectionState extends State { builder: (context, isRecording, _) => ElevatedButton( onPressed: () => widget.onRecordToggle?.call(), style: ElevatedButton.styleFrom( - iconColor: isRecording ? Colors.red : Colors.green, + iconColor: + isRecording ? Colors.red : Colors.lightGreen, ), child: Row( mainAxisSize: MainAxisSize.min, From ff87552ea9dc93809de9e618e04945fb1f4a1a5b Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Sun, 1 Mar 2026 16:38:56 +0200 Subject: [PATCH 38/68] Fix remaining withValues usages and remove unused import --- lib/widgets/g1_connection.dart | 2 +- packages/even_realities_g1/lib/src/voice/g1_microphone.dart | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/widgets/g1_connection.dart b/lib/widgets/g1_connection.dart index 2f5a4c5..efe770e 100644 --- a/lib/widgets/g1_connection.dart +++ b/lib/widgets/g1_connection.dart @@ -152,7 +152,7 @@ class LandingTileButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: activeColor != null - ? activeColor!.withValues(alpha: 0.15) + ? activeColor!.withAlpha((0.15 * 255).round()) : Colors.transparent, border: Border.all( color: activeColor ?? Colors.black12, diff --git a/packages/even_realities_g1/lib/src/voice/g1_microphone.dart b/packages/even_realities_g1/lib/src/voice/g1_microphone.dart index 103dafc..391c078 100644 --- a/packages/even_realities_g1/lib/src/voice/g1_microphone.dart +++ b/packages/even_realities_g1/lib/src/voice/g1_microphone.dart @@ -1,7 +1,4 @@ import 'dart:async'; - -import 'package:flutter/foundation.dart'; - import '../bluetooth/g1_connection_state.dart'; import '../bluetooth/g1_manager.dart'; import '../protocol/commands.dart'; From e2e0bfc3180f6c59476fa12f675e7ac0d8207a0b Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Sun, 1 Mar 2026 17:50:07 +0200 Subject: [PATCH 39/68] Fix widget tests and align LandingScreen test expectations --- lib/screens/home_screen.dart | 1 - lib/widgets/glasses_connection.dart | 3 +- test/ble_mock/g1_manager_mock.dart | 6 ++ test/g1_connection_widget_test.dart | 33 +------ test/widget_test.dart | 135 ++++++++++++++-------------- 5 files changed, 77 insertions(+), 101 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index f3b7839..964d18f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,7 +4,6 @@ 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 '../widgets/glasses_connection.dart'; import '../services/websocket_service.dart'; /// Main screen of the app. Manages BLE glasses connection, diff --git a/lib/widgets/glasses_connection.dart b/lib/widgets/glasses_connection.dart index 7586671..c2f3fba 100644 --- a/lib/widgets/glasses_connection.dart +++ b/lib/widgets/glasses_connection.dart @@ -65,8 +65,7 @@ class _GlassesConnectionState extends State { builder: (context, isRecording, _) => ElevatedButton( onPressed: () => widget.onRecordToggle?.call(), style: ElevatedButton.styleFrom( - iconColor: - isRecording ? Colors.red : Colors.lightGreen, + iconColor: isRecording ? Colors.red : Colors.green, ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/test/ble_mock/g1_manager_mock.dart b/test/ble_mock/g1_manager_mock.dart index 7c66cb7..afa2579 100644 --- a/test/ble_mock/g1_manager_mock.dart +++ b/test/ble_mock/g1_manager_mock.dart @@ -129,6 +129,12 @@ class MockG1Microphone implements G1Microphone { Stream get audioPacketStream => _audioPacketStreamController.stream; + @override + Future enable() async {} + + @override + Future disable() async {} + @override void dispose() { _audioPacketStreamController.close(); diff --git a/test/g1_connection_widget_test.dart b/test/g1_connection_widget_test.dart index d870fd2..2c65ca8 100644 --- a/test/g1_connection_widget_test.dart +++ b/test/g1_connection_widget_test.dart @@ -86,7 +86,7 @@ void main() { isTrue); }); - testWidgets('When connected shows Disconnect and Record', (tester) async { + testWidgets('When connected shows Connected state', (tester) async { await pumpConnection(tester); mockManager.emitState( @@ -94,35 +94,6 @@ void main() { ); await tester.pump(); - expect(find.text('Disconnect'), findsOneWidget); - expect( - find.text('Record').evaluate().isNotEmpty || - find.text('Stop').evaluate().isNotEmpty, - isTrue); - }); - - testWidgets('Tapping Record calls onRecordToggle', (tester) async { - var called = 0; - await pumpConnection( - tester, - onRecordToggle: () async { - called++; - }, - ); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.connected), - ); - await tester.pump(); - - // Tap the tile that contains "Record" or "Stop" - if (find.text('Record').evaluate().isNotEmpty) { - await tester.tap(find.text('Record')); - } else { - await tester.tap(find.text('Stop')); - } - await tester.pump(); - - expect(called, 1); + expect(find.text('Connected'), findsOneWidget); }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 8ab7e6c..5003a35 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -2,8 +2,7 @@ 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/home_screen.dart'; +import 'package:front/screens/landing_screen.dart'; void main() { late MockG1Manager mockManager; @@ -15,122 +14,123 @@ void main() { tearDown(() { mockManager.dispose(); }); - testWidgets('App shows text input and send button', - (WidgetTester tester) async { - // Build and render the app - await tester.pumpWidget(MaterialApp( - home: HomePage( - manager: mockManager, + + Future pumpLanding(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: LandingScreen( + manager: mockManager, + ), ), - )); + ); + } - // Verify that a text input field exists - expect(find.byType(TextField), findsOneWidget); + Future disposeLanding(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + } - // Verify that the Send button exists - expect(find.byIcon(Icons.send), findsOneWidget); + testWidgets('App shows text input and send button', + (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); - // Verify that the app title is shown - expect(find.text('Smarties App'), findsOneWidget); + await disposeLanding(tester); }); - testWidgets('Connecting to glasses text is shown when bluetooth is scanning ', + testWidgets('Connecting to glasses text is shown when bluetooth is scanning', (tester) async { - await tester.pumpWidget(MaterialApp( - home: HomePage( - manager: mockManager, - ), - )); + await pumpLanding(tester); mockManager.emitState( const G1ConnectionEvent(state: G1ConnectionState.connecting)); await tester.pump(); + expect(find.text('Connecting to glasses'), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await disposeLanding(tester); }); + testWidgets('Disconnect from glasses button is shown', (tester) async { - await tester.pumpWidget(MaterialApp( - home: HomePage( - manager: mockManager, - ), - )); + await pumpLanding(tester); mockManager.emitState( const G1ConnectionEvent(state: G1ConnectionState.disconnected)); + await tester.pump(); - final connectionButton = - find.widgetWithText(ElevatedButton, 'Connect to glasses'); - // Initially shows connect button (not connected) - expect(connectionButton, findsOneWidget); + + expect(find.text('Connect to glasses'), findsOneWidget); + + await disposeLanding(tester); }); + testWidgets('On connecting error right error message is shown', (tester) async { - await tester.pumpWidget(MaterialApp( - home: HomePage( - manager: mockManager, - ), - )); + await pumpLanding(tester); mockManager .emitState(const G1ConnectionEvent(state: G1ConnectionState.error)); + await tester.pump(); - final connectionButton = - find.widgetWithText(ElevatedButton, 'Connect to glasses'); + expect(find.text('Error in connecting to glasses'), findsOneWidget); + expect(find.text('Connect to glasses'), findsOneWidget); - expect(connectionButton, findsOneWidget); + await disposeLanding(tester); }); + testWidgets('On scanning Scanning for glasses message is shown', (tester) async { - await tester.pumpWidget(MaterialApp( - home: HomePage( - manager: mockManager, - ), - )); + await pumpLanding(tester); mockManager .emitState(const G1ConnectionEvent(state: G1ConnectionState.scanning)); + await tester.pump(); + expect(find.text('Searching for glasses'), findsOneWidget); + + await disposeLanding(tester); }); + testWidgets('When connected show right text', (tester) async { - await tester.pumpWidget(MaterialApp( - home: HomePage( - manager: mockManager, - ), - )); + await pumpLanding(tester); mockManager .emitState(const G1ConnectionEvent(state: G1ConnectionState.connected)); + await tester.pump(); - final disconnectButton = find.widgetWithText(ElevatedButton, 'Disconnect'); - expect(disconnectButton, findsOneWidget); - expect(find.text('Record'), findsOneWidget); + + expect(find.text('Connected'), findsOneWidget); + + await disposeLanding(tester); }); + testWidgets('Shows scanning state when connecting', (tester) async { - await tester.pumpWidget(MaterialApp( - home: HomePage( - manager: mockManager, - ), - )); - final connectButton = - find.widgetWithText(ElevatedButton, 'Connect to glasses'); - await tester.tap(connectButton); + await pumpLanding(tester); + mockManager + .emitState(const G1ConnectionEvent(state: G1ConnectionState.scanning)); await tester.pump(); - -// Search state expect(find.text('Searching for glasses'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - await tester.pump(const Duration(milliseconds: 500)); - // Connecting state + mockManager.emitState( + const G1ConnectionEvent(state: G1ConnectionState.connecting)); + await tester.pump(); expect(find.text('Connecting to glasses'), findsOneWidget); - await tester.pump(const Duration(milliseconds: 500)); - // Connected state - expect(find.widgetWithText(ElevatedButton, 'Disconnect'), findsOneWidget); + mockManager + .emitState(const G1ConnectionEvent(state: G1ConnectionState.connected)); + await tester.pump(); + expect(find.text('Connected'), findsOneWidget); + + await disposeLanding(tester); }); test('Can send text to glasses when connected', () async { @@ -141,6 +141,7 @@ void main() { final mockDisplay = mockManager.display as MockG1Display; expect(mockDisplay.getText, contains('test')); }); + test('Cannot send text to glasses when not connected', () async { mockManager.setConnected(false); From 8e3ceda4a1e4b6e8c1e74f1100daf57a5e4fe37d Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Sun, 1 Mar 2026 18:24:06 +0200 Subject: [PATCH 40/68] FIX: Stabilize logging, refine run.sh output filtering, adjust tests to use g1_connection, fix analyzer issues --- lib/screens/home_screen.dart | 1 + run.sh | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 964d18f..92d2200 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ 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 '../widgets/g1_connection.dart'; import '../services/websocket_service.dart'; /// Main screen of the app. Manages BLE glasses connection, diff --git a/run.sh b/run.sh index 426edf9..b5142c0 100755 --- a/run.sh +++ b/run.sh @@ -1,12 +1,9 @@ #!/bin/bash -# Valitsee konfiguraation argumentin perusteella CONFIG="config_dev.json" -if [ "$1" == "staging" ]; then - CONFIG="config_staging.json" -fi +[ "$1" == "staging" ] && CONFIG="config_staging.json" echo "Käytetään konfiguraatiota: $CONFIG" -# Suodattaa turhat lokit -flutter run --dart-define-from-file=$CONFIG | grep -vE "GSC|VRI|BLAST|SurfaceView|InputMethod|FlutterJNI|IDS_TAG" \ No newline at end of file +flutter run --dart-define-from-file=$CONFIG 2>&1 \ +| grep -E "E/flutter|Unhandled Exception" From eba7cff6763874ea8d87e593aa4d6a7cf4feec14 Mon Sep 17 00:00:00 2001 From: Sami Horttanainen Date: Tue, 3 Mar 2026 12:28:39 +0200 Subject: [PATCH 41/68] pipeline works without glasses --- lib/screens/landing_screen.dart | 79 +++++++++++++++++++++++++++++ lib/services/websocket_service.dart | 7 +++ pubspec.lock | 16 +++--- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 70d9ce8..77a25e7 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -109,6 +109,27 @@ class _LandingScreenState extends State { } } + void _onAiResponse() { + final aiResponse = _ws.aiResponse.value; + + // Empty = session reset (disconnect / new start) → reset pointer + if (aiResponse.isEmpty) { + _lastCommittedLength = 0; + return; + } + // Extract only the newly committed sentence + debugPrint(aiResponse); + final newSentence = aiResponse.substring(_lastCommittedLength).trim(); + _lastCommittedLength = aiResponse.length; + + if (newSentence.isEmpty) return; + + debugPrint("→ Adding to display: '$newSentence'"); + if (_manager.isConnected && _manager.transcription.isActive.value) { + _addSentenceToDisplay(newSentence); + } + } + /// Adds a sentence to the on-screen queue. /// /// Each sentence is a separate BLE packet (lineNumber 1..N). @@ -142,6 +163,7 @@ class _LandingScreenState extends State { Future _startTranscription() async { if (_manager.isConnected) { +<<<<<<< HEAD await _manager.transcription.stop(); await Future.delayed(const Duration(milliseconds: 300)); @@ -155,6 +177,23 @@ class _LandingScreenState extends State { await _ensurePhoneAudioReady(); await _phoneAudio!.start((pcm) { if (_ws.connected.value) _ws.sendAudio(pcm); +======= + //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 + _lastCommittedLength = 0; + _clearDisplayQueue(); + + await _ws.startAudioStream(); + await _manager.transcription.start(); + + if (_usePhoneMic) { + await _phoneAudio.start((pcm) { + if (_ws.connected.value) { + _ws.sendAudio(pcm); + } +>>>>>>> c26cf7e (pipeline works without glasses) }); } else { await _manager.microphone.enable(); @@ -162,6 +201,7 @@ class _LandingScreenState extends State { } await _manager.transcription.displayText('Recording started.'); +<<<<<<< HEAD } else { _ws.clearCommittedText(); _clearDisplayQueue(); @@ -173,29 +213,60 @@ class _LandingScreenState extends State { }); } +======= + debugPrint("Transcription (re)started"); + } else { + //wo glasses + _ws.clearCommittedText(); // reset accumulated text — backend starts fresh too + _lastCommittedLength = 0; + _clearDisplayQueue(); + await _ws.startAudioStream(); + await _phoneAudio.start( + (pcm) { + if (_ws.connected.value) _ws.sendAudio(pcm); + }, + ); + } +>>>>>>> c26cf7e (pipeline works without glasses) _isRecording.value = true; } Future _stopTranscription() async { _isRecording.value = false; +<<<<<<< HEAD +======= +>>>>>>> c26cf7e (pipeline works without glasses) if (_manager.isConnected) { _clearDisplayQueue(); await _manager.transcription.displayText('Recording stopped.'); await Future.delayed(const Duration(seconds: 2)); +<<<<<<< HEAD if (_usePhoneMic) { await _phoneAudio?.stop(); +======= + if (_usePhoneMic) { + await _phoneAudio.stop(); +>>>>>>> c26cf7e (pipeline works without glasses) } else { await _manager.microphone.disable(); await _audioPipeline.stop(); } +<<<<<<< HEAD +======= + // lisätty jotta paketit kerkiävät lähteä ennen sulkemista +>>>>>>> c26cf7e (pipeline works without glasses) await Future.delayed(const Duration(milliseconds: 200)); await _ws.stopAudioStream(); await _manager.transcription.stop(); } else { +<<<<<<< HEAD await _phoneAudio?.stop(); +======= + await _phoneAudio.stop(); +>>>>>>> c26cf7e (pipeline works without glasses) await _ws.stopAudioStream(); } } @@ -449,6 +520,10 @@ class _LandingScreenState extends State { ], ), const SizedBox(height: 22), +<<<<<<< HEAD +======= + +>>>>>>> c26cf7e (pipeline works without glasses) ValueListenableBuilder( valueListenable: _ws.aiResponse, builder: (context, aiResponse, _) { @@ -468,6 +543,10 @@ class _LandingScreenState extends State { ); }, ), +<<<<<<< HEAD +======= + +>>>>>>> c26cf7e (pipeline works without glasses) const SizedBox(height: 8), Center( child: Container( diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index e45f7c9..b9ceda0 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -33,6 +33,7 @@ class WebsocketService { 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 @@ -75,6 +76,10 @@ class WebsocketService { } else if (data['cmd'] == 'asr_stopped') { asrActive.value = false; } + } else if (type == 'ai') { + String response = data['data']; + aiResponse.value = response; + debugPrint(response); } else if (type == 'error') { //todo } @@ -101,6 +106,7 @@ class WebsocketService { connected.value = false; committedText.value = ''; interimText.value = ''; + aiResponse.value = ''; } } @@ -135,5 +141,6 @@ class WebsocketService { committedText.dispose(); interimText.dispose(); asrActive.dispose(); + aiResponse.dispose(); } } diff --git a/pubspec.lock b/pubspec.lock index eb00649..1ecbb85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -307,18 +307,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -544,10 +544,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" typed_data: dependency: transitive description: From 2efe24ec47e64d7a7a349860bb96465c2b7fdbe6 Mon Sep 17 00:00:00 2001 From: Sami Horttanainen Date: Wed, 4 Mar 2026 09:43:07 +0200 Subject: [PATCH 42/68] fix displaying of airesponse --- lib/screens/landing_screen.dart | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 77a25e7..c9c3f6a 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -112,21 +112,11 @@ class _LandingScreenState extends State { void _onAiResponse() { final aiResponse = _ws.aiResponse.value; - // Empty = session reset (disconnect / new start) → reset pointer - if (aiResponse.isEmpty) { - _lastCommittedLength = 0; - return; - } - // Extract only the newly committed sentence debugPrint(aiResponse); - final newSentence = aiResponse.substring(_lastCommittedLength).trim(); - _lastCommittedLength = aiResponse.length; - if (newSentence.isEmpty) return; - - debugPrint("→ Adding to display: '$newSentence'"); + debugPrint("→ Adding to display: '$aiResponse'"); if (_manager.isConnected && _manager.transcription.isActive.value) { - _addSentenceToDisplay(newSentence); + _addSentenceToDisplay(aiResponse); } } From f7b8a3cf3c9c70f83431901fb4442a6d99e52d2f Mon Sep 17 00:00:00 2001 From: Sami Horttanainen Date: Thu, 5 Mar 2026 08:39:42 +0200 Subject: [PATCH 43/68] remove dead code --- lib/screens/home_screen.dart | 208 ---------------------------- lib/screens/landing_screen.dart | 70 +--------- lib/widgets/glasses_connection.dart | 141 ------------------- 3 files changed, 5 insertions(+), 414 deletions(-) delete mode 100644 lib/screens/home_screen.dart delete mode 100644 lib/widgets/glasses_connection.dart diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart deleted file mode 100644 index 92d2200..0000000 --- a/lib/screens/home_screen.dart +++ /dev/null @@ -1,208 +0,0 @@ -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 '../widgets/g1_connection.dart'; -import '../services/websocket_service.dart'; - -/// Main screen of the app. Manages BLE glasses connection, -/// audio streaming, and live transcription display. - -class HomePage 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 HomePage( - {this.manager, this.decoder, this.ws, this.audioPipeline, super.key}); - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - final TextEditingController _controller = TextEditingController(); - late final G1Manager _manager; - late final Lc3Decoder _decoder; - late final WebsocketService _ws; - late final AudioPipeline _audioPipeline; - - @override - void initState() { - super.initState(); - - // Use injected dependencies or create real ones - _manager = widget.manager ?? G1Manager(); - _decoder = widget.decoder ?? Lc3Decoder(); - _ws = widget.ws ?? WebsocketService(); - _audioPipeline = widget.audioPipeline ?? - AudioPipeline( - _manager, - _decoder, - onPcmData: (pcm) { - // Forward decoded pcm audio to the backend via WebSocket - if (_ws.connected.value) _ws.sendAudio(pcm); - }, - ); - - // Connect to backend WebSocket server when homePage is initialized - _ws.connect(); - - // Add listener for mic audio packets from glasses - _audioPipeline.addListenerToMicrophone(); - - // React to Speech to text updates from the backend - // Used to update the UI (fired when committedText/interimText is changed) - _ws.committedText.addListener(_onWsChange); - _ws.interimText.addListener(_onWsChange); - } - - @override - void dispose() { - _ws.committedText.removeListener(_onWsChange); - _ws.interimText.removeListener(_onWsChange); - _controller.dispose(); - _audioPipeline.dispose(); - _ws.dispose(); - _manager.dispose(); - super.dispose(); - } - - /// Forwards changes to the glasses display if connected and transcription is active. - void _onWsChange() { - if (_manager.isConnected && _manager.transcription.isActive.value) { - final text = _ws.getFullText(); - _manager.transcription.displayText( - text, - isInterim: _ws.interimText.value.isNotEmpty, - ); - } - } - - /// Begin a transcription session - Future _startTranscription() async { - await _ws.startAudioStream(); - await _manager.transcription.start(); - } - - /// End a transcription session - Future _stopTranscription() async { - await _audioPipeline.stop(); - await _ws.stopAudioStream(); - await _manager.transcription.stop(); - } - - /// Send the text field contents to the glasses and clear the input. - void _sendAndClear() { - final text = _controller.text.trim(); - _sendTextToGlasses(text); - _controller.clear(); - } - - /// Display text on the glasses (only works when - /// connected and transcription mode is active). - /// can be used to test without backend - Future _sendTextToGlasses(String text) async { - if (_manager.isConnected && _manager.transcription.isActive.value) { - await _manager.transcription.displayText(text); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Smarties App'), - actions: [ - // WebSocket connection status indicator / reconnect button - Padding( - padding: const EdgeInsets.only(right: 8), - child: ListenableBuilder( - listenable: _ws.connected, - builder: (context, _) => _ws.connected.value - ? const Row( - children: [ - Icon(Icons.signal_cellular_alt, - color: Colors.green, size: 20), - SizedBox(width: 6), - Text('Connected', - style: - TextStyle(fontSize: 12, color: Colors.green)), - ], - ) - : OutlinedButton.icon( - onPressed: () => _ws.connect(), - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Reconnect to server'), - ), - )), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - const SizedBox(height: 24), - Text('Response:', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - - // Live transcription text - ListenableBuilder( - listenable: - Listenable.merge([_ws.committedText, _ws.interimText]), - builder: (context, _) => SelectableText(_ws.getFullText()), - ), - - // Text input row — only enabled during active transcription - ListenableBuilder( - listenable: _manager.transcription.isActive, - builder: (context, _) { - final active = _manager.transcription.isActive.value; - return Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - enabled: active, - decoration: const InputDecoration( - hintText: 'Type text to send to glasses', - border: OutlineInputBorder(), - ), - onSubmitted: (_) => _sendAndClear(), - ), - ), - const SizedBox(width: 8), - IconButton( - onPressed: active ? _sendAndClear : null, - icon: const Icon(Icons.send), - ), - ], - ); - }, - ), - ElevatedButton( - onPressed: () => _ws.clearCommittedText(), - child: const Text('Clear text')), - const SizedBox(height: 16), - - // BLE glasses connection widget + record toggle button - GlassesConnection( - manager: _manager, - onRecordToggle: () async { - if (!_manager.transcription.isActive.value) { - await _startTranscription(); - } else { - await _stopTranscription(); - } - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index c9c3f6a..8e9b82c 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -153,7 +153,6 @@ class _LandingScreenState extends State { Future _startTranscription() async { if (_manager.isConnected) { -<<<<<<< HEAD await _manager.transcription.stop(); await Future.delayed(const Duration(milliseconds: 300)); @@ -167,23 +166,6 @@ class _LandingScreenState extends State { await _ensurePhoneAudioReady(); await _phoneAudio!.start((pcm) { if (_ws.connected.value) _ws.sendAudio(pcm); -======= - //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 - _lastCommittedLength = 0; - _clearDisplayQueue(); - - await _ws.startAudioStream(); - await _manager.transcription.start(); - - if (_usePhoneMic) { - await _phoneAudio.start((pcm) { - if (_ws.connected.value) { - _ws.sendAudio(pcm); - } ->>>>>>> c26cf7e (pipeline works without glasses) }); } else { await _manager.microphone.enable(); @@ -191,7 +173,6 @@ class _LandingScreenState extends State { } await _manager.transcription.displayText('Recording started.'); -<<<<<<< HEAD } else { _ws.clearCommittedText(); _clearDisplayQueue(); @@ -203,65 +184,32 @@ class _LandingScreenState extends State { }); } -======= - debugPrint("Transcription (re)started"); - } else { - //wo glasses - _ws.clearCommittedText(); // reset accumulated text — backend starts fresh too - _lastCommittedLength = 0; - _clearDisplayQueue(); - await _ws.startAudioStream(); - await _phoneAudio.start( - (pcm) { - if (_ws.connected.value) _ws.sendAudio(pcm); - }, - ); - } ->>>>>>> c26cf7e (pipeline works without glasses) _isRecording.value = true; } - Future _stopTranscription() async { + Future stopTranscription() async { _isRecording.value = false; -<<<<<<< HEAD - -======= ->>>>>>> c26cf7e (pipeline works without glasses) if (_manager.isConnected) { _clearDisplayQueue(); await _manager.transcription.displayText('Recording stopped.'); await Future.delayed(const Duration(seconds: 2)); -<<<<<<< HEAD if (_usePhoneMic) { await _phoneAudio?.stop(); -======= - if (_usePhoneMic) { - await _phoneAudio.stop(); ->>>>>>> c26cf7e (pipeline works without glasses) } else { await _manager.microphone.disable(); await _audioPipeline.stop(); } -<<<<<<< HEAD - -======= - // lisätty jotta paketit kerkiävät lähteä ennen sulkemista ->>>>>>> c26cf7e (pipeline works without glasses) await Future.delayed(const Duration(milliseconds: 200)); await _ws.stopAudioStream(); await _manager.transcription.stop(); } else { -<<<<<<< HEAD await _phoneAudio?.stop(); -======= - await _phoneAudio.stop(); ->>>>>>> c26cf7e (pipeline works without glasses) await _ws.stopAudioStream(); } } - void _openDrawer() => _scaffoldKey.currentState?.openDrawer(); + void openDrawer() => _scaffoldKey.currentState?.openDrawer(); @override Widget build(BuildContext context) { @@ -278,7 +226,7 @@ class _LandingScreenState extends State { Row( children: [ IconButton( - onPressed: _openDrawer, + onPressed: openDrawer, icon: const Icon(Icons.menu, color: Color(0xFF00239D)), tooltip: 'Open history panel', ), @@ -368,7 +316,7 @@ class _LandingScreenState extends State { child: LandingTile( icon: Icons.list_alt, label: 'Key points', - onTap: _openDrawer, + onTap: openDrawer, ), ), const SizedBox(width: 14), @@ -444,7 +392,7 @@ class _LandingScreenState extends State { if (!isRecording) { await _startTranscription(); } else { - await _stopTranscription(); + await stopTranscription(); } }, child: Container( @@ -510,10 +458,6 @@ class _LandingScreenState extends State { ], ), const SizedBox(height: 22), -<<<<<<< HEAD -======= - ->>>>>>> c26cf7e (pipeline works without glasses) ValueListenableBuilder( valueListenable: _ws.aiResponse, builder: (context, aiResponse, _) { @@ -533,10 +477,6 @@ class _LandingScreenState extends State { ); }, ), -<<<<<<< HEAD -======= - ->>>>>>> c26cf7e (pipeline works without glasses) const SizedBox(height: 8), Center( child: Container( diff --git a/lib/widgets/glasses_connection.dart b/lib/widgets/glasses_connection.dart deleted file mode 100644 index c2f3fba..0000000 --- a/lib/widgets/glasses_connection.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:even_realities_g1/even_realities_g1.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; - -/// Widget that manages the BLE connection UI for the G1 glasses. -/// -/// Reacts to [G1ConnectionState] changes via a StreamBuilder and shows -/// different UI for each state -class GlassesConnection extends StatefulWidget { - final G1Manager manager; - - /// Called when the user taps the Record/Stop button (only visible when connected). - final Future Function()? onRecordToggle; - const GlassesConnection( - {super.key, required this.manager, this.onRecordToggle}); - - @override - State createState() => _GlassesConnectionState(); -} - -class _GlassesConnectionState extends State { - void startScan() async { - try { - await widget.manager.startScan(); - } on Exception catch (e) { - //If Bluetooth is off, attempt to turn it on. - if (e.toString().contains('Bluetooth is turned off')) { - await FlutterBluePlus.turnOn(); - } - } - } - - @override - Widget build(BuildContext context) { - // Rebuild whenever the glasses connection state changes - return StreamBuilder( - stream: widget.manager.connectionState, - builder: (context, snapshot) { - // No event yet — show the initial connect button - if (snapshot.connectionState == ConnectionState.waiting) { - return ElevatedButton( - onPressed: startScan, - child: const Text('Connect to glasses'), - ); - } - - if (snapshot.hasData) { - switch (snapshot.data!.state) { - // Connected - case G1ConnectionState.connected: - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: widget.manager.disconnect, - child: const Text('Disconnect'), - ), - const SizedBox(width: 8), - // Toggle between Record/Stop based on transcription state - ValueListenableBuilder( - valueListenable: widget.manager.transcription.isActive, - builder: (context, isRecording, _) => ElevatedButton( - onPressed: () => widget.onRecordToggle?.call(), - style: ElevatedButton.styleFrom( - iconColor: isRecording ? Colors.red : Colors.green, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(isRecording ? Icons.mic_off : Icons.mic), - const SizedBox(width: 4), - Text(isRecording ? 'Stop' : 'Record'), - ], - ), - ), - ) - ], - ) - ], - ); - - // Disconnected - case G1ConnectionState.disconnected: - return ElevatedButton( - onPressed: startScan, - child: const Text('Connect to glasses'), - ); - - // Scanning - case G1ConnectionState.scanning: - return const Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Searching for glasses'), - CircularProgressIndicator(), - ], - ); - - // Connecting - case G1ConnectionState.connecting: - return const Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Connecting to glasses'), - CircularProgressIndicator(), - ], - ); - - // Error - case G1ConnectionState.error: - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Error in connecting to glasses'), - ElevatedButton( - onPressed: startScan, - child: const Text('Connect to glasses'), - ), - ], - ); - } - } - - // Fallback if no data in stream - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('No glasses found'), - ElevatedButton( - onPressed: startScan, - child: const Text('Connect to glasses'), - ), - ], - ); - }, - ); - } -} From 1e28857a6f1226aec11dace57fd5cfa95a2da099 Mon Sep 17 00:00:00 2001 From: HorttanainenSami Date: Thu, 5 Mar 2026 17:54:39 +0200 Subject: [PATCH 44/68] Update README.md Add build instructions for android --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 9b0232f..48bb497 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,19 @@ When you return to coding: ``` --- +## Build app to android + +run command + +```bash +flutter build apk --dart-define-from-file=config_staging.json +``` + +then install it to usb connected android phone + +```bash +flutter install +``` ## Project structure (Flutter) From e77507d63319a7e4b594936b811e164113b24002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:07:06 +0200 Subject: [PATCH 45/68] fix: refactor github action --- .github/workflows/main.yml | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 27b534e..c16f682 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Frontend CI (Flutter) +name: Frontend CI on: push: @@ -11,23 +11,10 @@ jobs: 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 +22,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 . - 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 From f08d38f7c9e9ea6c4d8d5bf6b4922d8910936ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:09:55 +0200 Subject: [PATCH 46/68] fix: more CI refactor --- .github/workflows/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c16f682..6b7a3b0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,7 +9,6 @@ on: jobs: flutter: runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@v6 From d46205b133d6549f06e46c27c2c9557076406739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:27:53 +0200 Subject: [PATCH 47/68] fix: macos files --- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 ++++++++++++++++++++++++++ macos/Flutter/Flutter-Debug.xcconfig | 1 + macos/Flutter/Flutter-Release.xcconfig | 1 + macos/Podfile | 42 +++++++++++++++++++++++++ 6 files changed, 89 insertions(+) create mode 100644 ios/Podfile create mode 100644 macos/Podfile 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/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/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 From bd045bd012e2af07e80a0f266447698fc21b6376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:41:37 +0200 Subject: [PATCH 48/68] fix: stop tracking vscode files --- .gitignore | 6 ++---- .vscode/launch.json | 23 ----------------------- .vscode/settings.json | 13 ------------- 3 files changed, 2 insertions(+), 40 deletions(-) delete mode 100644 .vscode/launch.json delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 84d9097..9c570ce 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/ 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 -} From 11bc811f29deaaf16bd84e02d34bad9bccedcfbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:57:31 +0200 Subject: [PATCH 49/68] fix: edit comment Co-authored-by: vainiovesa <186166946+vainiovesa@users.noreply.github.com> --- lib/main.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0596182..bf49d39 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,9 +4,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 - + fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, color: false); // Disable logging runApp(const MyApp()); } From 3f415d1f80141c32726b0981602109dc45ff7c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:09:45 +0200 Subject: [PATCH 50/68] fix: format --- lib/main.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index bf49d39..844cf96 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,8 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; void main() { WidgetsFlutterBinding.ensureInitialized(); - fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, color: false); // Disable logging + fbp.FlutterBluePlus.setLogLevel(fbp.LogLevel.none, + color: false); // Disable logging for fbp package runApp(const MyApp()); } From 86aa9dca026f38d9b99028a5466521d93e0eddb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:12:26 +0200 Subject: [PATCH 51/68] fix: format --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 844cf96..f258a60 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +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); // Disable logging for fbp package + color: false); // Disable logging for fbp package runApp(const MyApp()); } From 19d4fad7aa5292b4b2001a263a5edacd146387e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:13:03 +0200 Subject: [PATCH 52/68] feat: remove output=none from dart format CI --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6b7a3b0..5c73020 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ jobs: run: flutter pub get - name: Check formatting - run: dart format --output=none --set-exit-if-changed . + run: dart format --set-exit-if-changed . - name: Analyze run: flutter analyze From 5446638da32288728bb26522fcc6330f09c5c769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:13:12 +0200 Subject: [PATCH 53/68] feat: renovate app startup config Co-authored-by: vainiovesa <186166946+vainiovesa@users.noreply.github.com> --- .gitignore | 4 +--- config_staging.example.json | 2 +- lib/services/websocket_service.dart | 24 ++++++++++++++++++++---- run.sh | 9 --------- 4 files changed, 22 insertions(+), 17 deletions(-) delete mode 100755 run.sh diff --git a/.gitignore b/.gitignore index 9c570ce..9b552aa 100644 --- a/.gitignore +++ b/.gitignore @@ -45,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/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/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index b9ceda0..c601859 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -22,10 +22,21 @@ import 'package:web_socket_channel/web_socket_channel.dart'; class WebsocketService { final String baseUrl; + static const String _defaultBaseUrl = '127.0.0.1:8000'; + static const String _apiUrlFromEnv = String.fromEnvironment('API_URL'); + WebsocketService({ - this.baseUrl = - const String.fromEnvironment('API_URL', defaultValue: '127.0.0.1:8000'), - }); + String? baseUrl, + }) : baseUrl = baseUrl ?? + const String.fromEnvironment('API_URL', + defaultValue: _defaultBaseUrl) { + if (baseUrl == null && _apiUrlFromEnv.isEmpty) { + debugPrint( + 'WARNING: API_URL is not set; using default baseUrl=$_defaultBaseUrl. ' + 'Set via --dart-define-from-file=config_.json', + ); + } + } WebSocketChannel? _audioChannel; @@ -45,8 +56,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; 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" From 807e78dc7b0aac27b7c8d90b820af8b2c169dab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:35:20 +0200 Subject: [PATCH 54/68] fix: refactor --- lib/services/websocket_service.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index c601859..52adcfb 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -21,18 +21,15 @@ import 'package:web_socket_channel/web_socket_channel.dart'; /// Raw PCM bytes (binary frame) class WebsocketService { final String baseUrl; + static const String defaultBaseUrl = '127.0.0.1:8000'; - static const String _defaultBaseUrl = '127.0.0.1:8000'; - static const String _apiUrlFromEnv = String.fromEnvironment('API_URL'); - - WebsocketService({ - String? baseUrl, - }) : baseUrl = baseUrl ?? + WebsocketService({String? baseUrl}) + : baseUrl = baseUrl ?? const String.fromEnvironment('API_URL', - defaultValue: _defaultBaseUrl) { - if (baseUrl == null && _apiUrlFromEnv.isEmpty) { + defaultValue: defaultBaseUrl) { + if (baseUrl == null && const String.fromEnvironment('API_URL').isEmpty) { debugPrint( - 'WARNING: API_URL is not set; using default baseUrl=$_defaultBaseUrl. ' + 'WARNING: API_URL is not set; using default baseUrl=$defaultBaseUrl. ' 'Set via --dart-define-from-file=config_.json', ); } From 010bfbfba26b885a1e9eb61047d8c42bbffbb64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veeti=20Martinm=C3=A4ki?= <181813689+veetimar@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:45:12 +0200 Subject: [PATCH 55/68] feat: update readme --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 48bb497..02ddffe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![GHA workflow badge](https://github.com/AI-Smarties/front/actions/workflows/main.yml/badge.svg) -# AI-Smarties - Frontend (Flutter) +# AI-Smarties - Frontend (Dart + Flutter) ## Requirements @@ -22,7 +22,7 @@ ## 2. Switch to development branch (dev) ```bash - git checkout dev + git switch dev ``` ## 3. Confirm the Flutter environment @@ -148,9 +148,7 @@ then install it to usb connected android phone flutter install ``` -## Project structure (Flutter) - -When `flutter create .` is run, the structure is typically: +## Project structure - `lib/` – Application UI and application logic - `test/` – Unit- and widget testing @@ -164,3 +162,6 @@ When `flutter create .` is run, the structure is typically: ## 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) From 02debe0d8473dbe382d7d9ce291d9de8afb22481 Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Mon, 2 Mar 2026 05:33:31 +0200 Subject: [PATCH 56/68] Added REST side panel for categories, conversations, and transcripts --- lib/screens/landing_screen.dart | 67 ++++++++++----------------------- 1 file changed, 19 insertions(+), 48 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 8e9b82c..ec76a71 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -112,9 +112,6 @@ class _LandingScreenState extends State { void _onAiResponse() { final aiResponse = _ws.aiResponse.value; - debugPrint(aiResponse); - - debugPrint("→ Adding to display: '$aiResponse'"); if (_manager.isConnected && _manager.transcription.isActive.value) { _addSentenceToDisplay(aiResponse); } @@ -134,6 +131,7 @@ class _LandingScreenState extends State { _displayedSentences.add(sentence); _manager.transcription.displayLines(List.unmodifiable(_displayedSentences)); + // Optional: auto-remove after some time to keep display fresh. Future.delayed(const Duration(seconds: 10), () { if (!_displayedSentences.contains(sentence)) return; _displayedSentences.remove(sentence); @@ -189,6 +187,7 @@ class _LandingScreenState extends State { Future stopTranscription() async { _isRecording.value = false; + if (_manager.isConnected) { _clearDisplayQueue(); await _manager.transcription.displayText('Recording stopped.'); @@ -327,17 +326,13 @@ class _LandingScreenState extends State { }, child: Container( height: 72, - padding: - const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: _usePhoneMic - ? Colors.lightGreen - .withAlpha((0.15 * 255).round()) + ? Colors.lightGreen.withAlpha((0.15 * 255).round()) : Colors.transparent, border: Border.all( - color: _usePhoneMic - ? Colors.lightGreen - : Colors.black12, + color: _usePhoneMic ? Colors.lightGreen : Colors.black12, ), borderRadius: BorderRadius.circular(8), ), @@ -345,11 +340,7 @@ class _LandingScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ _usePhoneMic - ? const Icon( - Icons.mic, - size: 22, - color: Colors.lightGreen, - ) + ? const Icon(Icons.mic, size: 22, color: Colors.lightGreen) : Image.asset( 'assets/images/g1-smart-glasses.webp', height: 22, @@ -358,18 +349,12 @@ class _LandingScreenState extends State { const SizedBox(width: 10), Expanded( child: Text( - _usePhoneMic - ? 'Phone mic\n(Active)' - : 'Glasses mic\n(Active)', + _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, + fontWeight: _usePhoneMic ? FontWeight.bold : FontWeight.normal, + color: _usePhoneMic ? Colors.lightGreen : Colors.black, ), ), ), @@ -397,17 +382,13 @@ class _LandingScreenState extends State { }, child: Container( height: 72, - padding: const EdgeInsets.symmetric( - horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: isRecording - ? Colors.red - .withAlpha((0.15 * 255).round()) + ? Colors.red.withAlpha((0.15 * 255).round()) : Colors.transparent, border: Border.all( - color: isRecording - ? Colors.red - : Colors.black12, + color: isRecording ? Colors.red : Colors.black12, ), borderRadius: BorderRadius.circular(8), ), @@ -415,27 +396,19 @@ class _LandingScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - isRecording - ? Icons.stop_circle_outlined - : Icons.fiber_manual_record, + isRecording ? Icons.stop_circle_outlined : Icons.fiber_manual_record, size: 22, - color: isRecording - ? Colors.red - : Colors.grey[800], + color: isRecording ? Colors.red : Colors.grey[800], ), const SizedBox(width: 10), Expanded( child: Text( - isRecording - ? 'Stop\nRecording' - : 'Start\nRecording', + isRecording ? 'Stop\nRecording' : 'Start\nRecording', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, - color: isRecording - ? Colors.red - : Colors.grey[800], + color: isRecording ? Colors.red : Colors.grey[800], ), ), ), @@ -464,8 +437,7 @@ class _LandingScreenState extends State { if (aiResponse.isEmpty) return const SizedBox.shrink(); return Container( width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(8), @@ -480,8 +452,7 @@ class _LandingScreenState extends State { const SizedBox(height: 8), Center( child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), decoration: BoxDecoration( border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(8), @@ -590,4 +561,4 @@ class LandingTile extends StatelessWidget { ), ); } -} +} \ No newline at end of file From ae16240ede9101c48f3bb5f2f508e4b4eb7257d1 Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 00:27:26 +0200 Subject: [PATCH 57/68] test: improved mocks and expanded landing screen coverage --- test/mocks/fake_rest_api_service.dart | 106 ++++++++++++++++++++ test/mocks/fake_websocket_service.dart | 104 +++++++++++++++++++ test/widget_test.dart | 132 +++++++++++-------------- 3 files changed, 269 insertions(+), 73 deletions(-) create mode 100644 test/mocks/fake_rest_api_service.dart create mode 100644 test/mocks/fake_websocket_service.dart diff --git a/test/mocks/fake_rest_api_service.dart b/test/mocks/fake_rest_api_service.dart new file mode 100644 index 0000000..db1e31d --- /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); +} \ No newline at end of file diff --git a/test/mocks/fake_websocket_service.dart b/test/mocks/fake_websocket_service.dart new file mode 100644 index 0000000..6a75e61 --- /dev/null +++ b/test/mocks/fake_websocket_service.dart @@ -0,0 +1,104 @@ +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); +} \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart index 5003a35..4e7f3ce 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,18 +1,24 @@ -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_websocket_service.dart'; +import 'mocks/fake_rest_api_service.dart'; + void main() { late MockG1Manager mockManager; + late FakeWebsocketService fakeWs; setUp(() { mockManager = MockG1Manager(); + fakeWs = FakeWebsocketService(); }); tearDown(() { mockManager.dispose(); + // LandingScreen owns ws and disposes it in LandingScreen.dispose(). }); Future pumpLanding(WidgetTester tester) async { @@ -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,100 @@ void main() { await tester.pump(const Duration(milliseconds: 600)); } - testWidgets('App shows text input and send button', - (WidgetTester tester) async { + testWidgets('Landing screen shows glasses image label', (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); - await disposeLanding(tester); }); - testWidgets('Connecting to glasses text is shown when bluetooth is scanning', - (tester) async { + testWidgets('Landing screen shows Key points tile', (tester) async { await pumpLanding(tester); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.connecting)); - - await tester.pump(); - - expect(find.text('Connecting to glasses'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - + expect(find.text('Key points'), findsOneWidget); await disposeLanding(tester); }); - testWidgets('Disconnect from glasses button is shown', (tester) async { + testWidgets('Landing screen shows disabled Recordings tile', (tester) async { await pumpLanding(tester); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.disconnected)); - - await tester.pump(); - - expect(find.text('Connect to glasses'), findsOneWidget); - + expect(find.text('Recordings'), findsOneWidget); await disposeLanding(tester); }); - testWidgets('On connecting error right error message is shown', - (tester) async { + testWidgets('Landing screen shows Start Recording button', (tester) async { await pumpLanding(tester); - - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.error)); - - await tester.pump(); - - expect(find.text('Error in connecting to glasses'), findsOneWidget); - expect(find.text('Connect to glasses'), findsOneWidget); - + expect(find.text('Start\nRecording'), findsOneWidget); await disposeLanding(tester); }); - testWidgets('On scanning Scanning for glasses message is shown', - (tester) async { + testWidgets('Landing screen shows menu icon button', (tester) async { await pumpLanding(tester); - - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.scanning)); - - await tester.pump(); - - expect(find.text('Searching for glasses'), findsOneWidget); - + expect(find.byIcon(Icons.menu), findsOneWidget); await disposeLanding(tester); }); - testWidgets('When connected show right text', (tester) async { + 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); + }); - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.connected)); - + testWidgets('Shows Reconnect button when WebSocket is disconnected', (tester) async { + await pumpLanding(tester); + fakeWs.setConnected(false); await tester.pump(); + expect(find.text('Reconnect to server'), findsOneWidget); + await disposeLanding(tester); +}); + testWidgets('Shows Connected indicator when WebSocket is connected', (tester) async { + fakeWs.setConnected(true); + await pumpLanding(tester); + await tester.pump(); expect(find.text('Connected'), findsOneWidget); - await disposeLanding(tester); }); - testWidgets('Shows scanning state when connecting', (tester) async { + testWidgets('Mic toggle shows Glasses mic by default', (tester) async { await pumpLanding(tester); + expect(find.text('Glasses mic\n(Active)'), findsOneWidget); + await disposeLanding(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)); + testWidgets('Tapping mic toggle switches to Phone mic', (tester) async { + await pumpLanding(tester); + await tester.tap(find.text('Glasses mic\n(Active)')); await tester.pump(); - expect(find.text('Connecting to glasses'), findsOneWidget); + expect(find.text('Phone mic\n(Active)'), findsOneWidget); + await disposeLanding(tester); + }); - mockManager - .emitState(const G1ConnectionEvent(state: G1ConnectionState.connected)); - await tester.pump(); - expect(find.text('Connected'), 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('Tapping Key points tile opens the side panel drawer', (tester) async { + await pumpLanding(tester); + await tester.tap(find.text('Key points')); + await tester.pumpAndSettle(); + expect(find.text('History'), 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); }); -} +} \ No newline at end of file From 4e2b840ad4dca003775dba22f596fd17519e78aa Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 00:38:14 +0200 Subject: [PATCH 58/68] fix: prevented repeated phone audio stream init --- lib/services/phone_audio_service.dart | 42 +++++++++++++++++++-------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/lib/services/phone_audio_service.dart b/lib/services/phone_audio_service.dart index 675eda1..8b24da8 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,7 +60,9 @@ class PhoneAudioService { } Future dispose() async { + await stop(); + await _controllerSubscription?.cancel(); await _controller.close(); await _recorder.closeRecorder(); } -} +} \ No newline at end of file From 2ab8890c86a5f738a7a748ac0ece233156e682d7 Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 00:39:19 +0200 Subject: [PATCH 59/68] feat: stabilized recording flow and injected testable dependencies --- lib/screens/landing_screen.dart | 75 ++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index ec76a71..bc88e94 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -109,14 +109,6 @@ class _LandingScreenState extends State { } } - void _onAiResponse() { - final aiResponse = _ws.aiResponse.value; - - if (_manager.isConnected && _manager.transcription.isActive.value) { - _addSentenceToDisplay(aiResponse); - } - } - /// Adds a sentence to the on-screen queue. /// /// Each sentence is a separate BLE packet (lineNumber 1..N). @@ -185,7 +177,7 @@ class _LandingScreenState extends State { _isRecording.value = true; } - Future stopTranscription() async { + Future _stopTranscription() async { _isRecording.value = false; if (_manager.isConnected) { @@ -208,7 +200,7 @@ class _LandingScreenState extends State { } } - void openDrawer() => _scaffoldKey.currentState?.openDrawer(); + void _openDrawer() => _scaffoldKey.currentState?.openDrawer(); @override Widget build(BuildContext context) { @@ -225,7 +217,7 @@ class _LandingScreenState extends State { Row( children: [ IconButton( - onPressed: openDrawer, + onPressed: _openDrawer, icon: const Icon(Icons.menu, color: Color(0xFF00239D)), tooltip: 'Open history panel', ), @@ -315,7 +307,7 @@ class _LandingScreenState extends State { child: LandingTile( icon: Icons.list_alt, label: 'Key points', - onTap: openDrawer, + onTap: _openDrawer, ), ), const SizedBox(width: 14), @@ -326,13 +318,17 @@ class _LandingScreenState extends State { }, child: Container( height: 72, - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: + const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: _usePhoneMic - ? Colors.lightGreen.withAlpha((0.15 * 255).round()) + ? Colors.lightGreen + .withAlpha((0.15 * 255).round()) : Colors.transparent, border: Border.all( - color: _usePhoneMic ? Colors.lightGreen : Colors.black12, + color: _usePhoneMic + ? Colors.lightGreen + : Colors.black12, ), borderRadius: BorderRadius.circular(8), ), @@ -340,7 +336,8 @@ class _LandingScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ _usePhoneMic - ? const Icon(Icons.mic, size: 22, color: Colors.lightGreen) + ? const Icon(Icons.mic, + size: 22, color: Colors.lightGreen) : Image.asset( 'assets/images/g1-smart-glasses.webp', height: 22, @@ -349,12 +346,18 @@ class _LandingScreenState extends State { const SizedBox(width: 10), Expanded( child: Text( - _usePhoneMic ? 'Phone mic\n(Active)' : 'Glasses mic\n(Active)', + _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, + fontWeight: _usePhoneMic + ? FontWeight.bold + : FontWeight.normal, + color: _usePhoneMic + ? Colors.lightGreen + : Colors.black, ), ), ), @@ -377,18 +380,22 @@ class _LandingScreenState extends State { if (!isRecording) { await _startTranscription(); } else { - await stopTranscription(); + await _stopTranscription(); } }, child: Container( height: 72, - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric( + horizontal: 14), decoration: BoxDecoration( color: isRecording - ? Colors.red.withAlpha((0.15 * 255).round()) + ? Colors.red + .withAlpha((0.15 * 255).round()) : Colors.transparent, border: Border.all( - color: isRecording ? Colors.red : Colors.black12, + color: isRecording + ? Colors.red + : Colors.black12, ), borderRadius: BorderRadius.circular(8), ), @@ -396,19 +403,27 @@ class _LandingScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - isRecording ? Icons.stop_circle_outlined : Icons.fiber_manual_record, + isRecording + ? Icons.stop_circle_outlined + : Icons.fiber_manual_record, size: 22, - color: isRecording ? Colors.red : Colors.grey[800], + color: isRecording + ? Colors.red + : Colors.grey[800], ), const SizedBox(width: 10), Expanded( child: Text( - isRecording ? 'Stop\nRecording' : 'Start\nRecording', + isRecording + ? 'Stop\nRecording' + : 'Start\nRecording', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, - color: isRecording ? Colors.red : Colors.grey[800], + color: isRecording + ? Colors.red + : Colors.grey[800], ), ), ), @@ -437,7 +452,8 @@ class _LandingScreenState extends State { if (aiResponse.isEmpty) return const SizedBox.shrink(); return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), decoration: BoxDecoration( border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(8), @@ -452,7 +468,8 @@ class _LandingScreenState extends State { const SizedBox(height: 8), Center( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 5), decoration: BoxDecoration( border: Border.all(color: Colors.black12), borderRadius: BorderRadius.circular(8), From 3cb58aae66769e7900152622bb9b9113c30b259f Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 00:39:51 +0200 Subject: [PATCH 60/68] feat: improved side panel summaries and drawer interactions --- lib/widgets/side_panel.dart | 170 +++++++++++++++++++++++++++++------- 1 file changed, 137 insertions(+), 33 deletions(-) diff --git a/lib/widgets/side_panel.dart b/lib/widgets/side_panel.dart index e6b3d98..9643c18 100644 --- a/lib/widgets/side_panel.dart +++ b/lib/widgets/side_panel.dart @@ -3,16 +3,12 @@ import 'package:flutter/material.dart'; import '../models/api_models.dart'; import '../services/rest_api_service.dart'; -// This widget was added as the new REST-driven side drawer. -// Focuses on categories, conversations, and transcript segments. class SidePanel extends StatefulWidget { const SidePanel({ super.key, required this.api, }); -// Injected service instead of costructing it inside the widget -// so that the widget is easier to test and stays consistent with other dependencies. final RestApiService api; @override @@ -27,18 +23,14 @@ class _SidePanelState extends State { Category? _selectedCategory; Conversation? _selectedConversation; -// Separate loading states were added so only the relevant section -// shows loading, instead of blanking out the whole drawer. bool _loadingCategories = false; bool _loadingConversations = false; bool _loadingVectors = false; -// Separate errors per section for more useful debugging and UX. String? _categoryError; String? _conversationError; String? _vectorError; -// Inline category creation UI. bool _showNewCategoryField = false; final _newCatController = TextEditingController(); bool _creatingCategory = false; @@ -81,9 +73,6 @@ class _SidePanelState extends State { setState(() { _loadingConversations = true; _conversationError = null; - - // Changing category clears selected conversation - // and transcript segments because they may no longer match. (possible for change) _selectedConversation = null; _vectors = []; _vectorError = null; @@ -138,8 +127,6 @@ class _SidePanelState extends State { if (!mounted) return; _newCatController.clear(); setState(() => _showNewCategoryField = false); - - // After creating a new category, reload categories so the chip list updates. await _loadCategories(); } on ApiException catch (e) { if (!mounted) return; @@ -158,8 +145,6 @@ class _SidePanelState extends State { } Future _refreshAll() async { - // These ids are preserved so refresh can store the selected conversations - // instead of losing transcript context unnecessarily. final selectedCategoryId = _selectedCategory?.id; final selectedConversationId = _selectedConversation?.id; @@ -190,7 +175,6 @@ class _SidePanelState extends State { } void _selectConversation(Conversation conv) { - // Tapping the selected conversation again collapses the transcript section. if (_selectedConversation?.id == conv.id) { setState(() { _selectedConversation = null; @@ -210,6 +194,92 @@ class _SidePanelState extends State { '${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) { return Drawer( @@ -224,7 +294,6 @@ class _SidePanelState extends State { child: ListView( padding: EdgeInsets.zero, children: [ - // Focuses only on relevant data, could possibly add counts if needed/wanted _buildSectionLabel('Categories'), _buildCategoryChips(), _buildNewCategoryRow(), @@ -264,8 +333,6 @@ class _SidePanelState extends State { ), ), ), - // Refresh button added so that the drawer can re-fetch backend data - // without needing to fully close/reopen it. IconButton( icon: const Icon(Icons.refresh, color: Colors.white), tooltip: 'Refresh', @@ -322,7 +389,6 @@ class _SidePanelState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( children: [ - // "All" chip added so the user can clear category filtering. Padding( padding: const EdgeInsets.only(right: 6), child: FilterChip( @@ -341,7 +407,6 @@ class _SidePanelState extends State { ), ), ), - // "New" action chip added to open the inline category creation form. ActionChip( avatar: const Icon(Icons.add, size: 16), label: const Text('New'), @@ -446,13 +511,14 @@ class _SidePanelState extends State { 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, - // Highlight added so the selected conversation is visually obvious. selectedTileColor: const Color(0xFF00239D).withValues(alpha: 0.08), leading: Icon( @@ -477,17 +543,56 @@ class _SidePanelState extends State { ), onTap: () => _selectConversation(conv), ), - // Conversation summary shown only for the currently selected item - // to keep the list compact. - if (isSelected && conv.summary.isNotEmpty) + if (hasSummary) Padding( padding: const EdgeInsets.fromLTRB(56, 0, 16, 8), - child: Text( - conv.summary, - style: TextStyle( - fontSize: 12, - color: Colors.grey[700], - fontStyle: FontStyle.italic, + 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).withValues(alpha: 0.04) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSelected + ? const Color(0xFF00239D).withValues(alpha: 0.12) + : 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], + ), + ], + ], + ), ), ), ), @@ -538,7 +643,6 @@ class _SidePanelState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Segment numbering added for easier reading/debugging. Text( 'Segment ${i + 1}', style: TextStyle( @@ -593,4 +697,4 @@ class _ErrorRow extends StatelessWidget { ), ); } -} +} \ No newline at end of file From 5cc4aacbdc5a53a13678aeae907566b3e4235b7b Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Tue, 17 Mar 2026 15:17:54 +0200 Subject: [PATCH 61/68] FIX: Remove Play Store fallback and launch Even app directly via AndroidIntent --- android/app/src/main/AndroidManifest.xml | 2 ++ lib/screens/landing_screen.dart | 33 ++++++++++++------------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 72c9895..67454bf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,5 +50,7 @@ + + diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index 1a1dc1b..a56c6e1 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -81,7 +81,8 @@ class _LandingScreenState extends State { } // Open the Even app - // Android: launch the app directly via package and activity + // 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) { @@ -92,24 +93,24 @@ class _LandingScreenState extends State { componentName: 'com.even.g1.MainActivity', flags: [0x10000000], ); - await intent.launch(); } catch (_) { - const storeIntent = AndroidIntent( - action: 'android.intent.action.VIEW', - data: 'market://details?id=com.even.g1', - flags: [0x10000000], - ); - - try { - await storeIntent.launch(); - } catch (_) { - const webIntent = AndroidIntent( - action: 'android.intent.action.VIEW', - data: 'https://play.google.com/store/apps/details?id=com.even.g1', - flags: [0x10000000], + 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'), + ), + ], + ), ); - await webIntent.launch(); } } } else if (Platform.isIOS) { From c552de1a137aaf96e95ff58cfde56a650aa9abb4 Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 18:21:44 +0200 Subject: [PATCH 62/68] fix: category selection and calendar integration flow --- lib/screens/landing_screen.dart | 760 ++++++++++++++-------------- lib/services/calendar_service.dart | 110 ++-- lib/services/websocket_service.dart | 16 +- lib/widgets/side_panel.dart | 104 +++- test/widget_test.dart | 1 + 5 files changed, 535 insertions(+), 456 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index e0c94b7..dd9d6a6 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:front/services/audio_pipeline.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'; @@ -18,8 +19,6 @@ import 'register_screen.dart'; /// 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; @@ -40,8 +39,6 @@ class LandingScreen extends StatefulWidget { } class _LandingScreenState extends State { - // GlobalKey lets us open the drawer from multiple places, - // not just from a local BuildContext next to the Scaffold. final GlobalKey _scaffoldKey = GlobalKey(); late final G1Manager _manager; @@ -51,12 +48,12 @@ class _LandingScreenState extends State { late final RestApiService _api; late final CalendarService _calendarService; - // Lazy-init: keeps FlutterSoundRecorder from being created during widget tests. PhoneAudioService? _phoneAudio; bool _phoneAudioInitialized = false; bool _usePhoneMic = false; bool _isMuted = false; + final ValueNotifier _isRecording = ValueNotifier(false); final ValueNotifier _isRecordingBusy = ValueNotifier(false); @@ -92,7 +89,6 @@ class _LandingScreenState extends State { _isRecording.dispose(); _isRecordingBusy.dispose(); _audioPipeline.dispose(); - // _phoneAudio is null if the user never recorded with the phone mic. if (_phoneAudio != null) { unawaited(_phoneAudio!.dispose()); } @@ -101,7 +97,6 @@ class _LandingScreenState extends State { super.dispose(); } - /// Creates and initialises [PhoneAudioService] on first use. Future _ensurePhoneAudioReady() async { _phoneAudio ??= PhoneAudioService(); if (!_phoneAudioInitialized) { @@ -110,9 +105,39 @@ class _LandingScreenState extends State { } } + 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); + if (!_isMuted) { debugPrint("→ Adding to display: '$aiResponse'"); if (_manager.isConnected && _manager.transcription.isActive.value) { @@ -123,15 +148,10 @@ class _LandingScreenState extends State { } } - /// 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 are also automatically removed after a fixed timeout - /// (currently 10 seconds), so older lines can clear even without new ones. void _addSentenceToDisplay(String sentence) { if (_isMuted) return; if (sentence.trim().isEmpty) return; + if (_displayedSentences.length >= _maxDisplayedSentences) { _displayedSentences.removeAt(0); } @@ -150,14 +170,16 @@ class _LandingScreenState extends State { if (_manager.isConnected && _manager.transcription.isActive.value) { _manager.transcription.displayLines(const []); } -} + } - /// Begin a transcription session Future _startTranscription() async { 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)); @@ -165,6 +187,7 @@ class _LandingScreenState extends State { _clearDisplayQueue(); await _ws.startAudioStream(); await _manager.transcription.start(); + if (_usePhoneMic) { await _ensurePhoneAudioReady(); await _phoneAudio!.start((pcm) { @@ -174,8 +197,9 @@ class _LandingScreenState extends State { await _manager.microphone.enable(); _audioPipeline.addListenerToMicrophone(); } + await _manager.transcription.displayText('Recording started.'); - debugPrint("Transcription (re)started"); + debugPrint('Transcription (re)started'); } else { _ws.clearCommittedText(); _clearDisplayQueue(); @@ -185,15 +209,16 @@ class _LandingScreenState extends State { if (_ws.connected.value) _ws.sendAudio(pcm); }); } + _isRecording.value = true; } finally { _isRecordingBusy.value = false; } } - /// End a transcription session Future _stopTranscription() async { if (_isRecordingBusy.value) return; + _isRecordingBusy.value = true; _isRecording.value = false; try { @@ -201,12 +226,14 @@ class _LandingScreenState extends State { _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(); @@ -226,13 +253,15 @@ class _LandingScreenState extends State { return Scaffold( key: _scaffoldKey, backgroundColor: Colors.white, - drawer: SidePanel(api: _api), + drawer: SidePanel( + api: _api, + onCategorySelected: _handleCategorySelected, + ), body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), child: Column( children: [ - // ===== TOP BAR ===== Row( children: [ SizedBox( @@ -284,351 +313,356 @@ 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), - - // ===== GLASSES CONNECTION + MIC TOGGLE ===== - Row( - children: [ - Expanded( - child: GlassesConnection( - manager: _manager, - onRecordToggle: () async { - if (!_manager.transcription.isActive.value) { - final granted = - await _calendarService.requestPermission(); - if (granted) { - final events = await _calendarService - .getUpcomingEvents(); - final activeEvent = _calendarService - .selectActiveContext(events); - if (activeEvent != null) { - final payload = _calendarService - .buildCalendarPayload(activeEvent); - _ws.sendCalendarContext(payload); - } - } - await _startTranscription(); - } else { - await _stopTranscription(); - } - }, - ), - ), - 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: 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, + ), + ), + 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], ), - 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, + 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), - - // ===== RECORDING + MUTE ===== - 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]), - 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), + 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], ), - 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, + 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), - - // Mute button - 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, + ), + 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 - : Colors.grey[800], + .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: 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'), + ], + ), ), ), - ), - ), - ], - ), - - 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'), ], ), ), - ), - ], + ); + }, ), ), - - // ===== LOGIN / REGISTER ===== Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( @@ -638,7 +672,8 @@ class _LandingScreenState extends State { onPressed: () => Navigator.push( context, MaterialPageRoute( - builder: (_) => const LoginScreen()), + builder: (_) => const LoginScreen(), + ), ), child: const Text( 'Sign in', @@ -653,7 +688,8 @@ class _LandingScreenState extends State { onPressed: () => Navigator.push( context, MaterialPageRoute( - builder: (_) => const RegisterScreen()), + builder: (_) => const RegisterScreen(), + ), ), child: const Text( 'Register', @@ -672,46 +708,4 @@ 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]), - ), - ), - ], - ), - ), - ); - } } \ No newline at end of file diff --git a/lib/services/calendar_service.dart b/lib/services/calendar_service.dart index 2eabc15..74580f1 100644 --- a/lib/services/calendar_service.dart +++ b/lib/services/calendar_service.dart @@ -1,39 +1,50 @@ import 'package:device_calendar/device_calendar.dart'; +import 'package:flutter/foundation.dart'; class CalendarService { final DeviceCalendarPlugin _calendarPlugin = DeviceCalendarPlugin(); - //Requests calendar permission + /// Requests calendar permission. Future requestPermission() async { - var permissionsGranted = await _calendarPlugin.requestPermissions(); - if (permissionsGranted.isSuccess && permissionsGranted.data == true) { - return true; - // Permission granted, you can now access the calendar - } else { - return false; - // Permission denied, handle accordingly - } + 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 + /// Searches for upcoming events in the next 7 days. Future> getUpcomingEvents() async { - var calendarResult = await _calendarPlugin.retrieveCalendars(); + debugPrint('CALENDAR SERVICE: retrieving calendars'); + + final calendarResult = await _calendarPlugin.retrieveCalendars(); if (calendarResult.isSuccess && calendarResult.data != null) { - List calendars = calendarResult.data!; - List events = []; + final List calendars = calendarResult.data!; + final List events = []; + + final DateTime startDate = DateTime.now(); + final DateTime endDate = startDate.add(const Duration(days: 7)); - DateTime startDate = DateTime.now(); - DateTime endDate = startDate.add(const Duration(days: 7)); + debugPrint( + 'CALENDAR SERVICE: searching events from $startDate to $endDate', + ); - for (var calendar in calendars) { - var eventResult = await _calendarPlugin.retrieveEvents( + 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) { - List calendarEvents = eventResult.data!; - for (var event in calendarEvents) { + final List calendarEvents = eventResult.data!; + for (final event in calendarEvents) { if (event.start != null && event.end != null) { events.add( CalendarEventModel( @@ -47,57 +58,65 @@ class CalendarService { } } } + 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 + /// Selects the active or upcoming event. CalendarEventModel? selectActiveContext(List events) { - DateTime now = DateTime.now(); - //Event is happening now - for (var event in 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; } } - //Upcoming event - for (var event in events) { + + for (final event in events) { if (event.start.isAfter(now)) { + debugPrint('CALENDAR SERVICE: upcoming event found = ${event.title}'); return event; } } - //No active or upcoming events + + debugPrint('CALENDAR SERVICE: no active or upcoming event found'); return null; } - //Builds the payload to send to backend + /// 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 - } + '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() - } + '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 +/// Model to represent calendar events in a simplified way for our application. class CalendarEventModel { final String title; final String? description; @@ -110,4 +129,9 @@ class CalendarEventModel { required this.start, required this.end, }); -} + + @override + String toString() { + return 'CalendarEventModel(title: $title, start: $start, end: $end)'; + } +} \ No newline at end of file diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index c90e1ef..c73a548 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -71,13 +70,17 @@ class WebsocketService { 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') { final String response = data['data']; aiResponse.value = response; debugPrint(response); } else if (type == 'error') { - // todo + debugPrint('WS ERROR: ${data['message']}'); } }, onError: (_) => disconnect(), @@ -119,6 +122,15 @@ class WebsocketService { } } + 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'})); diff --git a/lib/widgets/side_panel.dart b/lib/widgets/side_panel.dart index 9643c18..9370264 100644 --- a/lib/widgets/side_panel.dart +++ b/lib/widgets/side_panel.dart @@ -7,9 +7,11 @@ class SidePanel extends StatefulWidget { const SidePanel({ super.key, required this.api, + required this.onCategorySelected, }); final RestApiService api; + final ValueChanged onCategorySelected; @override State createState() => _SidePanelState(); @@ -33,6 +35,7 @@ class _SidePanelState extends State { bool _showNewCategoryField = false; final _newCatController = TextEditingController(); + final _newCatFocusNode = FocusNode(); bool _creatingCategory = false; String? _createCategoryError; @@ -46,6 +49,7 @@ class _SidePanelState extends State { @override void dispose() { _newCatController.dispose(); + _newCatFocusNode.dispose(); super.dispose(); } @@ -125,9 +129,26 @@ class _SidePanelState extends State { try { await widget.api.createCategory(name); if (!mounted) return; - _newCatController.clear(); - setState(() => _showNewCategoryField = false); + 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(() { @@ -171,6 +192,7 @@ class _SidePanelState extends State { void _selectCategory(Category? cat) { setState(() => _selectedCategory = cat); + widget.onCategorySelected(cat); _loadConversations(categoryId: cat?.id); } @@ -282,35 +304,44 @@ class _SidePanelState extends State { @override Widget build(BuildContext context) { + final keyboardBottom = MediaQuery.of(context).viewInsets.bottom; + return Drawer( child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - Expanded( - child: RefreshIndicator( - onRefresh: _refreshAll, - child: ListView( - padding: EdgeInsets.zero, - children: [ - _buildSectionLabel('Categories'), - _buildCategoryChips(), - _buildNewCategoryRow(), - const Divider(height: 24), - _buildSectionLabel('Conversations'), - _buildConversationList(), - if (_selectedConversation != null) ...[ + 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('Transcripts'), - _buildVectorList(), + _buildSectionLabel('Conversations'), + _buildConversationList(), + if (_selectedConversation != null) ...[ + const Divider(height: 24), + _buildSectionLabel('Transcripts'), + _buildVectorList(), + ], + const SizedBox(height: 24), ], - const SizedBox(height: 24), - ], + ), ), ), - ), - ], + ], + ), ), ), ); @@ -410,9 +441,22 @@ class _SidePanelState extends State { ActionChip( avatar: const Icon(Icons.add, size: 16), label: const Text('New'), - onPressed: () => setState( - () => _showNewCategoryField = !_showNewCategoryField, - ), + onPressed: () { + setState(() { + _showNewCategoryField = !_showNewCategoryField; + _createCategoryError = null; + }); + + if (_showNewCategoryField) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _newCatFocusNode.requestFocus(); + } + }); + } else { + FocusScope.of(context).unfocus(); + } + }, ), ], ), @@ -426,14 +470,17 @@ class _SidePanelState extends State { 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, @@ -466,6 +513,7 @@ class _SidePanelState extends State { _showNewCategoryField = false; _newCatController.clear(); _createCategoryError = null; + FocusScope.of(context).unfocus(); }), ), ], diff --git a/test/widget_test.dart b/test/widget_test.dart index ee87236..2dead2d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:even_realities_g1/even_realities_g1.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; From 872d9e7baf3d10b1ef387797b12e212142acd733 Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 18:33:22 +0200 Subject: [PATCH 63/68] fix: Format Dart files --- lib/screens/landing_screen.dart | 68 ++++++++++++++++---------- lib/services/calendar_service.dart | 5 +- lib/services/phone_audio_service.dart | 2 +- lib/services/websocket_service.dart | 2 +- lib/widgets/side_panel.dart | 7 +-- test/mocks/fake_rest_api_service.dart | 2 +- test/mocks/fake_websocket_service.dart | 9 ++-- test/widget_test.dart | 29 +++++++---- 8 files changed, 74 insertions(+), 50 deletions(-) diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index dd9d6a6..06e326b 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -161,7 +161,8 @@ class _LandingScreenState extends State { 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)); }); } @@ -320,7 +321,8 @@ class _LandingScreenState extends State { keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), + constraints: + BoxConstraints(minHeight: constraints.maxHeight), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -344,7 +346,8 @@ class _LandingScreenState extends State { child: GlassesConnection( manager: _manager, onRecordToggle: () async { - if (!_manager.transcription.isActive.value) { + if (!_manager + .transcription.isActive.value) { await _startTranscription(); } else { await _stopTranscription(); @@ -359,18 +362,19 @@ class _LandingScreenState extends State { [_isRecording, _isRecordingBusy], ), builder: (context, _) { - final isLocked = - _isRecording.value || _isRecordingBusy.value; + 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()) + ? Colors.black + .withAlpha((0.04 * 255).round()) : (_usePhoneMic - ? Colors.lightGreen - .withAlpha((0.15 * 255).round()) + ? Colors.lightGreen.withAlpha( + (0.15 * 255).round()) : Colors.transparent); final textColor = isLocked ? Colors.black38 @@ -384,7 +388,8 @@ class _LandingScreenState extends State { onTap: isLocked ? null : () => setState( - () => _usePhoneMic = !_usePhoneMic, + () => _usePhoneMic = + !_usePhoneMic, ), child: Container( height: 72, @@ -393,8 +398,10 @@ class _LandingScreenState extends State { ), decoration: BoxDecoration( color: backgroundColor, - border: Border.all(color: borderColor), - borderRadius: BorderRadius.circular(8), + border: Border.all( + color: borderColor), + borderRadius: + BorderRadius.circular(8), ), child: Row( mainAxisAlignment: @@ -466,20 +473,21 @@ class _LandingScreenState extends State { [_isRecording, _isRecordingBusy], ), builder: (context, _) { - final isRecording = _isRecording.value; + final isRecording = + _isRecording.value; final isBusy = _isRecordingBusy.value; - final canStart = - _usePhoneMic || isGlassesConnected; - final isDisabled = - isBusy || (!isRecording && !canStart); + 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()) + ? Colors.black.withAlpha( + (0.04 * 255).round()) : (isRecording ? Colors.red.withAlpha( (0.15 * 255).round(), @@ -511,8 +519,8 @@ class _LandingScreenState extends State { ), decoration: BoxDecoration( color: backgroundColor, - border: - Border.all(color: borderColor), + border: Border.all( + color: borderColor), borderRadius: BorderRadius.circular(8), ), @@ -522,8 +530,10 @@ class _LandingScreenState extends State { children: [ Icon( isRecording - ? Icons.stop_circle_outlined - : Icons.fiber_manual_record, + ? Icons + .stop_circle_outlined + : Icons + .fiber_manual_record, size: 22, color: foregroundColor, ), @@ -533,12 +543,14 @@ class _LandingScreenState extends State { isRecording ? 'Stop\nRecording' : 'Start\nRecording', - textAlign: TextAlign.center, + textAlign: + TextAlign.center, style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, - color: foregroundColor, + color: + foregroundColor, ), ), ), @@ -555,7 +567,8 @@ class _LandingScreenState extends State { const SizedBox(width: 14), Expanded( child: InkWell( - onTap: () => setState(() => _isMuted = !_isMuted), + onTap: () => + setState(() => _isMuted = !_isMuted), child: Container( height: 72, padding: const EdgeInsets.symmetric( @@ -579,7 +592,8 @@ class _LandingScreenState extends State { children: [ Icon( _isMuted - ? Icons.comments_disabled_outlined + ? Icons + .comments_disabled_outlined : Icons.comment_outlined, size: 22, color: _isMuted @@ -708,4 +722,4 @@ class _LandingScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/services/calendar_service.dart b/lib/services/calendar_service.dart index 74580f1..b420a60 100644 --- a/lib/services/calendar_service.dart +++ b/lib/services/calendar_service.dart @@ -64,7 +64,8 @@ class CalendarService { return events; } - debugPrint('CALENDAR SERVICE: no calendars or failed to retrieve calendars'); + debugPrint( + 'CALENDAR SERVICE: no calendars or failed to retrieve calendars'); return []; } @@ -134,4 +135,4 @@ class CalendarEventModel { String toString() { return 'CalendarEventModel(title: $title, start: $start, end: $end)'; } -} \ No newline at end of file +} diff --git a/lib/services/phone_audio_service.dart b/lib/services/phone_audio_service.dart index 8b24da8..5b03a1d 100644 --- a/lib/services/phone_audio_service.dart +++ b/lib/services/phone_audio_service.dart @@ -65,4 +65,4 @@ class PhoneAudioService { await _controller.close(); await _recorder.closeRecorder(); } -} \ No newline at end of file +} diff --git a/lib/services/websocket_service.dart b/lib/services/websocket_service.dart index c73a548..67a49ee 100644 --- a/lib/services/websocket_service.dart +++ b/lib/services/websocket_service.dart @@ -169,4 +169,4 @@ class WebsocketService { aiResponse.dispose(); } } -} \ No newline at end of file +} diff --git a/lib/widgets/side_panel.dart b/lib/widgets/side_panel.dart index 9370264..df672fb 100644 --- a/lib/widgets/side_panel.dart +++ b/lib/widgets/side_panel.dart @@ -595,8 +595,9 @@ class _SidePanelState extends State { Padding( padding: const EdgeInsets.fromLTRB(56, 0, 16, 8), child: InkWell( - onTap: - isSelected ? () => _showSummarySheet(context, conv) : null, + onTap: isSelected + ? () => _showSummarySheet(context, conv) + : null, borderRadius: BorderRadius.circular(6), child: Container( width: double.infinity, @@ -745,4 +746,4 @@ class _ErrorRow extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/test/mocks/fake_rest_api_service.dart b/test/mocks/fake_rest_api_service.dart index db1e31d..7448e87 100644 --- a/test/mocks/fake_rest_api_service.dart +++ b/test/mocks/fake_rest_api_service.dart @@ -103,4 +103,4 @@ class FakeRestApiService implements RestApiService { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} \ No newline at end of file +} diff --git a/test/mocks/fake_websocket_service.dart b/test/mocks/fake_websocket_service.dart index 6a75e61..d75a558 100644 --- a/test/mocks/fake_websocket_service.dart +++ b/test/mocks/fake_websocket_service.dart @@ -61,10 +61,9 @@ class FakeWebsocketService implements WebsocketService { } @override - String getFullText() => - [committedText.value, interimText.value] - .where((s) => s.isNotEmpty) - .join(' '); + String getFullText() => [committedText.value, interimText.value] + .where((s) => s.isNotEmpty) + .join(' '); // Test helper methods void setConnected(bool value) { @@ -101,4 +100,4 @@ class FakeWebsocketService implements WebsocketService { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} \ No newline at end of file +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 2dead2d..d294ab5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -59,14 +59,16 @@ void main() { await disposeLanding(tester); }); - testWidgets('Landing screen shows Sign in and Register links', (tester) async { + 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 { + testWidgets('Shows Reconnect button when WebSocket is disconnected', + (tester) async { await pumpLanding(tester); fakeWs.setConnected(false); await tester.pump(); @@ -74,7 +76,8 @@ void main() { await disposeLanding(tester); }); - testWidgets('Shows Connected indicator when WebSocket is connected', (tester) async { + testWidgets('Shows Connected indicator when WebSocket is connected', + (tester) async { fakeWs.setConnected(true); await pumpLanding(tester); await tester.pump(); @@ -88,7 +91,8 @@ void main() { await disposeLanding(tester); }); - testWidgets('Tapping mic toggle switches to glasses mic option', (tester) async { + testWidgets('Tapping mic toggle switches to glasses mic option', + (tester) async { await pumpLanding(tester); await tester.tap(find.text('Switch to phone mic')); await tester.pump(); @@ -147,7 +151,8 @@ void main() { await tester.pump(const Duration(seconds: 11)); }); - testWidgets('Connecting to glasses text is shown when bluetooth is connecting', + testWidgets( + 'Connecting to glasses text is shown when bluetooth is connecting', (tester) async { await pumpLanding(tester); @@ -163,7 +168,8 @@ void main() { await disposeLanding(tester); }); - testWidgets('Connect to glasses button is shown when disconnected', (tester) async { + testWidgets('Connect to glasses button is shown when disconnected', + (tester) async { await pumpLanding(tester); mockManager.emitState( @@ -177,7 +183,8 @@ void main() { await disposeLanding(tester); }); - testWidgets('On connecting error right error message is shown', (tester) async { + testWidgets('On connecting error right error message is shown', + (tester) async { await pumpLanding(tester); mockManager.emitState( @@ -192,7 +199,8 @@ void main() { await disposeLanding(tester); }); - testWidgets('On scanning, searching for glasses message is shown', (tester) async { + testWidgets('On scanning, searching for glasses message is shown', + (tester) async { await pumpLanding(tester); mockManager.emitState( @@ -220,7 +228,8 @@ void main() { await disposeLanding(tester); }); - testWidgets('Shows scanning state transitions when connecting', (tester) async { + testWidgets('Shows scanning state transitions when connecting', + (tester) async { await pumpLanding(tester); mockManager.emitState( @@ -257,4 +266,4 @@ void main() { final mockDisplay = mockManager.display as MockG1Display; expect(mockDisplay.getText, isEmpty); }); -} \ No newline at end of file +} From 330914988b6cad939b13dacf0d5c371a2308f842 Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 18:39:18 +0200 Subject: [PATCH 64/68] fix: side panel color compatibility for CI --- lib/widgets/side_panel.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/widgets/side_panel.dart b/lib/widgets/side_panel.dart index df672fb..eb21857 100644 --- a/lib/widgets/side_panel.dart +++ b/lib/widgets/side_panel.dart @@ -567,8 +567,7 @@ class _SidePanelState extends State { ListTile( dense: true, selected: isSelected, - selectedTileColor: - const Color(0xFF00239D).withValues(alpha: 0.08), + selectedTileColor: const Color(0xFF00239D).withAlpha(20), leading: Icon( Icons.chat_bubble_outline, size: 18, @@ -605,12 +604,12 @@ class _SidePanelState extends State { const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: isSelected - ? const Color(0xFF00239D).withValues(alpha: 0.04) + ? const Color(0xFF00239D).withAlpha(10) : Colors.transparent, borderRadius: BorderRadius.circular(6), border: Border.all( color: isSelected - ? const Color(0xFF00239D).withValues(alpha: 0.12) + ? const Color(0xFF00239D).withAlpha(31) : Colors.transparent, ), ), From 4d1db37d28c9cebfeedb86ce1afbde26e94be1cc Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 18:42:52 +0200 Subject: [PATCH 65/68] fix: removed duplicate connection state widget tests --- test/widget_test.dart | 88 ------------------------------------------- 1 file changed, 88 deletions(-) diff --git a/test/widget_test.dart b/test/widget_test.dart index d294ab5..0cb793b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -19,7 +19,6 @@ void main() { tearDown(() { mockManager.dispose(); - // LandingScreen owns ws and disposes it in LandingScreen.dispose(). }); Future pumpLanding(WidgetTester tester) async { @@ -151,23 +150,6 @@ void main() { await tester.pump(const Duration(seconds: 11)); }); - testWidgets( - 'Connecting to glasses text is shown when bluetooth is connecting', - (tester) async { - await pumpLanding(tester); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.connecting), - ); - - await tester.pump(); - - expect(find.text('Connecting to glasses'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - - await disposeLanding(tester); - }); - testWidgets('Connect to glasses button is shown when disconnected', (tester) async { await pumpLanding(tester); @@ -183,76 +165,6 @@ void main() { await disposeLanding(tester); }); - testWidgets('On connecting error right error message is shown', - (tester) async { - await pumpLanding(tester); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.error), - ); - - await tester.pump(); - - expect(find.text('Error in connecting to glasses'), findsOneWidget); - expect(find.text('Connect to glasses'), findsOneWidget); - - await disposeLanding(tester); - }); - - testWidgets('On scanning, searching for glasses message is shown', - (tester) async { - await pumpLanding(tester); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.scanning), - ); - - await tester.pump(); - - expect(find.text('Searching for glasses'), findsOneWidget); - - await disposeLanding(tester); - }); - - testWidgets('When connected show right text', (tester) async { - await pumpLanding(tester); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.connected), - ); - - await tester.pump(); - - expect(find.text('Connected'), findsOneWidget); - - await disposeLanding(tester); - }); - - testWidgets('Shows scanning state transitions when connecting', - (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); - - mockManager.emitState( - const G1ConnectionEvent(state: G1ConnectionState.connected), - ); - await tester.pump(); - expect(find.text('Connected'), findsOneWidget); - - await disposeLanding(tester); - }); - test('Can send text to glasses when connected', () async { mockManager.setConnected(true); await mockManager.sendTextToGlasses('test'); From ed4c8c8fcc7d3e6a3ef7bd36e9007cebdbc48ed5 Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Tue, 17 Mar 2026 21:20:11 +0200 Subject: [PATCH 66/68] FIX: merge conflicts and AndroidManifest --- android/app/src/main/AndroidManifest.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e57b283..e72c19d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> Date: Wed, 18 Mar 2026 10:39:13 +0200 Subject: [PATCH 67/68] fix: pub update --- pubspec.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index dfd7e5c..82947e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -323,10 +323,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -568,10 +568,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" timezone: dependency: transitive description: From d892a59f34b6d84e51e412aa747e274ff1a93681 Mon Sep 17 00:00:00 2001 From: Sami Horttanainen Date: Wed, 18 Mar 2026 10:49:05 +0200 Subject: [PATCH 68/68] use https --- lib/services/rest_api_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/rest_api_service.dart b/lib/services/rest_api_service.dart index 69e5d77..403ee53 100644 --- a/lib/services/rest_api_service.dart +++ b/lib/services/rest_api_service.dart @@ -22,7 +22,7 @@ class RestApiService { String path, { Map? queryParameters, }) { - return Uri.parse('http://$baseUrl$path').replace( + return Uri.parse('https://$baseUrl$path').replace( queryParameters: queryParameters, ); }