feat: Implement voice-to-text transcription for rapid order creation and refactor RapidOrderBloc state management.
This commit is contained in:
@@ -51,7 +51,10 @@ void main() async {
|
||||
/// The main application module for the Client app.
|
||||
class AppModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[core_localization.LocalizationModule()];
|
||||
List<Module> get imports => <Module>[
|
||||
core_localization.LocalizationModule(),
|
||||
CoreModule(),
|
||||
];
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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),
|
||||
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));
|
||||
}
|
||||
emit(
|
||||
state.copyWith(message: cleanedExample, status: RapidOrderStatus.initial),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -99,64 +84,26 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
||||
),
|
||||
|
||||
// Content
|
||||
Expanded(
|
||||
child: BlocBuilder<RapidOrderBloc, RapidOrderState>(
|
||||
builder: (BuildContext context, RapidOrderState state) {
|
||||
final bool isSubmitting =
|
||||
state.status == RapidOrderStatus.submitting;
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
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),
|
||||
|
||||
// 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>[
|
||||
// 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,8 +112,7 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Examples
|
||||
if (initialState != null)
|
||||
...initialState.examples.asMap().entries.map((
|
||||
...state.examples.asMap().entries.map((
|
||||
MapEntry<int, String> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
@@ -203,16 +149,19 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
||||
},
|
||||
hintText: labels.hint,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Actions
|
||||
_RapidOrderActions(
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: _RapidOrderActions(
|
||||
labels: labels,
|
||||
isSubmitting: isSubmitting,
|
||||
isListening: initialState?.isListening ?? false,
|
||||
isMessageEmpty:
|
||||
initialState != null &&
|
||||
initialState.message.trim().isEmpty,
|
||||
isListening: state.isListening,
|
||||
isTranscribing: state.isTranscribing,
|
||||
isMessageEmpty: state.message.trim().isEmpty,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -222,10 +171,6 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,9 +219,15 @@ 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>(
|
||||
onPressed: isTranscribing
|
||||
? null
|
||||
: () => BlocProvider.of<RapidOrderBloc>(
|
||||
context,
|
||||
).add(const RapidOrderVoiceToggled()),
|
||||
style: OutlinedButton.styleFrom(
|
||||
|
||||
Reference in New Issue
Block a user