feat: Implement audio recording and transcription for rapid order creation across platforms.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -285,4 +285,7 @@ class UiIcons {
|
||||
|
||||
/// Circle dollar icon
|
||||
static const IconData circleDollar = _IconLib.circleDollarSign;
|
||||
|
||||
/// Microphone icon
|
||||
static const IconData microphone = _IconLib.mic;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
record_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
record_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user