diff --git a/team_b/yappy/android/app/build.gradle b/team_b/yappy/android/app/build.gradle index 709e79eb..55d6c13f 100644 --- a/team_b/yappy/android/app/build.gradle +++ b/team_b/yappy/android/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId = "com.spring2025.yappy" - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 34 // Latest stable version versionCode = project.hasProperty('flutterVersionCode') ? flutterVersionCode.toInteger() : 1 versionName = project.hasProperty('flutterVersionName') ? flutterVersionName : "1.0.0" diff --git a/team_b/yappy/lib/home_page.dart b/team_b/yappy/lib/home_page.dart index 02abc8b0..64f4b26f 100644 --- a/team_b/yappy/lib/home_page.dart +++ b/team_b/yappy/lib/home_page.dart @@ -7,11 +7,47 @@ import 'package:yappy/medical_patient.dart'; import 'package:yappy/restaurant.dart'; import 'package:yappy/tool_bar.dart'; import 'package:yappy/settings_page.dart'; +import './services/model_manager.dart'; -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { const HomePage({super.key}); -//Creates the home page of the app -//The page will contain buttons that will navigate to different industries + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final ModelManager _modelManager = ModelManager(); + + @override + void initState() { + super.initState(); + // Check if models exist after the first frame is rendered + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkModelsExist(); + }); + } + + Future _checkModelsExist() async { + try { + // Skip check if downloads are already in progress + if (_modelManager.isDownloadInProgress()) { + return; + } + + final modelsExist = await _modelManager.modelsExist(); + if (!modelsExist && mounted) { + // Models don't exist, prompt for download + final shouldDownload = await _modelManager.showDownloadDialog(context); + if (shouldDownload && mounted) { + await _modelManager.downloadModels(context); + } + } + } catch (e) { + debugPrint('Error checking models: $e'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -59,5 +95,4 @@ class HomePage extends StatelessWidget { ), ); } -} - +} \ No newline at end of file diff --git a/team_b/yappy/lib/industry_menu.dart b/team_b/yappy/lib/industry_menu.dart index 6052f3e1..ddd5cdca 100644 --- a/team_b/yappy/lib/industry_menu.dart +++ b/team_b/yappy/lib/industry_menu.dart @@ -1,56 +1,90 @@ import 'package:flutter/material.dart'; +import 'package:record/record.dart'; +import 'package:yappy/speech_state.dart'; import 'package:yappy/services/database_helper.dart'; import 'package:share_plus/share_plus.dart'; +import 'services/model_manager.dart'; -class IndustryMenu extends StatelessWidget { +class IndustryMenu extends StatefulWidget { final String title; final IconData icon; + final SpeechState speechState; + final ModelManager modelManager; // Add model manager - const IndustryMenu({required this.title, required this.icon, super.key}); - Widget generateTranscript(BuildContext context, String title, String content) { - return AlertDialog( - title: Text(title), - content: SingleChildScrollView( - child: Text(content), - ), - actions: [ - //add export capes - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - icon: Icon(Icons.share), - onPressed: () { - // Add your share functionality here - Share.share( - content, - subject: title, - ); - }, - ), - IconButton( - icon: Icon(Icons.download), - onPressed: () { - // Add your download functionality here - }, - ), + const IndustryMenu({ + required this.title, + required this.icon, + required this.speechState, + required this.modelManager, // Add to constructor + super.key + }); + + @override + State createState() => _IndustryMenuState(); +} + +class _IndustryMenuState extends State { + bool modelsExist = false; + + @override + void initState() { + super.initState(); + _checkModels(); + } + + Future _checkModels() async { + final exist = await widget.modelManager.modelsExist(); + if (mounted) { + setState(() { + modelsExist = exist; + }); + } + } + + Widget generateTranscript(BuildContext context, String title, String content) { + return AlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: Text(content), + ), + actions: [ + //add export capes + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ IconButton( - icon: Icon(Icons.delete), - onPressed: () { - // Add your delete functionality here - }, - ), - ], - ), - TextButton( + icon: Icon(Icons.share), onPressed: () { - Navigator.of(context).pop(); + // Add your share functionality here + Share.share( + content, + subject: title, + ); }, - child: Text('Close'), ), - ], - ); - } + IconButton( + icon: Icon(Icons.download), + onPressed: () { + // Add your download functionality here + }, + ), + IconButton( + icon: Icon(Icons.delete), + onPressed: () { + // Add your delete functionality here + }, + ), + ], + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('Close'), + ), + ], + ); + } Future>> _fetchTranscripts() async { DatabaseHelper dbHelper = DatabaseHelper(); @@ -81,7 +115,7 @@ class IndustryMenu extends StatelessWidget { padding: EdgeInsets.all(12), child: Center( child: Text( - title, + widget.title, style: TextStyle( fontSize: 24, color: Colors.white @@ -97,18 +131,25 @@ class IndustryMenu extends StatelessWidget { children: [ // Creates the chat button for each menu Container( - decoration: - BoxDecoration(shape: BoxShape.circle, color: Colors.grey), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: !modelsExist + ? Color.fromRGBO(128, 128, 128, 0.5) + : (widget.speechState.recordState == RecordState.stop ? Colors.grey : Colors.red) + ), padding: EdgeInsets.all(5), - child: IconButton( - icon: Icon( - Icons.chat, - color: Colors.white, - size: screenHeight * .05, + child: Tooltip( + message: !modelsExist + ? "Download required models to enable recording" + : (widget.speechState.recordState == RecordState.stop ? "Start recording" : "Stop recording"), + child: IconButton( + icon: Icon( + widget.speechState.recordState == RecordState.stop ? Icons.mic : Icons.stop, + color: !modelsExist ? Color.fromRGBO(255, 255, 255, 0.5) : Colors.white, + size: screenHeight * .05, + ), + onPressed: !modelsExist ? null : () => widget.speechState.toggleRecording(), ), - onPressed: () { - //add Bernhards code here - }, ), ), SizedBox(width: screenWidth * .06), @@ -140,7 +181,7 @@ class IndustryMenu extends StatelessWidget { padding: EdgeInsets.all(5), child: IconButton( icon: Icon( - icon, + widget.icon, color: Colors.white, size: screenHeight * .05, ), @@ -209,7 +250,7 @@ class IndustryMenu extends StatelessWidget { ), onTap: () { Navigator.pop(context); - if (title == 'Restaurant') { + if (widget.title == 'Restaurant') { // Show Kanban style list for restaurant showModalBottomSheet( context: context, diff --git a/team_b/yappy/lib/main.dart b/team_b/yappy/lib/main.dart index 4e157d11..69ac584f 100644 --- a/team_b/yappy/lib/main.dart +++ b/team_b/yappy/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:yappy/home_page.dart'; import 'package:yappy/services/database_helper.dart'; +import './toast_widget.dart'; // Create a global instance of DatabaseHelper final DatabaseHelper dbHelper = DatabaseHelper(); @@ -18,7 +19,16 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, - home: const HomePage(), + title: 'Your App', + theme: ThemeData( + primarySwatch: Colors.lightGreen, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: HomePage(), // Your main app page + builder: (context, child) { + // Wrap every screen with ToastWidget + return ToastWidget(child: child ?? Container()); + }, ); } } \ No newline at end of file diff --git a/team_b/yappy/lib/mechanic.dart b/team_b/yappy/lib/mechanic.dart index 0345af40..a1a7a624 100644 --- a/team_b/yappy/lib/mechanic.dart +++ b/team_b/yappy/lib/mechanic.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:yappy/industry_menu.dart'; import 'package:yappy/tool_bar.dart'; import 'package:yappy/transcription_box.dart'; +import 'package:yappy/speech_state.dart'; +import 'services/model_manager.dart'; void main() { runApp(MechanicalAidApp()); @@ -20,7 +22,9 @@ class MechanicalAidApp extends StatelessWidget { //Creates a page for the Mechanical Aid industry //The page will contain the industry menu and the transcription box class MechanicalAidPage extends StatelessWidget { - const MechanicalAidPage({super.key}); + MechanicalAidPage({super.key}); + final speechState = SpeechState(); + final modelManager = ModelManager(); @override Widget build(BuildContext context) { @@ -31,16 +35,28 @@ class MechanicalAidPage extends StatelessWidget { child: ToolBar(), ), drawer: HamburgerDrawer(), - body: Column( - children: [ - IndustryMenu(title: "Vehicle Maintenance", icon: Icons.directions_car), - Expanded( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: TranscriptionBox(), - ), - ), - ], + body: ListenableBuilder( + listenable: speechState, + builder: (context, child) { + return Column( + children: [ + IndustryMenu( + title: "Vehicle Maintenance", + icon: Icons.directions_car, + speechState: speechState, + modelManager: modelManager, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TranscriptionBox( + controller: speechState.controller, + ), + ), + ), + ], + ); + } ), ); } diff --git a/team_b/yappy/lib/medical_doctor.dart b/team_b/yappy/lib/medical_doctor.dart index 8837701d..20caccb2 100644 --- a/team_b/yappy/lib/medical_doctor.dart +++ b/team_b/yappy/lib/medical_doctor.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:yappy/industry_menu.dart'; +import 'package:yappy/speech_state.dart'; import 'package:yappy/tool_bar.dart'; import 'package:yappy/transcription_box.dart'; - +import 'services/model_manager.dart'; class MedicalDoctorApp extends StatelessWidget { const MedicalDoctorApp({super.key}); @@ -17,7 +18,9 @@ class MedicalDoctorApp extends StatelessWidget { //Creates a page for the Medical Doctor industry //The page will contain the industry menu and the transcription box class MedicalDoctorPage extends StatelessWidget { - const MedicalDoctorPage({super.key}); + MedicalDoctorPage({super.key}); + final speechState = SpeechState(); + final modelManager = ModelManager(); @override Widget build(BuildContext context) { @@ -28,16 +31,28 @@ class MedicalDoctorPage extends StatelessWidget { child: ToolBar() ), drawer: HamburgerDrawer(), - body: Column( - children: [ - IndustryMenu(title: "Medical Doctor", icon: Icons.medical_services), - Expanded( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: TranscriptionBox(), - ), - ), - ], + body: ListenableBuilder( + listenable: speechState, + builder: (context, child) { + return Column( + children: [ + IndustryMenu( + title: "Medical Doctor", + icon: Icons.medical_services, + speechState: speechState, + modelManager: modelManager, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TranscriptionBox( + controller: speechState.controller, + ), + ), + ), + ], + ); + } ), ); } diff --git a/team_b/yappy/lib/medical_patient.dart b/team_b/yappy/lib/medical_patient.dart index aed3396b..62393566 100644 --- a/team_b/yappy/lib/medical_patient.dart +++ b/team_b/yappy/lib/medical_patient.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:yappy/speech_state.dart'; import 'package:yappy/tool_bar.dart'; import 'package:yappy/industry_menu.dart'; import 'package:yappy/transcription_box.dart'; - +import 'services/model_manager.dart'; class MedicalPatientApp extends StatelessWidget { const MedicalPatientApp({super.key}); @@ -17,7 +18,9 @@ class MedicalPatientApp extends StatelessWidget { //Creates a page for the Medical Patient industry //The page will contain the industry menu and the transcription box class MedicalPatientPage extends StatelessWidget { - const MedicalPatientPage({super.key}); + MedicalPatientPage({super.key}); + final speechState = SpeechState(); + final modelManager = ModelManager(); @override Widget build(BuildContext context) { @@ -28,16 +31,28 @@ class MedicalPatientPage extends StatelessWidget { child: ToolBar() ), drawer: HamburgerDrawer(), - body: Column( - children: [ - IndustryMenu(title: "Medical Patient", icon: Icons.local_pharmacy), - Expanded( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: TranscriptionBox(), - ), - ), - ], + body: ListenableBuilder( + listenable: speechState, + builder: (context, child) { + return Column( + children: [ + IndustryMenu( + title: "Medical Patient", + icon: Icons.local_pharmacy, + speechState: speechState, + modelManager: modelManager, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TranscriptionBox( + controller: speechState.controller, + ), + ), + ), + ], + ); + } ), ); } diff --git a/team_b/yappy/lib/offline_model.dart b/team_b/yappy/lib/offline_model.dart new file mode 100644 index 00000000..4bb68027 --- /dev/null +++ b/team_b/yappy/lib/offline_model.dart @@ -0,0 +1,27 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; + +// Remember to change `assets` in ../pubspec.yaml +// and download files to ../assets +Future getOfflineModelConfig( + {required int type}) async { + final appDir = await getApplicationCacheDirectory(); + final modelDir = Directory('${appDir.path}/models'); + switch (type) { + case 0: + final fullDir = '${modelDir.path}/sherpa-onnx-whisper-tiny.en'; + return sherpa_onnx.OfflineModelConfig( + whisper: sherpa_onnx.OfflineWhisperModelConfig( + encoder: '$fullDir/tiny.en-encoder.int8.onnx', + decoder: '$fullDir/tiny.en-decoder.int8.onnx', + ), + tokens: '$fullDir/tiny.en-tokens.txt', + modelType: 'whisper', + debug: false, + numThreads: 1 + ); + default: + throw ArgumentError('Unsupported type: $type'); + } +} diff --git a/team_b/yappy/lib/online_model.dart b/team_b/yappy/lib/online_model.dart new file mode 100644 index 00000000..1e2d7332 --- /dev/null +++ b/team_b/yappy/lib/online_model.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; + +Future getOnlineModelConfig( + {required int type}) async { + final appDir = await getApplicationCacheDirectory(); + final modelDir = Directory('${appDir.path}/models'); + switch (type) { + case 4: + final fullDir = '${modelDir.path}/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17-mobile'; + return sherpa_onnx.OnlineModelConfig( + transducer: sherpa_onnx.OnlineTransducerModelConfig( + encoder: '$fullDir/encoder-epoch-99-avg-1.int8.onnx', + decoder: '$fullDir/decoder-epoch-99-avg-1.onnx', + joiner: '$fullDir/joiner-epoch-99-avg-1.int8.onnx', + ), + tokens: '$fullDir/tokens.txt', + modelType: 'zipformer', + ); + default: + throw ArgumentError('Unsupported type: $type'); + } +} diff --git a/team_b/yappy/lib/restaurant.dart b/team_b/yappy/lib/restaurant.dart index 994eed5c..4ab65b18 100644 --- a/team_b/yappy/lib/restaurant.dart +++ b/team_b/yappy/lib/restaurant.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:yappy/tool_bar.dart'; import 'package:yappy/industry_menu.dart'; import 'package:yappy/transcription_box.dart'; +import 'package:yappy/speech_state.dart'; +import 'services/model_manager.dart'; class RestaurantApp extends StatelessWidget { const RestaurantApp({super.key}); @@ -16,7 +18,9 @@ class RestaurantApp extends StatelessWidget { //Creates a page for the Restaurant industry //The page will contain the industry menu and the transcription box class RestaurantPage extends StatelessWidget { - const RestaurantPage({super.key}); + RestaurantPage({super.key}); + final speechState = SpeechState(); + final modelManager = ModelManager(); @override Widget build(BuildContext context) { @@ -27,16 +31,28 @@ class RestaurantPage extends StatelessWidget { child: ToolBar() ), drawer: HamburgerDrawer(), - body: Column( - children: [ - IndustryMenu(title: "Restaurant", icon: Icons.restaurant_menu), - Expanded( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: TranscriptionBox(), - ), - ), - ], + body: ListenableBuilder( + listenable: speechState, + builder: (context, child) { + return Column( + children: [ + IndustryMenu( + title: "Restaurant", + icon: Icons.restaurant_menu, + speechState: speechState, + modelManager: modelManager, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TranscriptionBox( + controller: speechState.controller, + ), + ), + ), + ], + ); + } ), ); } diff --git a/team_b/yappy/lib/services/model_manager.dart b/team_b/yappy/lib/services/model_manager.dart new file mode 100644 index 00000000..5a2eb760 --- /dev/null +++ b/team_b/yappy/lib/services/model_manager.dart @@ -0,0 +1,397 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:http/http.dart' as http; +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as path; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'toast_service.dart'; + +class ModelManager { + // Base directory for storing models + late final Future _modelDirPath; + + // Model information with URLs and extraction details + final List _models = [ + ModelInfo( + name: 'Speaker Recognition Model', + url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx', + isCompressed: false, + size: 25.3, // Size in MB + ), + ModelInfo( + name: 'Offline Whisper Model (Tiny)', + url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.en.tar.bz2', + isCompressed: true, + keepPaths: ['sherpa-onnx-whisper-tiny.en/tiny.en-tokens.txt', 'sherpa-onnx-whisper-tiny.en/tiny.en-decoder.int8.onnx', 'sherpa-onnx-whisper-tiny.en/tiny.en-encoder.int8.onnx'], + size: 113, // Size in MB + ), + ModelInfo( + name: 'Online Zipformer Model', + url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17-mobile.tar.bz2', + isCompressed: true, + keepPaths: ['sherpa-onnx-streaming-zipformer-en-20M-2023-02-17-mobile/tokens.txt', 'sherpa-onnx-streaming-zipformer-en-20M-2023-02-17-mobile/decoder-epoch-99-avg-1.onnx', 'sherpa-onnx-streaming-zipformer-en-20M-2023-02-17-mobile/encoder-epoch-99-avg-1.int8.onnx', 'sherpa-onnx-streaming-zipformer-en-20M-2023-02-17-mobile/joiner-epoch-99-avg-1.int8.onnx'], + size: 103, // Size in MB + ), + ]; + + // Toast service + final _toastService = ToastService(); + + ModelManager() { + _modelDirPath = _initModelDir(); + } + + // Initialize the models directory + Future _initModelDir() async { + final appDir = await getApplicationCacheDirectory(); + final modelDir = Directory('${appDir.path}/models'); + if (!await modelDir.exists()) { + await modelDir.create(recursive: true); + } + return modelDir.path; + } + + // Check if all required models exist + Future modelsExist() async { + try { + final modelDir = await _modelDirPath; + + // Check for a marker file indicating successful model installation + final markerFile = File('$modelDir/models_installed.txt'); + if (await markerFile.exists()) { + return true; + } + + // Check individual models if marker doesn't exist + for (var model in _models) { + final modelFile = File('$modelDir/${model.getFilename()}'); + if (!await modelFile.exists()) { + return false; + } + } + + // If all models exist but marker doesn't, create the marker + await markerFile.writeAsString('Models installed on ${DateTime.now()}'); + return true; + } catch (e) { + debugPrint('Error checking models: $e'); + return false; + } + } + + // Check if we should only download on Wi-Fi + Future _getWifiOnlySetting() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool('wifi_only_downloads') ?? true; // Default to true + } + + // Save Wi-Fi only setting + Future saveWifiOnlySetting(bool wifiOnly) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('wifi_only_downloads', wifiOnly); + } + + // Check network connectivity + Future _checkConnectivity() async { + final wifiOnly = await _getWifiOnlySetting(); + + if (!wifiOnly) { + return true; // Download allowed on any connection + } + + // Check for WiFi connection + final connectivityResult = await Connectivity().checkConnectivity(); + return connectivityResult.contains(ConnectivityResult.wifi); + } + + // Show download dialog to user + Future showDownloadDialog(BuildContext context) async { + // Calculate total download size + final totalSize = _models.fold(0.0, (sum, model) => sum + model.size); + + return await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Download AI Models'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'This app requires downloading speech recognition models ' + 'to function properly (${totalSize.toStringAsFixed(1)} MB total).', + ), + const SizedBox(height: 12), + const Text( + 'Models will be downloaded when connected to WiFi. ' + 'You can change this in Settings later.', + ), + const SizedBox(height: 16), + const Text('Models to download:'), + ...List.generate(_models.length, (index) { + final model = _models[index]; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '• ${model.name} (${model.size.toStringAsFixed(1)} MB)', + style: const TextStyle(fontSize: 14), + ), + ); + }), + ], + ), + ), + actions: [ + TextButton( + child: const Text('Later'), + onPressed: () { + Navigator.of(dialogContext).pop(false); + }, + ), + TextButton( + child: const Text('Download Now'), + onPressed: () { + Navigator.of(dialogContext).pop(true); + }, + ), + ], + ); + }, + ) ?? false; // Default to false if dialog is dismissed + } + + // Show connectivity warning dialog + Future _showConnectivityWarning(BuildContext context) async { + return await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Wi-Fi Required'), + content: const Text( + 'You have selected to download models only on Wi-Fi. ' + 'Please connect to a Wi-Fi network or change your settings to continue.' + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(dialogContext).pop(false); + }, + ), + TextButton( + child: const Text('Change Setting'), + onPressed: () { + Navigator.of(dialogContext).pop(true); + }, + ), + ], + ); + }, + ) ?? false; // Default to false if dialog is dismissed + } + + // Check if downloads are currently in progress + bool isDownloadInProgress() { + return _toastService.isToastVisible; + } + + // Download all models + Future downloadModels(BuildContext context) async { + // Check connectivity first - still need context for initial dialogs + final canDownload = await _checkConnectivity(); + if (!canDownload) { + final changeSettings = await _showConnectivityWarning(context); + if (changeSettings) { + // User wants to change settings + await saveWifiOnlySetting(false); + } else { + // User canceled download + return false; + } + } + + // Start async download process + _startDownloadProcess(); + + // Return true to indicate the download has started + return true; + } + + // Handle the download process independently of any specific context + Future _startDownloadProcess() async { + try { + // Show initial toast notification + _toastService.showToast('Starting downloads...', progress: 0.0); + + final modelDir = await _modelDirPath; + int completedModels = 0; + + // Process each model + for (var model in _models) { + // Update progress + _toastService.showToast( + 'Downloading ${model.name}...', + progress: completedModels / _models.length, + ); + + // Download file + final response = await http.get(Uri.parse(model.url)); + if (response.statusCode != 200) { + throw Exception('Failed to download ${model.name}'); + } + + // Process based on file type + if (model.isCompressed) { + // Update progress + _toastService.showToast( + 'Extracting ${model.name}...', + progress: completedModels / _models.length, + ); + + // Handle compressed .bz2 file + await _processCompressedFile( + response.bodyBytes, + modelDir, + model.keepPaths ?? [], + ); + } else { + // Handle direct .onnx file + final file = File('$modelDir/${model.getFilename()}'); + await file.writeAsBytes(response.bodyBytes); + } + + completedModels++; + _toastService.updateProgress(completedModels / _models.length); + } + + // Create marker file to indicate successful installation + final markerFile = File('$modelDir/models_installed.txt'); + await markerFile.writeAsString('Models installed on ${DateTime.now()}'); + + // Hide toast and show success toast + _toastService.hideToast(); + _toastService.showSuccess('All models have been downloaded successfully.'); + + } catch (e) { + debugPrint('Error downloading models: $e'); + + // Hide progress toast + _toastService.hideToast(); + + // Show error toast + _toastService.showError('Failed to download models: $e'); + } + } + + // Process compressed .bz2 file + Future _processCompressedFile( + List fileBytes, + String modelDir, + List keepPaths, + ) async { + // Decompress bz2 + final archive = BZip2Decoder().decodeBytes(fileBytes); + + // Extract tar archive + final tarArchive = TarDecoder().decodeBytes(archive); + + // Process each file in the archive + for (final file in tarArchive) { + // Check if this file/directory should be kept + bool shouldKeep = keepPaths.isEmpty; + for (final keepPath in keepPaths) { + if (file.name.startsWith(keepPath)) { + shouldKeep = true; + break; + } + } + + if (shouldKeep) { + final filePath = '$modelDir/${file.name}'; + + if (file.isFile) { + // Create parent directories if needed + final parentDir = Directory(path.dirname(filePath)); + if (!await parentDir.exists()) { + await parentDir.create(recursive: true); + } + + // Write file + await File(filePath).writeAsBytes(file.content as List); + } else { + // Create directory + await Directory(filePath).create(recursive: true); + } + } + } + } + + // Delete all downloaded models + Future deleteModels() async { + try { + final modelDir = await _modelDirPath; + final directory = Directory(modelDir); + + if (await directory.exists()) { + // Delete all contents of the directory + await for (var entity in directory.list(recursive: true)) { + if (entity is File) { + await entity.delete(); + } else if (entity is Directory) { + // Only delete subdirectories, not the base model directory + if (entity.path != modelDir) { + await entity.delete(recursive: true); + } + } + } + } + + // Delete the installation marker file + final markerFile = File('$modelDir/models_installed.txt'); + if (await markerFile.exists()) { + await markerFile.delete(); + } + + return true; + } catch (e) { + debugPrint('Error deleting models: $e'); + return false; + } + } + + // Trigger model download from settings page + Future downloadModelsFromSettings(BuildContext context) async { + final shouldDownload = await showDownloadDialog(context); + if (shouldDownload) { + return await downloadModels(context); + } + return false; + } +} + +class ModelInfo { + final String name; + final String url; + final bool isCompressed; + final List? keepPaths; + final double size; // Size in MB + + ModelInfo({ + required this.name, + required this.url, + required this.isCompressed, + this.keepPaths, + required this.size, + }); + + String getFilename() { + return path.basename(url); + } +} \ No newline at end of file diff --git a/team_b/yappy/lib/services/toast_service.dart b/team_b/yappy/lib/services/toast_service.dart new file mode 100644 index 00000000..489e52e7 --- /dev/null +++ b/team_b/yappy/lib/services/toast_service.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +/// A service that manages toast notifications through stream controllers +class ToastService { + // Singleton pattern + static final ToastService _instance = ToastService._internal(); + factory ToastService() => _instance; + ToastService._internal(); + + // Stream controllers + final _messageController = StreamController.broadcast(); + final _progressController = StreamController.broadcast(); + final _visibilityController = StreamController.broadcast(); + final _successController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + + // Track toast visibility + bool _isToastVisible = false; + + // Stream getters + Stream get messageStream => _messageController.stream; + Stream get progressStream => _progressController.stream; + Stream get visibilityStream => _visibilityController.stream; + Stream get successStream => _successController.stream; + Stream get errorStream => _errorController.stream; + + // Check if toast is visible (i.e., download in progress) + bool get isToastVisible => _isToastVisible; + + // Methods to control the toast + void showToast(String message, {double progress = 0.0}) { + _messageController.add(message); + _progressController.add(progress); + _visibilityController.add(true); + _isToastVisible = true; + } + + void updateProgress(double progress) { + _progressController.add(progress); + } + + void updateMessage(String message) { + _messageController.add(message); + } + + void hideToast() { + _visibilityController.add(false); + _isToastVisible = false; + } + + void showSuccess(String message) { + _successController.add(message); + } + + void showError(String message) { + _errorController.add(message); + } + + // Clean up resources + void dispose() { + _messageController.close(); + _progressController.close(); + _visibilityController.close(); + _successController.close(); + _errorController.close(); + } +} \ No newline at end of file diff --git a/team_b/yappy/lib/settings_page.dart b/team_b/yappy/lib/settings_page.dart index 2975f409..00987191 100644 --- a/team_b/yappy/lib/settings_page.dart +++ b/team_b/yappy/lib/settings_page.dart @@ -1,29 +1,75 @@ import 'package:flutter/material.dart'; +import './services/model_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + bool _modelsDownloaded = false; + bool _wifiOnlyDownloads = true; + bool _isLoading = true; + final ModelManager _modelManager = ModelManager(); + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + try { + // Check models status + final exist = await _modelManager.modelsExist(); + + // Load Wi-Fi only setting + final prefs = await SharedPreferences.getInstance(); + final wifiOnly = prefs.getBool('wifi_only_downloads') ?? true; + + if (mounted) { + setState(() { + _modelsDownloaded = exist; + _wifiOnlyDownloads = wifiOnly; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Settings'), ), - body: ListView( + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( children: [ + // Original settings items ListTile( - leading: Icon(Icons.account_circle), - title: Text('Account'), + leading: const Icon(Icons.account_circle), + title: const Text('Account'), onTap: () { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( - title: Text('Version'), - content: Text('Version 1.0.0'), + title: const Text('Version'), + content: const Text('Version 1.0.0'), actions: [ TextButton( - child: Text('OK'), + child: const Text('OK'), onPressed: () { Navigator.of(context).pop(); }, @@ -35,43 +81,167 @@ class SettingsPage extends StatelessWidget { }, ), ListTile( - leading: Icon(Icons.notifications), - title: Text('API Keys'), + leading: const Icon(Icons.key), + title: const Text('API Keys'), onTap: () { showDialog( - context: context, - builder: (BuildContext context) { - TextEditingController apiKeyController = TextEditingController(); - return AlertDialog( - title: Text('Enter API Key'), - content: TextField( - controller: apiKeyController, - decoration: InputDecoration(hintText: "API Key"), - ), - actions: [ - TextButton( - child: Text('Cancel'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: Text('Save'), - onPressed: () { - // Save the API key to env.dart file - // String apiKey = apiKeyController.text; - // Add your logic to save the API key here - Navigator.of(context).pop(); - }, - ), - ], - ); - }, + context: context, + builder: (BuildContext context) { + TextEditingController apiKeyController = TextEditingController(); + return AlertDialog( + title: const Text('Enter API Key'), + content: TextField( + controller: apiKeyController, + decoration: const InputDecoration(hintText: "API Key"), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Save'), + onPressed: () { + // Save the API key to env.dart file + // String apiKey = apiKeyController.text; + // Add your logic to save the API key here + Navigator.of(context).pop(); + }, + ), + ], + ); + }, ); }, ), + + // Divider to separate original and new settings + const Divider(), + + // New model management settings + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'AI Speech Models', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + + ListTile( + leading: const Icon(Icons.cloud_download), + title: const Text('Speech Recognition Models'), + subtitle: Text(_modelsDownloaded + ? 'Downloaded and ready to use' + : 'Not downloaded'), + trailing: _modelsDownloaded + ? const Icon(Icons.check_circle, color: Colors.green) + : ElevatedButton( + onPressed: () => _handleModelDownload(context), + child: const Text('Download'), + ), + ), + + SwitchListTile( + secondary: const Icon(Icons.wifi), + title: const Text('Download on Wi-Fi only'), + subtitle: const Text( + 'When enabled, models will only download when connected to Wi-Fi'), + value: _wifiOnlyDownloads, + onChanged: (value) async { + setState(() { + _wifiOnlyDownloads = value; + }); + // Save preference to shared preferences + await _modelManager.saveWifiOnlySetting(value); + }, + ), + + if (_modelsDownloaded) + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Delete Models'), + subtitle: const Text('Free up space by removing downloaded models'), + onTap: () => _handleDeleteModels(context), + ), ], ), ); } + + Future _handleModelDownload(BuildContext context) async { + final result = await _modelManager.downloadModelsFromSettings(context); + + if (!mounted) return; + + // Refresh the model status + final exist = await _modelManager.modelsExist(); + + if (!mounted) return; + + setState(() { + _modelsDownloaded = exist; + }); + } + + Future _handleDeleteModels(BuildContext context) async { + final shouldDelete = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete Models?'), + content: const Text( + 'This will delete all downloaded speech models. ' + 'You will need to download them again to use the app\'s ' + 'speech recognition features.' + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? false; + + if (!shouldDelete || !mounted) return; + + final success = await _modelManager.deleteModels(); + + if (!mounted) return; + + setState(() { + _modelsDownloaded = !success; + }); + + _showResultSnackBar(success); + } + + // Separate method for showing the snackbar to avoid context across async gap + void _showResultSnackBar(bool success) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Models deleted successfully'), + duration: Duration(seconds: 2), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to delete models'), + backgroundColor: Colors.red, + duration: Duration(seconds: 2), + ), + ); + } + } } \ No newline at end of file diff --git a/team_b/yappy/lib/speaker_model.dart b/team_b/yappy/lib/speaker_model.dart new file mode 100644 index 00000000..5cb8e38a --- /dev/null +++ b/team_b/yappy/lib/speaker_model.dart @@ -0,0 +1,14 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +Future getSpeakerModel( + {required int type}) async { + final appDir = await getApplicationCacheDirectory(); + final modelDir = Directory('${appDir.path}/models'); + switch (type) { + case 0: + return '${modelDir.path}/3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'; + default: + throw ArgumentError('Unsupported type: $type'); + } + } \ No newline at end of file diff --git a/team_b/yappy/lib/speech_state.dart b/team_b/yappy/lib/speech_state.dart new file mode 100644 index 00000000..b90f2165 --- /dev/null +++ b/team_b/yappy/lib/speech_state.dart @@ -0,0 +1,705 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:record/record.dart'; + +import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; + +import './utils.dart'; +import './online_model.dart'; +import './offline_model.dart'; +import './speaker_model.dart'; + +Future createOnlineRecognizer() async { + final type = 4; + + final modelConfig = await getOnlineModelConfig(type: type); + final config = sherpa_onnx.OnlineRecognizerConfig( + model: modelConfig, + ruleFsts: '', + ); + + return sherpa_onnx.OnlineRecognizer(config); +} + +Future createOfflineRecognizer() async { + final type = 0; + + final modelConfig = await getOfflineModelConfig(type: type); + final config = sherpa_onnx.OfflineRecognizerConfig( + model: modelConfig + ); + + return sherpa_onnx.OfflineRecognizer(config); +} + +Future createSpeakerExtractor() async { + final type = 0; + + final model = await getSpeakerModel(type: type); + final config = sherpa_onnx.SpeakerEmbeddingExtractorConfig( + model: model, + numThreads: 2, + debug: false, + provider: 'cpu', + ); + + return sherpa_onnx.SpeakerEmbeddingExtractor(config: config); +} + +class Conversation { + final List segments; + final String audioFilePath; + + Conversation({ + required this.segments, + required this.audioFilePath, + }); + + // Convert to JSON for persistence + Map toJson() => { + 'segments': segments.map((s) => s.toJson()).toList(), + 'audioFilePath': audioFilePath, + }; + + factory Conversation.fromJson(Map json) => Conversation( + segments: (json['segments'] as List) + .map((s) => RecognizedSegment.fromJson(s)) + .toList(), + audioFilePath: json['audioFilePath'], + ); + + // Generate a transcript from the conversation + String getTranscript({bool includeSpeakerTags = true}) { + final buffer = StringBuffer(); + RecognizedSegment? lastSegment; + + for (final segment in segments) { + if (segment.text.isEmpty) continue; + + if (buffer.isNotEmpty) { + // Add a newline if the speaker changes or if this is a new thought + if (lastSegment == null || + lastSegment.speakerId != segment.speakerId) { + buffer.write('\n\n'); + } else { + buffer.write(' '); + } + } + + // Add speaker tag if requested and available + if (includeSpeakerTags && segment.speakerId != null) { + buffer.write('${segment.speakerId}: '); + } + + buffer.write(segment.text); + lastSegment = segment; + } + + return buffer.toString(); + } +} + +class RecognizedSegment { + final int index; + String text; + String? speakerId; + bool isProcessed; + double start; + double end; + Float32List? speakerEmbedding; + + RecognizedSegment({ + required this.index, + required this.text, + this.speakerId, + this.isProcessed = false, + required this.start, + required this.end, + this.speakerEmbedding, + }); + + // Add a method to convert to/from JSON for persistence + Map toJson() => { + 'index': index, + 'text': text, + 'speakerId': speakerId, + 'isProcessed': isProcessed, + 'start': start, + 'end': end, + }; + + factory RecognizedSegment.fromJson(Map json) => RecognizedSegment( + index: json['index'], + text: json['text'], + speakerId: json['speakerId'], + isProcessed: json['isProcessed'], + start: json['start'], + end: json['end'], + ); +} + +class AudioSegment { + final Float32List samples; + final int sampleRate; + final int index; + final double start; + final double end; + + AudioSegment({ + required this.samples, + required this.sampleRate, + required this.index, + required this.start, + required this.end, + }); +} + +class SpeechState extends ChangeNotifier { + final TextEditingController controller = TextEditingController(); + final AudioRecorder audioRecorder = AudioRecorder(); + + RecordState recordState = RecordState.stop; + bool isInitialized = false; + int currentIndex = 0; + double currentTimestamp = 0.0; + int currentSpeakerCount = 0; + + // Store all recognized segments + final List recognizedSegments = []; + + // First pass - streaming recognition + sherpa_onnx.OnlineRecognizer? onlineRecognizer; + sherpa_onnx.OnlineStream? onlineStream; + + // Second pass - offline recognition + sherpa_onnx.OfflineRecognizer? offlineRecognizer; + + // Speaker identification + sherpa_onnx.SpeakerEmbeddingExtractor? speakerExtractor; + sherpa_onnx.SpeakerEmbeddingManager? speakerManager; + + List allAudioSamples = []; + // Buffer for collecting samples between endpoints + List currentSegmentSamples = []; + final int sampleRate = 16000; + + // Store segments that need offline processing + List pendingSegments = []; + bool isProcessingOffline = false; + + // Path to save the complete audio recording + String? recordingFilePath; + + // Create a Conversation object when recording is stopped + Conversation? lastConversation; + + Future initialize() async { + if (!isInitialized) { + try { + // init online recognizer + sherpa_onnx.initBindings(); + onlineRecognizer = await createOnlineRecognizer(); + onlineStream = onlineRecognizer?.createStream(); + + // init offline recognizer + offlineRecognizer = await createOfflineRecognizer(); + + // init speaker identification components + speakerExtractor = await createSpeakerExtractor(); + speakerManager = sherpa_onnx.SpeakerEmbeddingManager(speakerExtractor!.dim); + + isInitialized = true; + notifyListeners(); + } catch (e) { + debugPrint('Sherpa initialization failed: $e'); + } + } + } + + // Helper method to update the displayed text + void _updateDisplayText() { + final buffer = StringBuffer(); + + // Debug log + // debugPrint('Updating display text with ${recognizedSegments.length} segments'); + + for (final segment in recognizedSegments) { + // Debug log + // debugPrint('Segment ${segment.index}: "${segment.text}" (Speaker: ${segment.speakerId ?? "Unknown"})'); + + if (segment.text.isNotEmpty) { + if (buffer.isNotEmpty) { + buffer.write('\n'); + } + final prefix = segment.speakerId ?? 'Speaker Unknown'; + buffer.write('$prefix: ${segment.text}'); + } + } + + // final displayText = buffer.toString(); + // debugPrint('Setting display text: $displayText'); + + controller.value = TextEditingValue( + text: buffer.toString(), + selection: TextSelection.collapsed(offset: buffer.length), + ); + + notifyListeners(); // Make sure UI gets updated + } + + // Add a new segment of recognized text + void _addRecognizedSegment(String text, double start) { + recognizedSegments.add(RecognizedSegment( + index: currentIndex, + text: text, + start: start, + end: start, // Will be updated when segment ends + )); + + debugPrint('Added new segment $currentIndex: "$text" (${start.toStringAsFixed(2)}s - ${start.toStringAsFixed(2)}s)'); + } + + // Update an existing segment with improved recognition + void _updateRecognizedSegment(int index, String newText, {String? speakerId, Float32List? embedding}) { + final segmentIndex = recognizedSegments.indexWhere((s) => s.index == index); + if (segmentIndex != -1) { + if (newText.trim().isNotEmpty) { + recognizedSegments[segmentIndex].text = newText; + } + recognizedSegments[segmentIndex].isProcessed = true; + + if (speakerId != null) { + recognizedSegments[segmentIndex].speakerId = speakerId; + } + + if (embedding != null) { + recognizedSegments[segmentIndex].speakerEmbedding = embedding; + } + + // _updateDisplayText(); + } + } + + Future processSegmentOffline(AudioSegment segment) async { + debugPrint('Processing segment ${segment.index} offline (${segment.samples.length} samples)'); + + if (segment.samples.isEmpty) { + debugPrint('Empty samples for segment ${segment.index}, skipping'); + return; + } + + try { + // Perform offline speech recognition + final offlineStream = offlineRecognizer!.createStream(); + + debugPrint('Running offline recognition for segment ${segment.index}'); + offlineStream.acceptWaveform( + samples: segment.samples, + sampleRate: segment.sampleRate + ); + + offlineRecognizer!.decode(offlineStream); + final result = offlineRecognizer!.getResult(offlineStream); + + debugPrint('Offline recognition result for segment ${segment.index}: "${result.text}"'); + + // Perform speaker identification + final speakerStream = speakerExtractor!.createStream(); + + speakerStream.acceptWaveform( + samples: segment.samples, + sampleRate: segment.sampleRate, + ); + + speakerStream.inputFinished(); + + final embedding = speakerExtractor!.compute(speakerStream); + + // Search for matching speaker + final threshold = 0.6; // Adjust threshold as needed + var speakerId = speakerManager!.search(embedding: embedding, threshold: threshold); + + // If no match, register a new speaker + if (speakerId.isEmpty) { + currentSpeakerCount++; + speakerId = 'Speaker $currentSpeakerCount'; + debugPrint('New speaker detected: $speakerId for segment ${segment.index}'); + speakerManager!.add(name: speakerId, embedding: embedding); + } else { + debugPrint('Matched existing speaker: $speakerId for segment ${segment.index}'); + } + + // Ignore empty results + if (result.text.trim().isNotEmpty) { + // Update the recognized segment with both improved text and speaker ID + _updateRecognizedSegment( + segment.index, + result.text, + speakerId: speakerId, + embedding: embedding, + ); + } + + // Free resources + offlineStream.free(); + speakerStream.free(); + } catch (e) { + debugPrint('Error processing segment ${segment.index} offline: $e'); + } + } + + Future processPendingSegments() async { + if (pendingSegments.isEmpty || isProcessingOffline) { + debugPrint('No pending segments to process or already processing'); + return; + } + + debugPrint('Processing ${pendingSegments.length} pending segments'); + isProcessingOffline = true; + + try { + // Create a local copy to process to avoid modification during iteration + final segmentsToProcess = List.from(pendingSegments); + pendingSegments.clear(); + + for (final segment in segmentsToProcess) { + debugPrint('Processing pending segment ${segment.index}'); + await processSegmentOffline(segment); + // Update display after each segment is processed + _updateDisplayText(); + } + } catch (e) { + debugPrint('Error processing pending segments: $e'); + } finally { + isProcessingOffline = false; + debugPrint('Finished processing pending segments'); + } + } + + Future toggleRecording() async { + if (recordState == RecordState.stop) { + controller.value = TextEditingValue( + text: "Initializing Yappy!, just a moment..." + ); + await startRecording(); + } else { + await stopRecording(); + } + } + + Future _createRecordingFilePath() async { + // final dir = await getApplicationDocumentsDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + // return '${dir.path}/recording_$timestamp.wav'; + return '/storage/emulated/0/Documents/recording_$timestamp.wav'; + } + + Future startRecording() async { + if (!isInitialized) { + await initialize(); + } + + try { + if (await audioRecorder.hasPermission()) { + // Reset speakers for new recording + currentSpeakerCount = 0; + recognizedSegments.clear(); + + // Create a path for saving the recording + recordingFilePath = await _createRecordingFilePath(); + + const config = RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: 16000, + numChannels: 1, + ); + + final recordStream = await audioRecorder.startStream(config); + currentSegmentSamples.clear(); + allAudioSamples.clear(); + currentTimestamp = 0.0; + currentIndex = 0; + + recordState = RecordState.record; + controller.value = TextEditingValue( + text: "Listening..." + ); + notifyListeners(); + + recordStream.listen( + (data) { + final samplesFloat32 = convertBytesToFloat32(Uint8List.fromList(data)); + + // Add samples to current segment buffer + currentSegmentSamples.add(samplesFloat32); + allAudioSamples.add(samplesFloat32); + + // Update current timestamp based on number of samples + currentTimestamp += samplesFloat32.length / sampleRate; + + onlineStream!.acceptWaveform( + samples: samplesFloat32, + sampleRate: sampleRate + ); + + while (onlineRecognizer!.isReady(onlineStream!)) { + onlineRecognizer!.decode(onlineStream!); + } + + final text = onlineRecognizer!.getResult(onlineStream!).text; + + if (text.isNotEmpty) { + // Update or add the current segment + final existingSegmentIndex = recognizedSegments.indexWhere((s) => s.index == currentIndex); + + if (existingSegmentIndex != -1) { + // Update existing segment + recognizedSegments[existingSegmentIndex].text = text; + // debugPrint('Updated segment $currentIndex with text: "$text"'); + } else { + // Add new segment + debugPrint('Adding new segment $currentIndex with text: "$text"'); + _addRecognizedSegment(text, currentTimestamp); + } + + // Always update display when we have new text + _updateDisplayText(); + } + + if (onlineRecognizer!.isEndpoint(onlineStream!)) { + // Store the current segment for offline processing + + //ISSUE IS HERE, need to use recognizedSegments.LastOrNull like before, or integrate VAD + if (currentSegmentSamples.isNotEmpty && recognizedSegments.lastOrNull != null + ) { + // Combine all Float32Lists into a single one + final combinedSamples = Float32List(currentSegmentSamples.fold( + 0, (sum, list) => sum + list.length)); + var offset = 0; + for (var samples in currentSegmentSamples) { + combinedSamples.setRange(offset, offset + samples.length, samples); + offset += samples.length; + } + + final segmentStart = recognizedSegments.lastOrNull?.start ?? 0.0; + + pendingSegments.add(AudioSegment( + samples: combinedSamples, + sampleRate: sampleRate, + index: currentIndex, + start: segmentStart, + end: currentTimestamp, + )); + + // Process with online recognizer in the background + processPendingSegments(); + } + + // Reset for next segment + onlineRecognizer!.reset(onlineStream!); + currentSegmentSamples.clear(); + currentIndex += 1; + } + }, + onError: (error) { + debugPrint('Error from audio stream: $error'); + }, + onDone: () { + debugPrint('Audio stream done'); + }, + ); + } + } catch (e) { + debugPrint('Error starting recording: $e'); + } + } + + Future saveWavFile() async { + if (allAudioSamples.isEmpty || recordingFilePath == null) return; + + try { + // Combine all audio samples + final totalSamples = allAudioSamples.fold(0, (sum, list) => sum + list.length); + final combinedSamples = Float32List(totalSamples); + + var offset = 0; + for (var samples in allAudioSamples) { + combinedSamples.setRange(offset, offset + samples.length, samples); + offset += samples.length; + } + + // Convert Float32List to Int16List for WAV format + final int16Samples = Int16List(combinedSamples.length); + for (var i = 0; i < combinedSamples.length; i++) { + // Scale and clamp to Int16 range + final scaledSample = combinedSamples[i] * 32767; + int16Samples[i] = scaledSample.clamp(-32768, 32767).toInt(); + } + + // Create WAV file + final file = File(recordingFilePath!); + final sink = file.openWrite(); + + // WAV header (44 bytes) + final header = ByteData(44); + + // RIFF chunk descriptor + header.setUint8(0, 0x52); // 'R' + header.setUint8(1, 0x49); // 'I' + header.setUint8(2, 0x46); // 'F' + header.setUint8(3, 0x46); // 'F' + + // Chunk size (file size - 8) + final dataSize = int16Samples.length * 2; // 2 bytes per sample + final fileSize = 36 + dataSize; + header.setUint32(4, fileSize, Endian.little); + + // Format ('WAVE') + header.setUint8(8, 0x57); // 'W' + header.setUint8(9, 0x41); // 'A' + header.setUint8(10, 0x56); // 'V' + header.setUint8(11, 0x45); // 'E' + + // 'fmt ' subchunk + header.setUint8(12, 0x66); // 'f' + header.setUint8(13, 0x6D); // 'm' + header.setUint8(14, 0x74); // 't' + header.setUint8(15, 0x20); // ' ' + + // Subchunk1 size (16 for PCM) + header.setUint32(16, 16, Endian.little); + + // Audio format (1 for PCM) + header.setUint16(20, 1, Endian.little); + + // Number of channels (1 for mono) + header.setUint16(22, 1, Endian.little); + + // Sample rate + header.setUint32(24, sampleRate, Endian.little); + + // Byte rate (SampleRate * NumChannels * BitsPerSample/8) + header.setUint32(28, sampleRate * 1 * 16 ~/ 8, Endian.little); + + // Block align (NumChannels * BitsPerSample/8) + header.setUint16(32, 1 * 16 ~/ 8, Endian.little); + + // Bits per sample + header.setUint16(34, 16, Endian.little); + + // 'data' subchunk + header.setUint8(36, 0x64); // 'd' + header.setUint8(37, 0x61); // 'a' + header.setUint8(38, 0x74); // 't' + header.setUint8(39, 0x61); // 'a' + + // Subchunk2 size (data size) + header.setUint32(40, dataSize, Endian.little); + + // Write header + sink.add(header.buffer.asUint8List()); + + // Write data + final dataBytes = int16Samples.buffer.asUint8List(); + sink.add(dataBytes); + + await sink.close(); + + debugPrint('WAV file saved to: $recordingFilePath'); + } catch (e) { + debugPrint('Error saving WAV file: $e'); + } + } + + Future createConversation() async { + // Ensure WAV file is saved + await saveWavFile(); + + // Create conversation object + return Conversation( + segments: List.from(recognizedSegments), // Make a copy + audioFilePath: recordingFilePath ?? '', + ); + } + + Future stopRecording() async { + debugPrint('Stopping recording'); + + try { + // Update UI immediately to show we're stopping + recordState = RecordState.stop; + notifyListeners(); + + // Process any remaining audio + if (currentSegmentSamples.isNotEmpty) { + debugPrint('Processing final segment $currentIndex'); + + // Combine all Float32Lists into a single one + final combinedSamples = Float32List(currentSegmentSamples.fold( + 0, (sum, list) => sum + list.length)); + var offset = 0; + for (var samples in currentSegmentSamples) { + combinedSamples.setRange(offset, offset + samples.length, samples); + offset += samples.length; + } + + final segmentStart = recognizedSegments.lastOrNull?.start ?? 0.0; + + pendingSegments.add(AudioSegment( + samples: combinedSamples, + sampleRate: sampleRate, + index: currentIndex, + start: segmentStart, + end: currentTimestamp, + )); + } + + // Clean up resources + if (onlineStream != null) { + onlineStream!.free(); + onlineStream = onlineRecognizer?.createStream(); + } + + await audioRecorder.stop(); + + // Process final segments + debugPrint('Processing final segments with offline recognizer'); + await processPendingSegments(); + + // Save the recording as a WAV file + debugPrint('Saving WAV file'); + await saveWavFile(); + + // Create conversation object + debugPrint('Creating conversation object'); + lastConversation = await createConversation(); + + currentSegmentSamples.clear(); + // allAudioSamples.clear(); + // Final update to display text + _updateDisplayText(); + + debugPrint('Recording stopped successfully'); + } catch (e) { + debugPrint('Error stopping recording: $e'); + } finally { + notifyListeners(); + } + } + + @override + void dispose() { + controller.dispose(); + audioRecorder.dispose(); + onlineStream?.free(); + onlineRecognizer?.free(); + offlineRecognizer?.free(); + speakerExtractor?.free(); + speakerManager?.free(); + super.dispose(); + } +} diff --git a/team_b/yappy/lib/streaming_asr.dart b/team_b/yappy/lib/streaming_asr.dart new file mode 100644 index 00000000..f14d300c --- /dev/null +++ b/team_b/yappy/lib/streaming_asr.dart @@ -0,0 +1,243 @@ +// Copyright (c) 2024 Xiaomi Corporation +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:record/record.dart'; + +import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; + +import './utils.dart'; +import './online_model.dart'; + +Future createOnlineRecognizer() async { + final type = 4; + + final modelConfig = await getOnlineModelConfig(type: type); + final config = sherpa_onnx.OnlineRecognizerConfig( + model: modelConfig, + ruleFsts: '', + ); + + return sherpa_onnx.OnlineRecognizer(config); +} + +class StreamingAsrScreen extends StatefulWidget { + const StreamingAsrScreen({super.key}); + + @override + State createState() => _StreamingAsrScreenState(); +} + +class _StreamingAsrScreenState extends State { + late final TextEditingController _controller; + late final AudioRecorder _audioRecorder; + + final String _title = 'Real-time speech recognition'; + String _last = ''; + int _index = 0; + bool _isInitialized = false; + + sherpa_onnx.OnlineRecognizer? _recognizer; + sherpa_onnx.OnlineStream? _stream; + final int _sampleRate = 16000; + + StreamSubscription? _recordSub; + RecordState _recordState = RecordState.stop; + + @override + void initState() { + _audioRecorder = AudioRecorder(); + _controller = TextEditingController(); + + _recordSub = _audioRecorder.onStateChanged().listen((recordState) { + _updateRecordState(recordState); + }); + + super.initState(); + } + + Future _start() async { + if (!_isInitialized) { + sherpa_onnx.initBindings(); + _recognizer = await createOnlineRecognizer(); + _stream = _recognizer?.createStream(); + + _isInitialized = true; + } + + try { + if (await _audioRecorder.hasPermission()) { + const encoder = AudioEncoder.pcm16bits; + + if (!await _isEncoderSupported(encoder)) { + return; + } + + final devs = await _audioRecorder.listInputDevices(); + debugPrint(devs.toString()); + + const config = RecordConfig( + encoder: encoder, + sampleRate: 16000, + numChannels: 1, + ); + + final stream = await _audioRecorder.startStream(config); + + stream.listen( + (data) { + final samplesFloat32 = + convertBytesToFloat32(Uint8List.fromList(data)); + + _stream!.acceptWaveform( + samples: samplesFloat32, sampleRate: _sampleRate); + while (_recognizer!.isReady(_stream!)) { + _recognizer!.decode(_stream!); + } + final text = _recognizer!.getResult(_stream!).text; + String textToDisplay = _last; + if (text != '') { + if (_last == '') { + textToDisplay = '$_index: $text'; + } else { + textToDisplay = '$_index: $text\n$_last'; + } + } + + if (_recognizer!.isEndpoint(_stream!)) { + _recognizer!.reset(_stream!); + if (text != '') { + _last = textToDisplay; + _index += 1; + } + } + // print('text: $textToDisplay'); + + _controller.value = TextEditingValue( + text: textToDisplay, + selection: TextSelection.collapsed(offset: textToDisplay.length), + ); + }, + onDone: () { + if (kDebugMode) { + print('stream stopped.'); + } + }, + ); + } + } catch (e) { + if (kDebugMode) { + print(e); + } + } + } + + Future _stop() async { + _stream!.free(); + _stream = _recognizer!.createStream(); + + await _audioRecorder.stop(); + } + + // Future _pause() => _audioRecorder.pause(); + + // Future _resume() => _audioRecorder.resume(); + + void _updateRecordState(RecordState recordState) { + setState(() => _recordState = recordState); + } + + Future _isEncoderSupported(AudioEncoder encoder) async { + final isSupported = await _audioRecorder.isEncoderSupported( + encoder, + ); + + if (!isSupported) { + debugPrint('${encoder.name} is not supported on this platform.'); + debugPrint('Supported encoders are:'); + + for (final e in AudioEncoder.values) { + if (await _audioRecorder.isEncoderSupported(e)) { + debugPrint('- ${encoder.name}'); + } + } + } + + return isSupported; + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: Text(_title), + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 50), + TextField( + maxLines: 5, + controller: _controller, + readOnly: true, + ), + const SizedBox(height: 50), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildRecordStopControl(), + const SizedBox(width: 20), + _buildText(), + ], + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _recordSub?.cancel(); + _audioRecorder.dispose(); + _stream?.free(); + _recognizer?.free(); + super.dispose(); + } + + Widget _buildRecordStopControl() { + late Icon icon; + late Color color; + + if (_recordState != RecordState.stop) { + icon = const Icon(Icons.stop, color: Colors.red, size: 30); + color = Colors.red.withValues(alpha: 0.1); + } else { + final theme = Theme.of(context); + icon = Icon(Icons.mic, color: theme.primaryColor, size: 30); + color = theme.primaryColor.withValues(alpha: 0.1); + } + + return ClipOval( + child: Material( + color: color, + child: InkWell( + child: SizedBox(width: 56, height: 56, child: icon), + onTap: () { + (_recordState != RecordState.stop) ? _stop() : _start(); + }, + ), + ), + ); + } + + Widget _buildText() { + if (_recordState == RecordState.stop) { + return const Text("Start"); + } else { + return const Text("Stop"); + } + } +} diff --git a/team_b/yappy/lib/toast_widget.dart b/team_b/yappy/lib/toast_widget.dart new file mode 100644 index 00000000..574b07ae --- /dev/null +++ b/team_b/yappy/lib/toast_widget.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'services/toast_service.dart'; + +/// Widget that displays persistent toast notifications across all screens +class ToastWidget extends StatefulWidget { + final Widget child; + + const ToastWidget({super.key, required this.child}); + + @override + State createState() => _ToastWidgetState(); +} + +class _ToastWidgetState extends State { + final _toastService = ToastService(); + bool _visible = false; + String _message = ''; + double _progress = 0.0; + + @override + void initState() { + super.initState(); + + // Listen to service streams + _toastService.messageStream.listen((message) { + if (mounted) { + setState(() => _message = message); + } + }); + + _toastService.progressStream.listen((progress) { + if (mounted) { + setState(() => _progress = progress); + } + }); + + _toastService.visibilityStream.listen((visible) { + if (mounted) { + setState(() => _visible = visible); + } + }); + + _toastService.successStream.listen(_showSuccessSnackBar); + _toastService.errorStream.listen(_showErrorSnackBar); + } + + void _showSuccessSnackBar(String message) { + // Use ScaffoldMessenger to show SnackBar globally + final messenger = ScaffoldMessenger.of(context); + + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.green, + duration: const Duration(seconds: 3), + behavior: SnackBarBehavior.floating, + ), + ); + } + + void _showErrorSnackBar(String message) { + // Use ScaffoldMessenger to show SnackBar globally + final messenger = ScaffoldMessenger.of(context); + + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Material( + // Ensure Material is available for elevation and visual elements + type: MaterialType.transparency, + child: Stack( + children: [ + // Main content + widget.child, + + // Toast notification + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + bottom: _visible ? 32.0 : -100.0, + left: 16.0, + right: 16.0, + child: SafeArea( + child: Material( + elevation: 4.0, + borderRadius: BorderRadius.circular(8.0), + color: Colors.black87, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2.0, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: Text( + _message, + style: const TextStyle( + color: Colors.white, + fontSize: 14.0, + ), + ), + ), + ], + ), + const SizedBox(height: 8.0), + LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/team_b/yappy/lib/transcription_box.dart b/team_b/yappy/lib/transcription_box.dart index a3b47ed7..130817c1 100644 --- a/team_b/yappy/lib/transcription_box.dart +++ b/team_b/yappy/lib/transcription_box.dart @@ -1,34 +1,12 @@ import 'package:flutter/material.dart'; -void main() { - runApp(TranscriptionBox()); -} -//Creates a transcription box that will display the AI transcript -//The box will be a text field that will display the AI transcript class TranscriptionBox extends StatelessWidget { - const TranscriptionBox({super.key}); + final TextEditingController controller; - @override - Widget build(BuildContext context) { - return MaterialApp( - debugShowCheckedModeBanner: false, - home: Scaffold( - backgroundColor: Colors.black, - body: Column( - children: [ - Expanded( - child: ChatBox(), - ), - ], - ), - ), - ); - } -} -//Creates a chat box that will display the AI transcript -//The box will be a text field that will display the AI transcript -class ChatBox extends StatelessWidget { - const ChatBox({super.key}); + const TranscriptionBox({ + required this.controller, + super.key + }); @override Widget build(BuildContext context) { @@ -38,7 +16,9 @@ class ChatBox extends StatelessWidget { color: const Color.fromARGB(255, 67, 67, 67) ), child: TextField( + controller: controller, maxLines: null, + readOnly: true, decoration: InputDecoration( hintText: 'AI Transcript will go here', hintStyle: TextStyle( diff --git a/team_b/yappy/lib/utils.dart b/team_b/yappy/lib/utils.dart new file mode 100644 index 00000000..12a044c8 --- /dev/null +++ b/team_b/yappy/lib/utils.dart @@ -0,0 +1,37 @@ +// Copyright (c) 2024 Xiaomi Corporation +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'dart:typed_data'; +import "dart:io"; + +// Copy the asset file from src to dst +Future copyAssetFile(String src, [String? dst]) async { + final Directory directory = await getApplicationCacheDirectory(); + dst ??= basename(src); + final target = join(directory.path, dst); + bool exists = await File(target).exists(); + + final data = await rootBundle.load(src); + + if (!exists || File(target).lengthSync() != data.lengthInBytes) { + final List bytes = + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + await File(target).writeAsBytes(bytes); + } + + return target; +} + +Float32List convertBytesToFloat32(Uint8List bytes, [endian = Endian.little]) { + final values = Float32List(bytes.length ~/ 2); + + final data = ByteData.view(bytes.buffer); + + for (var i = 0; i < bytes.length; i += 2) { + int short = data.getInt16(i, endian); + values[i ~/ 2] = short / 32678.0; + } + + return values; +} diff --git a/team_b/yappy/linux/.gitignore b/team_b/yappy/linux/.gitignore index d3896c98..24ce4374 100644 --- a/team_b/yappy/linux/.gitignore +++ b/team_b/yappy/linux/.gitignore @@ -1 +1,3 @@ flutter/ephemeral +flutter/generated* +**/flutter/generated* \ No newline at end of file diff --git a/team_b/yappy/linux/flutter/generated_plugin_registrant.cc b/team_b/yappy/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index e71a16d2..00000000 --- a/team_b/yappy/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,11 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - - -void fl_register_plugins(FlPluginRegistry* registry) { -} diff --git a/team_b/yappy/linux/flutter/generated_plugin_registrant.h b/team_b/yappy/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/team_b/yappy/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/team_b/yappy/linux/flutter/generated_plugins.cmake b/team_b/yappy/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 2e1de87a..00000000 --- a/team_b/yappy/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,23 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/team_b/yappy/macos/.gitignore b/team_b/yappy/macos/.gitignore index 7f1506c5..55610ecf 100644 --- a/team_b/yappy/macos/.gitignore +++ b/team_b/yappy/macos/.gitignore @@ -2,6 +2,7 @@ **/Flutter/ephemeral/ **/Pods/ **/Flutter/GeneratedPluginRegistrant.* +Flutter/Generated* # Xcode-related **/dgph diff --git a/team_b/yappy/macos/Flutter/GeneratedPluginRegistrant.swift b/team_b/yappy/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 29d4e7f0..00000000 --- a/team_b/yappy/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import sqflite_darwin - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) -} diff --git a/team_b/yappy/pubspec.yaml b/team_b/yappy/pubspec.yaml index 0583fe72..b8410829 100644 --- a/team_b/yappy/pubspec.yaml +++ b/team_b/yappy/pubspec.yaml @@ -31,18 +31,20 @@ dependencies: flutter: sdk: flutter -# Allows sharing with the phones default apps. - share_plus: ^10.1.4 - - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 flutter_launcher_icons: ^0.14.3 - sqflite: ^2.0.3+1 - path: ^1.8.0 + sqflite: ^2.4.2 + path: ^1.9.1 + path_provider: ^2.1.5 envied: ^1.1.1 - path_provider: ^2.0.11 + sherpa_onnx: ^1.10.46 + record: ^5.2.1 + connectivity_plus: ^6.1.3 + shared_preferences: ^2.5.2 + share_plus: ^10.1.4 # Allows sharing with the phones default apps. + http: ^1.3.0 # For network requests + permission_handler: ^11.4.0 # For storage permissions + archive: ^4.0.4 # For BZ2 decompression dev_dependencies: flutter_test: @@ -77,6 +79,7 @@ flutter: - assets/icon/app_icon.png - assets/yappy_database.db - assets/test_document.txt + - assets/ # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/team_b/yappy/test/industry_menu_test.dart b/team_b/yappy/test/industry_menu_test.dart index ed189361..08c35101 100644 --- a/team_b/yappy/test/industry_menu_test.dart +++ b/team_b/yappy/test/industry_menu_test.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yappy/industry_menu.dart'; +import 'package:yappy/speech_state.dart'; void main() { + + final SpeechState speechState = SpeechState(); testWidgets('IndustryMenu displays title and icons', (WidgetTester tester) async { // Build the IndustryMenu widget. await tester.pumpWidget(MaterialApp( home: Scaffold( - body: IndustryMenu(title: 'Industry Title', icon: Icons.business), + body: IndustryMenu(title: 'Industry Title', icon: Icons.business, speechState: speechState), ), )); @@ -28,7 +31,7 @@ void main() { // Build the IndustryMenu widget. await tester.pumpWidget(MaterialApp( home: Scaffold( - body: IndustryMenu(title: 'Industry Title', icon: Icons.business), + body: IndustryMenu(title: 'Industry Title', icon: Icons.business, speechState: speechState), ), ));