feat: Implement voice-to-text transcription for rapid order creation and refactor RapidOrderBloc state management.

This commit is contained in:
Achintha Isuru
2026-02-27 20:37:14 -05:00
parent cbd337f4e3
commit a53dddf2e6
7 changed files with 188 additions and 199 deletions

View File

@@ -51,7 +51,10 @@ void main() async {
/// The main application module for the Client app. /// The main application module for the Client app.
class AppModule extends Module { class AppModule extends Module {
@override @override
List<Module> get imports => <Module>[core_localization.LocalizationModule()]; List<Module> get imports => <Module>[
core_localization.LocalizationModule(),
CoreModule(),
];
@override @override
void routes(RouteManager r) { void routes(RouteManager r) {

View File

@@ -349,6 +349,7 @@
"listening": "Listening...", "listening": "Listening...",
"send": "Send Message", "send": "Send Message",
"sending": "Sending...", "sending": "Sending...",
"transcribing": "Transcribing...",
"success_title": "Request Sent!", "success_title": "Request Sent!",
"success_message": "We're finding available workers for you right now. You'll be notified as they accept.", "success_message": "We're finding available workers for you right now. You'll be notified as they accept.",
"back_to_orders": "Back to Orders" "back_to_orders": "Back to Orders"

View File

@@ -349,6 +349,7 @@
"listening": "Escuchando...", "listening": "Escuchando...",
"send": "Enviar Mensaje", "send": "Enviar Mensaje",
"sending": "Enviando...", "sending": "Enviando...",
"transcribing": "Transcribiendo...",
"success_title": "\u00a1Solicitud Enviada!", "success_title": "\u00a1Solicitud Enviada!",
"success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.", "success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.",
"back_to_orders": "Volver a \u00d3rdenes" "back_to_orders": "Volver a \u00d3rdenes"

View File

@@ -26,13 +26,13 @@ import 'presentation/pages/recurring_order_page.dart';
/// presentation layer BLoCs. /// presentation layer BLoCs.
class ClientCreateOrderModule extends Module { class ClientCreateOrderModule extends Module {
@override @override
List<Module> get imports => <Module>[DataConnectModule()]; List<Module> get imports => <Module>[DataConnectModule(), CoreModule()];
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<ClientCreateOrderRepositoryInterface>( i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
(Injector i) => ClientCreateOrderRepositoryImpl( () => ClientCreateOrderRepositoryImpl(
service: i.get<dc.DataConnectService>(), service: i.get<dc.DataConnectService>(),
rapidOrderService: i.get<RapidOrderService>(), rapidOrderService: i.get<RapidOrderService>(),
), ),
@@ -49,7 +49,7 @@ class ClientCreateOrderModule extends Module {
// BLoCs // BLoCs
i.add<RapidOrderBloc>( i.add<RapidOrderBloc>(
(Injector i) => RapidOrderBloc( () => RapidOrderBloc(
i.get<TranscribeRapidOrderUseCase>(), i.get<TranscribeRapidOrderUseCase>(),
i.get<ParseRapidOrderTextToOrderUseCase>(), i.get<ParseRapidOrderTextToOrderUseCase>(),
i.get<AudioRecorderService>(), i.get<AudioRecorderService>(),

View File

@@ -15,7 +15,7 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
this._parseRapidOrderUseCase, this._parseRapidOrderUseCase,
this._audioRecorderService, this._audioRecorderService,
) : super( ) : super(
const RapidOrderInitial( const RapidOrderState(
examples: <String>[ examples: <String>[
'"We had a call out. Need 2 cooks ASAP"', '"We had a call out. Need 2 cooks ASAP"',
'"Need 5 bartenders ASAP until 5am"', '"Need 5 bartenders ASAP until 5am"',
@@ -36,33 +36,76 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
RapidOrderMessageChanged event, RapidOrderMessageChanged event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) { ) {
if (state is RapidOrderInitial) { emit(
emit((state as RapidOrderInitial).copyWith(message: event.message)); state.copyWith(message: event.message, status: RapidOrderStatus.initial),
} );
} }
Future<void> _onVoiceToggled( Future<void> _onVoiceToggled(
RapidOrderVoiceToggled event, RapidOrderVoiceToggled event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) async { ) async {
if (state is RapidOrderInitial) { if (!state.isListening) {
final RapidOrderInitial currentState = state as RapidOrderInitial; // Start Recording
final bool newListeningState = !currentState.isListening; await handleError(
emit: emit.call,
emit(currentState.copyWith(isListening: newListeningState)); action: () async {
await _audioRecorderService.startRecording();
// Simulate voice recognition
if (newListeningState) {
await Future<void>.delayed(const Duration(seconds: 2));
if (state is RapidOrderInitial) {
emit( emit(
(state as RapidOrderInitial).copyWith( state.copyWith(isListening: true, status: RapidOrderStatus.initial),
message: 'Need 2 servers for a banquet right now.', );
},
onError: (String errorKey) =>
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
);
} else {
// Stop Recording and Transcribe
await handleError(
emit: emit.call,
action: () async {
// 1. Stop recording
final String? audioPath = await _audioRecorderService.stopRecording();
if (audioPath == null) {
emit(
state.copyWith(
isListening: false, isListening: false,
status: RapidOrderStatus.initial,
), ),
); );
return;
} }
}
// 2. Transcribe
emit(
state.copyWith(
isListening: false,
isTranscribing: true,
status: RapidOrderStatus.initial,
),
);
final String transcription = await _transcribeRapidOrderUseCase(
audioPath,
);
// 3. Update message
emit(
state.copyWith(
message: transcription,
isListening: false,
isTranscribing: false,
status: RapidOrderStatus.initial,
),
);
},
onError: (String errorKey) => state.copyWith(
status: RapidOrderStatus.failure,
error: errorKey,
isListening: false,
isTranscribing: false,
),
);
} }
} }
@@ -70,30 +113,29 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
RapidOrderSubmitted event, RapidOrderSubmitted event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) async { ) async {
final RapidOrderState currentState = state; final String message = state.message;
if (currentState is RapidOrderInitial) { emit(state.copyWith(status: RapidOrderStatus.submitting));
final String message = currentState.message;
emit(const RapidOrderSubmitting());
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final OneTimeOrder order = await _parseRapidOrderUseCase(message); final OneTimeOrder order = await _parseRapidOrderUseCase(message);
emit(RapidOrderParsed(order)); emit(
}, state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order),
onError: (String errorKey) => RapidOrderFailure(errorKey), );
},
onError: (String errorKey) =>
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
); );
}
} }
void _onExampleSelected( void _onExampleSelected(
RapidOrderExampleSelected event, RapidOrderExampleSelected event,
Emitter<RapidOrderState> emit, Emitter<RapidOrderState> emit,
) { ) {
if (state is RapidOrderInitial) {
final String cleanedExample = event.example.replaceAll('"', ''); final String cleanedExample = event.example.replaceAll('"', '');
emit((state as RapidOrderInitial).copyWith(message: cleanedExample)); emit(
state.copyWith(message: cleanedExample, status: RapidOrderStatus.initial),
);
} }
} }
}

View File

@@ -1,59 +1,55 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
abstract class RapidOrderState extends Equatable { enum RapidOrderStatus { initial, submitting, parsed, failure }
const RapidOrderState();
@override class RapidOrderState extends Equatable {
List<Object?> get props => <Object?>[]; const RapidOrderState({
} this.status = RapidOrderStatus.initial,
class RapidOrderInitial extends RapidOrderState {
const RapidOrderInitial({
this.message = '', this.message = '',
this.isListening = false, this.isListening = false,
required this.examples, this.isTranscribing = false,
this.examples = const <String>[],
this.error,
this.parsedOrder,
}); });
final RapidOrderStatus status;
final String message; final String message;
final bool isListening; final bool isListening;
final bool isTranscribing;
final List<String> examples; final List<String> examples;
final String? error;
final OneTimeOrder? parsedOrder;
@override @override
List<Object?> get props => <Object?>[message, isListening, examples]; List<Object?> get props => <Object?>[
status,
message,
isListening,
isTranscribing,
examples,
error,
parsedOrder,
];
RapidOrderInitial copyWith({ RapidOrderState copyWith({
RapidOrderStatus? status,
String? message, String? message,
bool? isListening, bool? isListening,
bool? isTranscribing,
List<String>? examples, List<String>? examples,
String? error,
OneTimeOrder? parsedOrder,
}) { }) {
return RapidOrderInitial( return RapidOrderState(
status: status ?? this.status,
message: message ?? this.message, message: message ?? this.message,
isListening: isListening ?? this.isListening, isListening: isListening ?? this.isListening,
isTranscribing: isTranscribing ?? this.isTranscribing,
examples: examples ?? this.examples, examples: examples ?? this.examples,
error: error ?? this.error,
parsedOrder: parsedOrder ?? this.parsedOrder,
); );
} }
} }
class RapidOrderSubmitting extends RapidOrderState {
const RapidOrderSubmitting();
}
class RapidOrderSuccess extends RapidOrderState {
const RapidOrderSuccess();
}
class RapidOrderFailure extends RapidOrderState {
const RapidOrderFailure(this.error);
final String error;
@override
List<Object?> get props => <Object?>[error];
}
class RapidOrderParsed extends RapidOrderState {
const RapidOrderParsed(this.order);
final OneTimeOrder order;
@override
List<Object?> get props => <Object?>[order];
}

View File

@@ -10,7 +10,6 @@ import '../../blocs/rapid_order/rapid_order_event.dart';
import '../../blocs/rapid_order/rapid_order_state.dart'; import '../../blocs/rapid_order/rapid_order_state.dart';
import 'rapid_order_example_card.dart'; import 'rapid_order_example_card.dart';
import 'rapid_order_header.dart'; import 'rapid_order_header.dart';
import 'rapid_order_success_view.dart';
/// The main content of the Rapid Order page. /// The main content of the Rapid Order page.
class RapidOrderView extends StatelessWidget { class RapidOrderView extends StatelessWidget {
@@ -19,23 +18,7 @@ class RapidOrderView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientCreateOrderRapidEn labels =
t.client_create_order.rapid;
return BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
if (state is RapidOrderSuccess) {
return RapidOrderSuccessView(
title: labels.success_title,
message: labels.success_message,
buttonLabel: labels.back_to_orders,
onDone: () => Modular.to.toClientOrders(),
);
}
return const _RapidOrderForm(); return const _RapidOrderForm();
},
);
} }
} }
@@ -65,24 +48,26 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
return BlocListener<RapidOrderBloc, RapidOrderState>( return BlocListener<RapidOrderBloc, RapidOrderState>(
listener: (BuildContext context, RapidOrderState state) { listener: (BuildContext context, RapidOrderState state) {
if (state is RapidOrderInitial) { if (state.status == RapidOrderStatus.initial) {
if (_messageController.text != state.message) { if (_messageController.text != state.message) {
_messageController.text = state.message; _messageController.text = state.message;
_messageController.selection = TextSelection.fromPosition( _messageController.selection = TextSelection.fromPosition(
TextPosition(offset: _messageController.text.length), TextPosition(offset: _messageController.text.length),
); );
} }
} else if (state is RapidOrderParsed) { } else if (state.status == RapidOrderStatus.parsed &&
state.parsedOrder != null) {
Modular.to.toCreateOrderOneTime( Modular.to.toCreateOrderOneTime(
arguments: <String, dynamic>{ arguments: <String, dynamic>{
'order': state.order, 'order': state.parsedOrder,
'isRapidDraft': true, 'isRapidDraft': true,
}, },
); );
} else if (state is RapidOrderFailure) { } else if (state.status == RapidOrderStatus.failure &&
state.error != null) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: translateErrorKey(state.error), message: translateErrorKey(state.error!),
type: UiSnackbarType.error, type: UiSnackbarType.error,
); );
} }
@@ -99,64 +84,26 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
), ),
// Content // Content
Expanded(
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
final bool isSubmitting =
state.status == RapidOrderStatus.submitting;
return Column(
children: <Widget>[
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
labels.tell_us,
style: UiTypography.headline3m.textPrimary,
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.destructive,
borderRadius: UiConstants.radiusSm,
),
child: Text(
labels.urgent_badge,
style: UiTypography.footnote2b.copyWith(
color: UiColors.white,
),
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Main Card
Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
final RapidOrderInitial? initialState =
state is RapidOrderInitial ? state : null;
final bool isSubmitting =
state is RapidOrderSubmitting;
return Column(
children: <Widget>[ children: <Widget>[
// Icon // Icon
const _AnimatedZapIcon(), const _AnimatedZapIcon(),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
labels.need_staff, labels.need_staff,
style: UiTypography.headline2m.textPrimary, style: UiTypography.headline3b.textPrimary,
), ),
const SizedBox(height: UiConstants.space2),
Text( Text(
labels.type_or_speak, labels.type_or_speak,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -165,8 +112,7 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
// Examples // Examples
if (initialState != null) ...state.examples.asMap().entries.map((
...initialState.examples.asMap().entries.map((
MapEntry<int, String> entry, MapEntry<int, String> entry,
) { ) {
final int index = entry.key; final int index = entry.key;
@@ -203,16 +149,19 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
}, },
hintText: labels.hint, hintText: labels.hint,
), ),
const SizedBox(height: UiConstants.space4), ],
),
// Actions ),
_RapidOrderActions( ),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: _RapidOrderActions(
labels: labels, labels: labels,
isSubmitting: isSubmitting, isSubmitting: isSubmitting,
isListening: initialState?.isListening ?? false, isListening: state.isListening,
isMessageEmpty: isTranscribing: state.isTranscribing,
initialState != null && isMessageEmpty: state.message.trim().isEmpty,
initialState.message.trim().isEmpty, ),
), ),
], ],
); );
@@ -222,10 +171,6 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
], ],
), ),
), ),
),
],
),
),
); );
} }
} }
@@ -248,13 +193,6 @@ class _AnimatedZapIcon extends StatelessWidget {
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.destructive.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
), ),
child: const Icon(UiIcons.zap, color: UiColors.white, size: 32), child: const Icon(UiIcons.zap, color: UiColors.white, size: 32),
); );
@@ -266,11 +204,13 @@ class _RapidOrderActions extends StatelessWidget {
required this.labels, required this.labels,
required this.isSubmitting, required this.isSubmitting,
required this.isListening, required this.isListening,
required this.isTranscribing,
required this.isMessageEmpty, required this.isMessageEmpty,
}); });
final TranslationsClientCreateOrderRapidEn labels; final TranslationsClientCreateOrderRapidEn labels;
final bool isSubmitting; final bool isSubmitting;
final bool isListening; final bool isListening;
final bool isTranscribing;
final bool isMessageEmpty; final bool isMessageEmpty;
@override @override
@@ -279,9 +219,15 @@ class _RapidOrderActions extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: UiButton.secondary( child: UiButton.secondary(
text: isListening ? labels.listening : labels.speak, text: isTranscribing
? labels.transcribing
: isListening
? labels.listening
: labels.speak,
leadingIcon: UiIcons.microphone, leadingIcon: UiIcons.microphone,
onPressed: () => BlocProvider.of<RapidOrderBloc>( onPressed: isTranscribing
? null
: () => BlocProvider.of<RapidOrderBloc>(
context, context,
).add(const RapidOrderVoiceToggled()), ).add(const RapidOrderVoiceToggled()),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(