diff --git a/lib/features/voice_to_text/view/voice_to_text_model.dart b/lib/features/voice_to_text/view/voice_to_text_model.dart index 04ba44c..71ec762 100644 --- a/lib/features/voice_to_text/view/voice_to_text_model.dart +++ b/lib/features/voice_to_text/view/voice_to_text_model.dart @@ -10,6 +10,7 @@ abstract class VoiceToTextModel extends Listenable { Stream> get waveformStream; Duration get elapsedDuration; bool get isTimerRunning; + RecordingState get recordingState; void setActiveWord(int index); void toggleCursorVisibility(bool visible); @@ -17,18 +18,28 @@ abstract class VoiceToTextModel extends Listenable { void startTimer(); void pauseTimer(); void resetTimer(); + void startRecording(); + void pauseRecording(); + void resumeRecording(); + void stopRecording(); + void restartRecording(); + void discardRecording(); // Other state already planned (timer, waveform, recording commands) lives here too. } +enum RecordingState { idle, recording, paused, stopped } + class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { VoiceToTextModelState({ required List initialTranscript, int initialActiveWordIndex = 0, bool initialCursorVisible = true, + RecordingState initialRecordingState = RecordingState.idle, }) : _transcript = List.unmodifiable(initialTranscript), _activeWordIndex = initialActiveWordIndex, - _isCursorVisible = initialCursorVisible; + _isCursorVisible = initialCursorVisible, + _recordingState = initialRecordingState; final List _transcript; int _activeWordIndex; @@ -39,6 +50,7 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { Duration _elapsedDuration = Duration.zero; bool _isTimerRunning = false; Timer? _timer; + RecordingState _recordingState; @override List get transcript => _transcript; @@ -61,6 +73,9 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { @override bool get isTimerRunning => _isTimerRunning; + @override + RecordingState get recordingState => _recordingState; + @override void setActiveWord(int index) { if (index == _activeWordIndex) return; @@ -120,6 +135,57 @@ class VoiceToTextModelState extends ChangeNotifier implements VoiceToTextModel { } } + @override + void startRecording() { + if (_recordingState == RecordingState.recording) return; + _recordingState = RecordingState.recording; + startTimer(); + notifyListeners(); + } + + @override + void pauseRecording() { + if (_recordingState != RecordingState.recording) return; + _recordingState = RecordingState.paused; + pauseTimer(); + notifyListeners(); + } + + @override + void resumeRecording() { + if (_recordingState != RecordingState.paused) return; + _recordingState = RecordingState.recording; + startTimer(); + notifyListeners(); + } + + @override + void stopRecording() { + if (_recordingState == RecordingState.stopped) return; + _recordingState = RecordingState.stopped; + resetTimer(); + notifyListeners(); + } + + @override + void restartRecording() { + _recordingState = RecordingState.recording; + resetTimer(); + startTimer(); + notifyListeners(); + } + + @override + void discardRecording() { + _recordingState = RecordingState.idle; + _waveformData = const []; + if (!_waveformController.isClosed) { + _waveformController.add(_waveformData); + } + resetTimer(); + notifyListeners(); + } + @override void dispose() { _timer?.cancel(); diff --git a/lib/features/voice_to_text/view/voice_to_text_screen.dart b/lib/features/voice_to_text/view/voice_to_text_screen.dart index b5c4ab6..978051f 100644 --- a/lib/features/voice_to_text/view/voice_to_text_screen.dart +++ b/lib/features/voice_to_text/view/voice_to_text_screen.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../widget/control_buttons.dart'; import '../widget/text_display.dart'; import '../widget/timer_display.dart'; import '../widget/waveform.dart'; @@ -64,7 +65,6 @@ class _VoiceToTextViewState extends State<_VoiceToTextView> { _waveformTick, (_) => _pushWaveformSample(model), ); - model.startTimer(); }); } @@ -75,11 +75,20 @@ class _VoiceToTextViewState extends State<_VoiceToTextView> { } void _pushWaveformSample(VoiceToTextModel model) { - final sample = List.generate(_waveformSampleCount, (index) { - final variance = math.sin(index / 4) * 0.3 + 0.5; - final noise = (_random.nextDouble() - 0.5) * 0.2; - return (variance + noise).clamp(0.0, 1.0); + final halfCount = _waveformSampleCount ~/ 2; + final leading = List.generate(halfCount, (index) { + final phase = index / halfCount * math.pi; + final base = (math.sin(phase) * 0.5) + 0.5; + final noise = (_random.nextDouble() - 0.5) * 0.15; + return (base + noise).clamp(0.0, 1.0); }); + final trailing = List.from(leading.reversed); + final sample = [...leading, ...trailing]; + if (sample.length < _waveformSampleCount) { + sample.addAll( + List.filled(_waveformSampleCount - sample.length, 0.3), + ); + } model.updateWaveform(sample); } @@ -105,22 +114,49 @@ class _VoiceToTextViewState extends State<_VoiceToTextView> { flex: 3, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), - child: WaveformStream( - stream: model.waveformStream, - initialAmplitudes: model.waveformData, - height: 220, - barColor: Colors.black87, - barWidth: 3, - spacing: 6, - backgroundColor: Colors.transparent, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: WaveformStream( + stream: model.waveformStream, + initialAmplitudes: model.waveformData, + height: 220, + barColor: Colors.black87, + barWidth: 3, + spacing: 6, + backgroundColor: Colors.transparent, + ), + ), ), ), ), TimerDisplay(duration: model.elapsedDuration), + ControlButtonsRow( + state: model.recordingState, + onMicTap: () => _handleMicTap(model), + onPause: model.pauseRecording, + onResume: model.resumeRecording, + onDiscard: model.discardRecording, + ), const SizedBox(height: 24), ], ), ), ); } + + void _handleMicTap(VoiceToTextModelState model) { + switch (model.recordingState) { + case RecordingState.idle: + case RecordingState.stopped: + model.startRecording(); + break; + case RecordingState.recording: + model.stopRecording(); + break; + case RecordingState.paused: + model.stopRecording(); + break; + } + } } diff --git a/lib/features/voice_to_text/widget/control_buttons.dart b/lib/features/voice_to_text/widget/control_buttons.dart new file mode 100644 index 0000000..4673a25 --- /dev/null +++ b/lib/features/voice_to_text/widget/control_buttons.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; + +import '../view/voice_to_text_model.dart'; + +class ControlButtonsRow extends StatelessWidget { + const ControlButtonsRow({ + super.key, + required this.state, + required this.onMicTap, + required this.onPause, + required this.onResume, + required this.onDiscard, + }); + + final RecordingState state; + final VoidCallback onMicTap; + final VoidCallback onPause; + final VoidCallback onResume; + final VoidCallback onDiscard; + + bool get _showSecondary => + state == RecordingState.recording || state == RecordingState.paused; + + @override + Widget build(BuildContext context) { + final leftIcon = state == RecordingState.paused + ? Icons.play_arrow + : Icons.pause; + final leftLabel = state == RecordingState.paused + ? 'Resume recording' + : 'Pause recording'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _showSecondary + ? _SecondaryCircularButton( + key: const ValueKey('pause_button'), + icon: leftIcon, + tooltip: leftLabel, + onPressed: state == RecordingState.paused + ? onResume + : onPause, + ) + : const SizedBox(width: 64, height: 64), + ), + const SizedBox(width: 24), + _MicButton( + isRecording: state == RecordingState.recording, + onPressed: onMicTap, + state: state, + ), + const SizedBox(width: 24), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _showSecondary + ? _SecondaryCircularButton( + key: const ValueKey('discard_button'), + icon: Icons.delete_outline, + tooltip: 'Discard recording', + onPressed: onDiscard, + ) + : const SizedBox(width: 64, height: 64), + ), + ], + ), + ); + } +} + +class _SecondaryCircularButton extends StatelessWidget { + const _SecondaryCircularButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: SizedBox( + width: 64, + height: 64, + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onPressed, + child: Ink( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black.withValues(alpha: 0.2)), + ), + child: Center(child: Icon(icon, color: Colors.black87, size: 28)), + ), + ), + ), + ), + ); + } +} + +class _MicButton extends StatefulWidget { + const _MicButton({ + required this.isRecording, + required this.onPressed, + required this.state, + }); + + final bool isRecording; + final RecordingState state; + final VoidCallback onPressed; + + @override + State<_MicButton> createState() => _MicButtonState(); +} + +class _MicButtonState extends State<_MicButton> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + if (widget.isRecording) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant _MicButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isRecording && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isRecording && _controller.isAnimating) { + _controller + ..stop() + ..reset(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tooltip = switch (widget.state) { + RecordingState.recording => 'Stop recording', + RecordingState.paused => 'Stop recording', + RecordingState.stopped => 'Start new recording', + RecordingState.idle => 'Start recording', + }; + + final button = Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onPressed, + customBorder: const CircleBorder(), + child: Ink( + width: 88, + height: 88, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Color.fromARGB(255, 0, 0, 0), + Color.fromARGB(255, 0, 0, 0), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Icon(Icons.mic, size: 36, color: Colors.white), + ), + ), + ); + + return Semantics( + label: tooltip, + button: true, + child: SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + if (widget.isRecording) + ...List.generate(3, (index) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final progress = (_controller.value + index / 3) % 1; + final scale = 1 + (progress * 0.6); + final opacity = (1 - progress).clamp(0.0, 1.0); + return Transform.scale( + scale: scale, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color.fromARGB( + 255, + 87, + 88, + 88, + ).withValues(alpha: 0.1 * opacity), + ), + ), + ); + }, + ); + }), + Tooltip(message: tooltip, child: button), + ], + ), + ), + ); + } +} diff --git a/lib/features/voice_to_text/widget/waveform.dart b/lib/features/voice_to_text/widget/waveform.dart index 8a489c5..c34d0ad 100644 --- a/lib/features/voice_to_text/widget/waveform.dart +++ b/lib/features/voice_to_text/widget/waveform.dart @@ -1,4 +1,3 @@ -import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -151,14 +150,14 @@ class WaveformPainter extends CustomPainter { return; } + final totalWidth = + amplitudes.length * barWidth + (amplitudes.length - 1) * spacing; + final startX = (size.width - totalWidth) / 2; + final paint = Paint() ..color = barColor ..strokeWidth = barWidth ..strokeCap = StrokeCap.round; - - final totalWidth = - amplitudes.length * barWidth + (amplitudes.length - 1) * spacing; - final startX = math.max((size.width - totalWidth) / 2, 0.0); final maxHeight = size.height; for (var i = 0; i < amplitudes.length; i++) { diff --git a/pubspec.lock b/pubspec.lock index 1956a6a..8f3e021 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -142,6 +142,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -179,6 +184,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" json_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3271841..05df03c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,8 @@ dependencies: flutter: sdk: flutter provider: ^6.1.5+1 + flutter_localizations: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/test/features/voice_to_text/view/voice_to_text_model_test.dart b/test/features/voice_to_text/view/voice_to_text_model_test.dart index 9416ad3..978dca5 100644 --- a/test/features/voice_to_text/view/voice_to_text_model_test.dart +++ b/test/features/voice_to_text/view/voice_to_text_model_test.dart @@ -132,5 +132,47 @@ void main() { model.dispose(); }); }); + + test('recording state transitions control timer lifecycle', () { + fakeAsync((async) { + final model = VoiceToTextModelState(initialTranscript: transcript); + + expect(model.recordingState, RecordingState.idle); + + model.startRecording(); + expect(model.recordingState, RecordingState.recording); + async.elapse(const Duration(seconds: 2)); + expect(model.elapsedDuration, const Duration(seconds: 2)); + + model.pauseRecording(); + expect(model.recordingState, RecordingState.paused); + async.elapse(const Duration(seconds: 1)); + expect(model.elapsedDuration, const Duration(seconds: 2)); + + model.resumeRecording(); + expect(model.recordingState, RecordingState.recording); + async.elapse(const Duration(seconds: 1)); + expect(model.elapsedDuration, const Duration(seconds: 3)); + + model.stopRecording(); + expect(model.recordingState, RecordingState.stopped); + expect(model.elapsedDuration, Duration.zero); + + model.restartRecording(); + expect(model.recordingState, RecordingState.recording); + expect(model.elapsedDuration, Duration.zero); + + async.elapse(const Duration(seconds: 1)); + expect(model.elapsedDuration, const Duration(seconds: 1)); + + model.updateWaveform([0.2, 0.4]); + model.discardRecording(); + expect(model.recordingState, RecordingState.idle); + expect(model.elapsedDuration, Duration.zero); + expect(model.waveformData, isEmpty); + + model.dispose(); + }); + }); }); } diff --git a/test/features/voice_to_text/widget/control_buttons_test.dart b/test/features/voice_to_text/widget/control_buttons_test.dart new file mode 100644 index 0000000..2c3e7e4 --- /dev/null +++ b/test/features/voice_to_text/widget/control_buttons_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:iva_mobile/features/voice_to_text/view/voice_to_text_model.dart'; +import 'package:iva_mobile/features/voice_to_text/widget/control_buttons.dart'; + +void main() { + testWidgets('shows only microphone button when idle', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + supportedLocales: const [Locale('en', 'US')], + home: Scaffold( + body: ControlButtonsRow( + state: RecordingState.idle, + onMicTap: _noop, + onPause: _noop, + onResume: _noop, + onDiscard: _noop, + ), + ), + ), + ); + + expect(find.byIcon(Icons.mic), findsOneWidget); + expect(find.byIcon(Icons.pause), findsNothing); + expect(find.byIcon(Icons.delete_outline), findsNothing); + }); + + testWidgets('shows pause and discard while recording', (tester) async { + RecordingState currentState = RecordingState.recording; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + supportedLocales: const [Locale('en', 'US')], + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return ControlButtonsRow( + state: currentState, + onMicTap: () {}, + onPause: () => setState(() { + currentState = RecordingState.paused; + }), + onResume: () => setState(() { + currentState = RecordingState.recording; + }), + onDiscard: () => setState(() { + currentState = RecordingState.idle; + }), + ); + }, + ), + ), + ), + ); + + expect(find.byIcon(Icons.pause), findsOneWidget); + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + + await tester.tap(find.byIcon(Icons.pause)); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + + await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.pumpAndSettle(); + + expect(currentState, RecordingState.idle); + }); +} + +void _noop() {}