feat: Implement audio recording and transcription for rapid order creation across platforms.

This commit is contained in:
Achintha Isuru
2026-02-27 11:49:17 -05:00
parent 52bdf48155
commit 55a31661a1
31 changed files with 260 additions and 21 deletions

View File

@@ -31,3 +31,4 @@ export 'src/services/device/camera/camera_service.dart';
export 'src/services/device/gallery/gallery_service.dart';
export 'src/services/device/file/file_picker_service.dart';
export 'src/services/device/file_upload/device_file_upload_service.dart';
export 'src/services/device/audio/audio_recorder_service.dart';

View File

@@ -40,6 +40,7 @@ class CoreModule extends Module {
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
i.addSingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
i.addSingleton<FilePickerService>(FilePickerService.new);
i.addSingleton<AudioRecorderService>(AudioRecorderService.new);
i.addSingleton<DeviceFileUploadService>(
() => DeviceFileUploadService(
cameraService: i.get<CameraService>(),

View File

@@ -0,0 +1,55 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:krow_domain/krow_domain.dart';
/// Service for recording audio using the device microphone.
class AudioRecorderService extends BaseDeviceService {
/// Creates an [AudioRecorderService].
AudioRecorderService() : _recorder = AudioRecorder();
final AudioRecorder _recorder;
/// Starts recording audio to a temporary file.
///
/// Returns the path where the audio is being recorded.
Future<void> startRecording() async {
return action(() async {
if (await _recorder.hasPermission()) {
final Directory tempDir = await getTemporaryDirectory();
final String path =
'${tempDir.path}/rapid_order_audio_${DateTime.now().millisecondsSinceEpoch}.m4a';
// Configure the recording
const RecordConfig config = RecordConfig(
encoder: AudioEncoder.aacLc, // Good balance of quality and size
bitRate: 128000,
sampleRate: 44100,
);
await _recorder.start(config, path: path);
} else {
throw Exception('Microphone permission not granted');
}
});
}
/// Stops the current recording.
///
/// Returns the path to the recorded audio file, or null if no recording was active.
Future<String?> stopRecording() async {
return action(() async {
return await _recorder.stop();
});
}
/// Checks if the recorder is currently recording.
Future<bool> isRecording() async {
return await _recorder.isRecording();
}
/// Disposes the recorder resources.
void dispose() {
_recorder.dispose();
}
}

View File

@@ -25,4 +25,6 @@ dependencies:
image_picker: ^1.1.2
path_provider: ^2.1.3
file_picker: ^8.1.7
record: ^5.2.0
firebase_auth: ^6.1.4

View File

@@ -285,4 +285,7 @@ class UiIcons {
/// Circle dollar icon
static const IconData circleDollar = _IconLib.circleDollarSign;
/// Microphone icon
static const IconData microphone = _IconLib.mic;
}

View File

@@ -9,6 +9,7 @@ import 'domain/usecases/create_permanent_order_usecase.dart';
import 'domain/usecases/create_recurring_order_usecase.dart';
import 'domain/usecases/create_rapid_order_usecase.dart';
import 'domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'domain/usecases/transcribe_rapid_order_usecase.dart';
import 'presentation/blocs/index.dart';
import 'presentation/pages/create_order_page.dart';
import 'presentation/pages/one_time_order_page.dart';
@@ -23,7 +24,7 @@ import 'presentation/pages/recurring_order_page.dart';
/// presentation layer BLoCs.
class ClientCreateOrderModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[DataConnectModule(), CoreModule()];
@override
void binds(Injector i) {
@@ -37,10 +38,17 @@ class ClientCreateOrderModule extends Module {
i.addLazySingleton(CreatePermanentOrderUseCase.new);
i.addLazySingleton(CreateRecurringOrderUseCase.new);
i.addLazySingleton(CreateRapidOrderUseCase.new);
i.addLazySingleton(TranscribeRapidOrderUseCase.new);
i.addLazySingleton(GetOrderDetailsForReorderUseCase.new);
// BLoCs
i.add<RapidOrderBloc>(RapidOrderBloc.new);
i.add<RapidOrderBloc>(
(Injector i) => RapidOrderBloc(
i.get<CreateRapidOrderUseCase>(),
i.get<TranscribeRapidOrderUseCase>(),
i.get<AudioRecorderService>(),
),
);
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
i.add<PermanentOrderBloc>(PermanentOrderBloc.new);
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);

View File

@@ -1,5 +1,6 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/client_create_order_repository_interface.dart';
@@ -13,10 +14,14 @@ import '../../domain/repositories/client_create_order_repository_interface.dart'
/// on delegation and data mapping, without business logic.
class ClientCreateOrderRepositoryImpl
implements ClientCreateOrderRepositoryInterface {
ClientCreateOrderRepositoryImpl({required dc.DataConnectService service})
: _service = service;
ClientCreateOrderRepositoryImpl({
required dc.DataConnectService service,
required RapidOrderService rapidOrderService,
}) : _service = service,
_rapidOrderService = rapidOrderService;
final dc.DataConnectService _service;
final RapidOrderService _rapidOrderService;
@override
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
@@ -367,6 +372,13 @@ class ClientCreateOrderRepositoryImpl
throw UnimplementedError('Rapid order IA is not connected yet.');
}
@override
Future<String> transcribeRapidOrder(String audioPath) async {
final RapidOrderTranscriptionResponse response = await _rapidOrderService
.transcribeAudio(audioFileUri: audioPath);
return response.transcript;
}
@override
Future<void> reorder(String previousOrderId, DateTime newDate) async {
// TODO: Implement reorder functionality to fetch the previous order and create a new one with the updated date.

View File

@@ -24,6 +24,11 @@ abstract interface class ClientCreateOrderRepositoryInterface {
/// [description] is the text message (or transcribed voice) describing the need.
Future<void> createRapidOrder(String description);
/// Transcribes the audio file for a rapid order.
///
/// [audioPath] is the local path to the recorded audio file.
Future<String> transcribeRapidOrder(String audioPath);
/// Reorders an existing staffing order with a new date.
///
/// [previousOrderId] is the ID of the order to reorder.

View File

@@ -0,0 +1,16 @@
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for transcribing audio for a rapid order.
class TranscribeRapidOrderUseCase {
/// Creates a [TranscribeRapidOrderUseCase].
TranscribeRapidOrderUseCase(this._repository);
final ClientCreateOrderRepositoryInterface _repository;
/// Executes the use case.
///
/// [audioPath] is the local path to the audio file.
Future<String> call(String audioPath) async {
return _repository.transcribeRapidOrder(audioPath);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:client_create_order/src/domain/arguments/rapid_order_arguments.dart';
import 'package:client_create_order/src/domain/usecases/create_rapid_order_usecase.dart';
import 'package:client_create_order/src/domain/usecases/transcribe_rapid_order_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
@@ -9,8 +10,11 @@ import 'rapid_order_state.dart';
/// BLoC for managing the rapid (urgent) order creation flow.
class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
with BlocErrorHandler<RapidOrderState> {
RapidOrderBloc(this._createRapidOrderUseCase)
: super(
RapidOrderBloc(
this._createRapidOrderUseCase,
this._transcribeRapidOrderUseCase,
this._audioRecorderService,
) : super(
const RapidOrderInitial(
examples: <String>[
'"We had a call out. Need 2 cooks ASAP"',
@@ -25,6 +29,8 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
on<RapidOrderExampleSelected>(_onExampleSelected);
}
final CreateRapidOrderUseCase _createRapidOrderUseCase;
final TranscribeRapidOrderUseCase _transcribeRapidOrderUseCase;
final AudioRecorderService _audioRecorderService;
void _onMessageChanged(
RapidOrderMessageChanged event,
@@ -43,19 +49,31 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
final RapidOrderInitial currentState = state as RapidOrderInitial;
final bool newListeningState = !currentState.isListening;
emit(currentState.copyWith(isListening: newListeningState));
// Simulate voice recognition
if (newListeningState) {
await Future<void>.delayed(const Duration(seconds: 2));
if (state is RapidOrderInitial) {
emit(
(state as RapidOrderInitial).copyWith(
message: 'Need 2 servers for a banquet right now.',
isListening: false,
),
);
}
// Start Recording
await _audioRecorderService.startRecording();
emit(currentState.copyWith(isListening: true));
} else {
// Stop Recording and Transcribe
emit(currentState.copyWith(isListening: false));
await handleError(
emit: emit.call,
action: () async {
final String? path = await _audioRecorderService.stopRecording();
if (path != null) {
final String transcript = await _transcribeRapidOrderUseCase(
path,
);
if (state is RapidOrderInitial) {
emit(
(state as RapidOrderInitial).copyWith(message: transcript),
);
}
}
},
onError: (String errorKey) => RapidOrderFailure(errorKey),
);
}
}
}
@@ -91,5 +109,10 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
emit((state as RapidOrderInitial).copyWith(message: cleanedExample));
}
}
}
@override
Future<void> close() {
_audioRecorderService.dispose();
return super.close();
}
}

View File

@@ -29,7 +29,7 @@ class RapidOrderView extends StatelessWidget {
title: labels.success_title,
message: labels.success_message,
buttonLabel: labels.back_to_orders,
onDone: () => Modular.to.navigate(ClientPaths.orders),
onDone: () => Modular.to.toClientOrders(),
);
}
@@ -273,7 +273,7 @@ class _RapidOrderActions extends StatelessWidget {
Expanded(
child: UiButton.secondary(
text: isListening ? labels.listening : labels.speak,
leadingIcon: UiIcons.bell, // Placeholder for mic
leadingIcon: UiIcons.microphone,
onPressed: () => BlocProvider.of<RapidOrderBloc>(
context,
).add(const RapidOrderVoiceToggled()),

View File

@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <record_linux/record_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
}

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
record_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -10,6 +10,7 @@ import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import record_darwin
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

@@ -9,6 +9,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <record_windows/record_windows_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
@@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
}

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
record_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST