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

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

View File

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

View File

@@ -26,13 +26,13 @@ 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) {
// Repositories
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
(Injector i) => ClientCreateOrderRepositoryImpl(
() => ClientCreateOrderRepositoryImpl(
service: i.get<dc.DataConnectService>(),
rapidOrderService: i.get<RapidOrderService>(),
),
@@ -49,7 +49,7 @@ class ClientCreateOrderModule extends Module {
// BLoCs
i.add<RapidOrderBloc>(
(Injector i) => RapidOrderBloc(
() => RapidOrderBloc(
i.get<TranscribeRapidOrderUseCase>(),
i.get<ParseRapidOrderTextToOrderUseCase>(),
i.get<AudioRecorderService>(),

View File

@@ -15,7 +15,7 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
this._parseRapidOrderUseCase,
this._audioRecorderService,
) : super(
const RapidOrderInitial(
const RapidOrderState(
examples: <String>[
'"We had a call out. Need 2 cooks ASAP"',
'"Need 5 bartenders ASAP until 5am"',
@@ -36,33 +36,76 @@ class RapidOrderBloc extends Bloc<RapidOrderEvent, RapidOrderState>
RapidOrderMessageChanged event,
Emitter<RapidOrderState> emit,
) {
if (state is RapidOrderInitial) {
emit((state as RapidOrderInitial).copyWith(message: event.message));
}
emit(
state.copyWith(message: event.message, status: RapidOrderStatus.initial),
);
}
Future<void> _onVoiceToggled(
RapidOrderVoiceToggled event,
Emitter<RapidOrderState> emit,
) async {
if (state is RapidOrderInitial) {
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) {
if (!state.isListening) {
// Start Recording
await handleError(
emit: emit.call,
action: () async {
await _audioRecorderService.startRecording();
emit(
(state as RapidOrderInitial).copyWith(
message: 'Need 2 servers for a banquet right now.',
state.copyWith(isListening: true, status: RapidOrderStatus.initial),
);
},
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,
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,
Emitter<RapidOrderState> emit,
) async {
final RapidOrderState currentState = state;
if (currentState is RapidOrderInitial) {
final String message = currentState.message;
emit(const RapidOrderSubmitting());
final String message = state.message;
emit(state.copyWith(status: RapidOrderStatus.submitting));
await handleError(
emit: emit.call,
action: () async {
final OneTimeOrder order = await _parseRapidOrderUseCase(message);
emit(RapidOrderParsed(order));
},
onError: (String errorKey) => RapidOrderFailure(errorKey),
);
}
await handleError(
emit: emit.call,
action: () async {
final OneTimeOrder order = await _parseRapidOrderUseCase(message);
emit(
state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order),
);
},
onError: (String errorKey) =>
state.copyWith(status: RapidOrderStatus.failure, error: errorKey),
);
}
void _onExampleSelected(
RapidOrderExampleSelected event,
Emitter<RapidOrderState> emit,
) {
if (state is RapidOrderInitial) {
final String cleanedExample = event.example.replaceAll('"', '');
emit((state as RapidOrderInitial).copyWith(message: cleanedExample));
}
final String cleanedExample = event.example.replaceAll('"', '');
emit(
state.copyWith(message: cleanedExample, status: RapidOrderStatus.initial),
);
}
}

View File

@@ -1,59 +1,55 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class RapidOrderState extends Equatable {
const RapidOrderState();
enum RapidOrderStatus { initial, submitting, parsed, failure }
@override
List<Object?> get props => <Object?>[];
}
class RapidOrderInitial extends RapidOrderState {
const RapidOrderInitial({
class RapidOrderState extends Equatable {
const RapidOrderState({
this.status = RapidOrderStatus.initial,
this.message = '',
this.isListening = false,
required this.examples,
this.isTranscribing = false,
this.examples = const <String>[],
this.error,
this.parsedOrder,
});
final RapidOrderStatus status;
final String message;
final bool isListening;
final bool isTranscribing;
final List<String> examples;
final String? error;
final OneTimeOrder? parsedOrder;
@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,
bool? isListening,
bool? isTranscribing,
List<String>? examples,
String? error,
OneTimeOrder? parsedOrder,
}) {
return RapidOrderInitial(
return RapidOrderState(
status: status ?? this.status,
message: message ?? this.message,
isListening: isListening ?? this.isListening,
isTranscribing: isTranscribing ?? this.isTranscribing,
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 'rapid_order_example_card.dart';
import 'rapid_order_header.dart';
import 'rapid_order_success_view.dart';
/// The main content of the Rapid Order page.
class RapidOrderView extends StatelessWidget {
@@ -19,23 +18,7 @@ class RapidOrderView extends StatelessWidget {
@override
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>(
listener: (BuildContext context, RapidOrderState state) {
if (state is RapidOrderInitial) {
if (state.status == RapidOrderStatus.initial) {
if (_messageController.text != state.message) {
_messageController.text = state.message;
_messageController.selection = TextSelection.fromPosition(
TextPosition(offset: _messageController.text.length),
);
}
} else if (state is RapidOrderParsed) {
} else if (state.status == RapidOrderStatus.parsed &&
state.parsedOrder != null) {
Modular.to.toCreateOrderOneTime(
arguments: <String, dynamic>{
'order': state.order,
'order': state.parsedOrder,
'isRapidDraft': true,
},
);
} else if (state is RapidOrderFailure) {
} else if (state.status == RapidOrderStatus.failure &&
state.error != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.error),
message: translateErrorKey(state.error!),
type: UiSnackbarType.error,
);
}
@@ -100,63 +85,25 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
// Content
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
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),
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
builder: (BuildContext context, RapidOrderState state) {
final bool isSubmitting =
state.status == RapidOrderStatus.submitting;
// 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(
return Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
// Icon
const _AnimatedZapIcon(),
const SizedBox(height: UiConstants.space4),
Text(
labels.need_staff,
style: UiTypography.headline2m.textPrimary,
style: UiTypography.headline3b.textPrimary,
),
const SizedBox(height: UiConstants.space2),
Text(
labels.type_or_speak,
textAlign: TextAlign.center,
@@ -165,31 +112,30 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
const SizedBox(height: UiConstants.space6),
// Examples
if (initialState != null)
...initialState.examples.asMap().entries.map((
MapEntry<int, String> entry,
) {
final int index = entry.key;
final String example = entry.value;
final bool isHighlighted = index == 0;
...state.examples.asMap().entries.map((
MapEntry<int, String> entry,
) {
final int index = entry.key;
final String example = entry.value;
final bool isHighlighted = index == 0;
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space2,
),
child: RapidOrderExampleCard(
example: example,
isHighlighted: isHighlighted,
label: labels.example,
onTap: () =>
BlocProvider.of<RapidOrderBloc>(
context,
).add(
RapidOrderExampleSelected(example),
),
),
);
}),
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space2,
),
child: RapidOrderExampleCard(
example: example,
isHighlighted: isHighlighted,
label: labels.example,
onTap: () =>
BlocProvider.of<RapidOrderBloc>(
context,
).add(
RapidOrderExampleSelected(example),
),
),
);
}),
const SizedBox(height: UiConstants.space4),
// Input
@@ -203,24 +149,23 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
},
hintText: labels.hint,
),
const SizedBox(height: UiConstants.space4),
// Actions
_RapidOrderActions(
labels: labels,
isSubmitting: isSubmitting,
isListening: initialState?.isListening ?? false,
isMessageEmpty:
initialState != null &&
initialState.message.trim().isEmpty,
),
],
);
},
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: _RapidOrderActions(
labels: labels,
isSubmitting: isSubmitting,
isListening: state.isListening,
isTranscribing: state.isTranscribing,
isMessageEmpty: state.message.trim().isEmpty,
),
),
],
);
},
),
),
],
@@ -248,13 +193,6 @@ class _AnimatedZapIcon extends StatelessWidget {
end: Alignment.bottomRight,
),
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),
);
@@ -266,11 +204,13 @@ class _RapidOrderActions extends StatelessWidget {
required this.labels,
required this.isSubmitting,
required this.isListening,
required this.isTranscribing,
required this.isMessageEmpty,
});
final TranslationsClientCreateOrderRapidEn labels;
final bool isSubmitting;
final bool isListening;
final bool isTranscribing;
final bool isMessageEmpty;
@override
@@ -279,11 +219,17 @@ class _RapidOrderActions extends StatelessWidget {
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: isListening ? labels.listening : labels.speak,
text: isTranscribing
? labels.transcribing
: isListening
? labels.listening
: labels.speak,
leadingIcon: UiIcons.microphone,
onPressed: () => BlocProvider.of<RapidOrderBloc>(
context,
).add(const RapidOrderVoiceToggled()),
onPressed: isTranscribing
? null
: () => BlocProvider.of<RapidOrderBloc>(
context,
).add(const RapidOrderVoiceToggled()),
style: OutlinedButton.styleFrom(
backgroundColor: isListening
? UiColors.destructive.withValues(alpha: 0.05)