From aa2604c8ed9004e1dcafe780bcb55e78ff356e9b Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Mon, 2 Mar 2026 05:33:31 +0200 Subject: [PATCH 01/31] 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 1cc3d42b6dfd7f23623d92a15d6b9ac54cbba3b2 Mon Sep 17 00:00:00 2001 From: Matias Palmroth Date: Sun, 1 Mar 2026 15:13:31 +0200 Subject: [PATCH 02/31] 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 03/31] 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 04/31] 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 05/31] 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 06/31] 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 07/31] 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 08/31] 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 09/31] 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 10/31] 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 11/31] 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 12/31] 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 13/31] 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 14/31] 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 15/31] 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 16/31] 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 17/31] 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 18/31] 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 19/31] 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 20/31] 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 21/31] 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 22/31] 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 23/31] 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 24/31] 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 25/31] 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 26/31] 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 27/31] 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 c552de1a137aaf96e95ff58cfde56a650aa9abb4 Mon Sep 17 00:00:00 2001 From: negentropy-en Date: Tue, 17 Mar 2026 18:21:44 +0200 Subject: [PATCH 28/31] 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 29/31] 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 30/31] 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 31/31] 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');